Files
senboost/scripts/make-tray-icons.mjs
maeneko 781d8a574f Добавлена работа в трее и версия 0.6.1
Приложение теперь может сворачиваться в трей вместо закрытия: обход
продолжает работать в фоне, иконка в трее показывает статус и
позволяет включить/выключить обход без открытия окна. Добавлен
автозапуск самого приложения вместе с системой (свёрнутым в трей).
2026-09-04 20:01:05 +03:00

141 lines
5.4 KiB
JavaScript

// Рисует иконки для трея: щит с заливкой («обход включён») и щит контуром («выключен»),
// по паре размеров на каждую (16 и 32 px — 1x и 2x для меню-бара macOS).
//
// Чёрный цвет + альфа — это ровно то, что macOS ждёт от template-картинки: систему интересует
// только альфа-канал, цвет она подставляет сама под светлое или тёмное меню-бар.
//
// Скрипт запускается руками (`node scripts/make-tray-icons.mjs`), результат лежит в
// resources/tray и коммитится: в сборку его не включаем, чтобы не тащить генерацию картинок
// в каждый `npm run build`.
import { deflateSync } from 'node:zlib'
import { mkdir, writeFile } from 'node:fs/promises'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
const OUT_DIR = join(dirname(fileURLToPath(import.meta.url)), '..', 'resources', 'tray')
/** Сглаживание: считаем SUPERSAMPLE² проб на пиксель и усредняем — иначе на 16 px рвань. */
const SUPERSAMPLE = 4
/** Толщина контура у «выключенного» щита, в долях размера. */
const STROKE = 0.11
/** Центр, относительно которого сжимаем щит, чтобы получить внутренний край контура. */
const CENTER = { x: 0.5, y: 0.54 }
const TOP = 0.1
const BOTTOM = 0.94
/** Ниже этой линии бока щита сходятся к острию. */
const SHOULDER = 0.56
const HALF_WIDTH = 0.34
/** Скругление верхних углов. */
const CORNER = 0.1
/**
* Точка внутри щита? Щит — прямоугольник со скруглённым верхом, ниже «плеч» сужающийся
* по четверти эллипса к острию внизу.
*/
function insideShield(x, y) {
if (y < TOP || y > BOTTOM) return false
let halfWidth = HALF_WIDTH
if (y > SHOULDER) {
const t = (y - SHOULDER) / (BOTTOM - SHOULDER)
halfWidth = HALF_WIDTH * Math.sqrt(Math.max(0, 1 - t * t))
}
const dx = Math.abs(x - 0.5)
if (dx > halfWidth) return false
// Скругление верхних углов: за пределами прямых участков считаем расстояние до центра дуги.
if (y < TOP + CORNER && dx > halfWidth - CORNER) {
const cx = halfWidth - CORNER
const cy = TOP + CORNER
return (dx - cx) ** 2 + (y - cy) ** 2 <= CORNER * CORNER
}
return true
}
/** Тот же щит, сжатый к центру на толщину контура, — внутренняя граница обводки. */
function insideInnerShield(x, y) {
const scale = 1 / (1 - 2 * STROKE)
return insideShield((x - CENTER.x) * scale + CENTER.x, (y - CENTER.y) * scale + CENTER.y)
}
/** Альфа пикселя: доля проб, попавших в фигуру. */
function coverage(px, py, size, filled) {
let hits = 0
for (let sy = 0; sy < SUPERSAMPLE; sy++) {
for (let sx = 0; sx < SUPERSAMPLE; sx++) {
const x = (px + (sx + 0.5) / SUPERSAMPLE) / size
const y = (py + (sy + 0.5) / SUPERSAMPLE) / size
if (!insideShield(x, y)) continue
if (!filled && insideInnerShield(x, y)) continue
hits++
}
}
return Math.round((hits / (SUPERSAMPLE * SUPERSAMPLE)) * 255)
}
function rgba(size, filled) {
// По байту на канал, плюс ведущий байт фильтра в начале каждой строки (формат PNG).
const rows = Buffer.alloc(size * (size * 4 + 1))
let offset = 0
for (let y = 0; y < size; y++) {
rows[offset++] = 0 // filter type 0 (None)
for (let x = 0; x < size; x++) {
offset += 3 // R,G,B остаются нулями — чёрный
rows[offset++] = coverage(x, y, size, filled)
}
}
return rows
}
function chunk(type, data) {
const length = Buffer.alloc(4)
length.writeUInt32BE(data.length)
const body = Buffer.concat([Buffer.from(type, 'ascii'), data])
const crc = Buffer.alloc(4)
crc.writeUInt32BE(crc32(body))
return Buffer.concat([length, body, crc])
}
const CRC_TABLE = Array.from({ length: 256 }, (_, n) => {
let c = n
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1
return c >>> 0
})
function crc32(buffer) {
let c = 0xffffffff
for (const byte of buffer) c = CRC_TABLE[(c ^ byte) & 0xff] ^ (c >>> 8)
return (c ^ 0xffffffff) >>> 0
}
function png(size, filled) {
const header = Buffer.alloc(13)
header.writeUInt32BE(size, 0)
header.writeUInt32BE(size, 4)
header[8] = 8 // бит на канал
header[9] = 6 // цветовой тип: RGBA
return Buffer.concat([
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
chunk('IHDR', header),
chunk('IDAT', deflateSync(rgba(size, filled), { level: 9 })),
chunk('IEND', Buffer.alloc(0))
])
}
await mkdir(OUT_DIR, { recursive: true })
for (const [name, filled] of [
['on', true],
['off', false]
]) {
for (const size of [16, 32]) {
const path = join(OUT_DIR, `${name}-${size}.png`)
await writeFile(path, png(size, filled))
console.log(`создано: ${path}`)
}
}