diff --git a/apps/desktop/build/hunter-notes.svg b/apps/desktop/build/hunter-notes.svg new file mode 100644 index 0000000..2950a5d --- /dev/null +++ b/apps/desktop/build/hunter-notes.svg @@ -0,0 +1,77 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/desktop/build/icon.ico b/apps/desktop/build/icon.ico new file mode 100644 index 0000000..9b293c8 Binary files /dev/null and b/apps/desktop/build/icon.ico differ diff --git a/apps/desktop/build/icon/128.png b/apps/desktop/build/icon/128.png new file mode 100644 index 0000000..56ba335 Binary files /dev/null and b/apps/desktop/build/icon/128.png differ diff --git a/apps/desktop/build/icon/16.png b/apps/desktop/build/icon/16.png new file mode 100644 index 0000000..75aaed1 Binary files /dev/null and b/apps/desktop/build/icon/16.png differ diff --git a/apps/desktop/build/icon/24.png b/apps/desktop/build/icon/24.png new file mode 100644 index 0000000..f2d6a8a Binary files /dev/null and b/apps/desktop/build/icon/24.png differ diff --git a/apps/desktop/build/icon/256.png b/apps/desktop/build/icon/256.png new file mode 100644 index 0000000..b4f2141 Binary files /dev/null and b/apps/desktop/build/icon/256.png differ diff --git a/apps/desktop/build/icon/32.png b/apps/desktop/build/icon/32.png new file mode 100644 index 0000000..259a0d8 Binary files /dev/null and b/apps/desktop/build/icon/32.png differ diff --git a/apps/desktop/build/icon/48.png b/apps/desktop/build/icon/48.png new file mode 100644 index 0000000..a2ce2c7 Binary files /dev/null and b/apps/desktop/build/icon/48.png differ diff --git a/apps/desktop/build/icon/512.png b/apps/desktop/build/icon/512.png new file mode 100644 index 0000000..6d78783 Binary files /dev/null and b/apps/desktop/build/icon/512.png differ diff --git a/apps/desktop/build/icon/64.png b/apps/desktop/build/icon/64.png new file mode 100644 index 0000000..cd0d8e5 Binary files /dev/null and b/apps/desktop/build/icon/64.png differ diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 79fec79..79fbc70 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -18,6 +18,7 @@ ], "asar": true, "win": { + "icon": "build/icon.ico", "target": [ "nsis", "dir" diff --git a/apps/desktop/scripts/make-icon.mjs b/apps/desktop/scripts/make-icon.mjs new file mode 100644 index 0000000..c830199 --- /dev/null +++ b/apps/desktop/scripts/make-icon.mjs @@ -0,0 +1,288 @@ +import { deflateSync } from 'node:zlib'; +import { promises as fs } from 'node:fs'; +import nodePath from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = nodePath.dirname(fileURLToPath(import.meta.url)); +const OUT = nodePath.join(__dirname, '..', 'build'); +const SIZES = [16, 24, 32, 48, 64, 128, 256, 512]; + +// ---------- PNG encoder ---------- +const CRC_TABLE = (() => { + const t = new Uint32Array(256); + for (let n = 0; n < 256; n++) { + let c = n; + for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; + t[n] = c >>> 0; + } + return t; +})(); +function crc32(buf) { + let c = 0xffffffff; + for (let i = 0; i < buf.length; i++) c = CRC_TABLE[(c ^ buf[i]) & 0xff] ^ (c >>> 8); + return (c ^ 0xffffffff) >>> 0; +} +function pngChunk(type, data) { + const len = Buffer.alloc(4); len.writeUInt32BE(data.length, 0); + const typeBuf = Buffer.from(type, 'ascii'); + const crcBuf = Buffer.alloc(4); crcBuf.writeUInt32BE(crc32(Buffer.concat([typeBuf, data])), 0); + return Buffer.concat([len, typeBuf, data, crcBuf]); +} +function encodePNG(w, h, rgba) { + const sig = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); + const ihdr = Buffer.alloc(13); + ihdr.writeUInt32BE(w, 0); ihdr.writeUInt32BE(h, 4); + ihdr[8] = 8; ihdr[9] = 6; // bit depth 8, color type 6 (RGBA) + ihdr[10] = 0; ihdr[11] = 0; ihdr[12] = 0; + const stride = w * 4; + const raw = Buffer.alloc(h * (stride + 1)); + for (let y = 0; y < h; y++) { + raw[y * (stride + 1)] = 0; // filter None + rgba.copy(raw, y * (stride + 1) + 1, y * stride, (y + 1) * stride); + } + const idat = pngChunk('IDAT', deflateSync(raw, { level: 9 })); + return Buffer.concat([sig, pngChunk('IHDR', ihdr), idat, pngChunk('IEND', Buffer.alloc(0))]); +} + +// ---------- ICO encoder ---------- +function encodeICO(sizes, pngs) { + const header = Buffer.alloc(6); + header.writeUInt16LE(0, 0); header.writeUInt16LE(1, 2); header.writeUInt16LE(sizes.length, 4); + const entries = []; + let offset = 6 + 16 * sizes.length; + for (let i = 0; i < sizes.length; i++) { + const s = sizes[i]; + const e = Buffer.alloc(16); + e[0] = s >= 256 ? 0 : s; e[1] = s >= 256 ? 0 : s; + e[2] = 0; e[3] = 0; + e.writeUInt16LE(1, 4); e.writeUInt16LE(32, 6); + e.writeUInt32LE(pngs[i].length, 8); + e.writeUInt32LE(offset, 12); + entries.push(e); + offset += pngs[i].length; + } + return Buffer.concat([header, ...entries, ...pngs]); +} + +// ---------- Rasterizer ---------- +function blend(buf, W, x, y, r, g, b, a) { + if (a <= 0.001) return; + x = Math.round(x); y = Math.round(y); + if (x < 0 || y < 0 || x >= W || y >= W) return; + const i = (y * W + x) * 4; + const da = buf[i + 3] / 255; + const oa = a + da * (1 - a); + if (oa <= 0) return; + buf[i] = Math.round((r * a + buf[i] * da * (1 - a)) / oa); + buf[i + 1] = Math.round((g * a + buf[i + 1] * da * (1 - a)) / oa); + buf[i + 2] = Math.round((b * a + buf[i + 2] * da * (1 - a)) / oa); + buf[i + 3] = Math.round(oa * 255); +} +function inRR(px, py, x, y, w, h, r) { + if (px < x || px > x + w || py < y || py > y + h) return false; + const cx = Math.max(x + r, Math.min(px, x + w - r)); + const cy = Math.max(y + r, Math.min(py, y + h - r)); + const dx = px - cx, dy = py - cy; + return dx * dx + dy * dy <= r * r; +} +function inCircle(px, py, cx, cy, r) { + const dx = px - cx, dy = py - cy; + return dx * dx + dy * dy <= r * r; +} +function lerp(a, b, t) { return a + (b - a) * t; } +function lerpColor(c1, c2, t) { return [lerp(c1[0], c2[0], t), lerp(c1[1], c2[1], t), lerp(c1[2], c2[2], t)]; } + +// linear gradient stops: [[pos, [r,g,b], alpha], ...] +function vGradientColor(stops, y, y0, y1) { + const t = Math.min(1, Math.max(0, (y - y0) / (y1 - y0))); + for (let i = 0; i < stops.length - 1; i++) { + const a = stops[i], b = stops[i + 1]; + if (t >= a[0] && t <= b[0]) { + const lt = (t - a[0]) / (b[0] - a[0] || 1); + return [lerp(a[1][0], b[1][0], lt), lerp(a[1][1], b[1][1], lt), lerp(a[1][2], b[1][2], lt), lerp(a[2], b[2], lt)]; + } + } + const last = stops[stops.length - 1]; + return [...last[1], last[2]]; +} + +// Box blur on alpha channel (for soft shadows) +function boxBlur(buf, W, radius) { + const H = W; + const tmp = new Float32Array(W * H); + for (let i = 0; i < W * H; i++) tmp[i] = buf[i * 4 + 3] / 255; + const src = tmp.slice(); + const win = radius * 2 + 1; + // horizontal + for (let y = 0; y < H; y++) { + let acc = 0; + for (let x = -radius; x <= radius; x++) acc += src[Math.max(0, Math.min(W - 1, x)) + y * W]; + for (let x = 0; x < W; x++) { + tmp[x + y * W] = acc / win; + const add = Math.min(W - 1, x + radius + 1), rem = Math.max(0, x - radius); + acc += src[add + y * W] - src[rem + y * W]; + } + } + const src2 = tmp.slice(); + for (let x = 0; x < W; x++) { + let acc = 0; + for (let y = -radius; y <= radius; y++) acc += src2[x + Math.max(0, Math.min(H - 1, y)) * W]; + for (let y = 0; y < H; y++) { + tmp[x + y * W] = acc / win; + const add = Math.min(H - 1, y + radius + 1), rem = Math.max(0, y - radius); + acc += src2[x + add * W] - src2[x + rem * W]; + } + } + for (let i = 0; i < W * H; i++) buf[i * 4 + 3] = Math.round(tmp[i] * 255); +} + +// Affine: rotate around pivot +function rotPoint(px, py, px_, py_, cosA, sinA) { + const dx = px - px_, dy = py - py_; + return [px_ + dx * cosA - dy * sinA, py_ + dx * sinA + dy * cosA]; +} + +function render(size) { + const hi = Math.max(256, size * 2); + const k = hi / 512; + const W = hi; + const buf = new Uint8ClampedArray(W * W * 4); + // 循环坐标即缓冲区坐标(缓冲区 W=hi,循环 0..hi);k 只用于设计坐标→缓冲区换算 + const blendAt = (x, y, r, g, b, a) => blend(buf, W, x, y, r, g, b, a); + const sA = (ang) => Math.sin(ang), cA = (ang) => Math.cos(ang); + + // ---- Tile base (vertical gradient, rounded 116) ---- + const tileStops = [[0, [0x1a, 0x1e, 0x2b], 1], [0.55, [0x16, 0x1a, 0x26], 1], [1, [0x10, 0x13, 0x1c], 1]]; + for (let y = 0; y < hi; y++) { + const c = vGradientColor(tileStops, y / k, 0, 512); + for (let x = 0; x < hi; x++) { + if (inRR(x / k, y / k, 0, 0, 512, 512, 116)) blendAt(x, y, c[0], c[1], c[2], 1); + } + } + + // ---- Top sheen (rounded top, white 0.09 -> 0) ---- + for (let y = 0; y < hi; y++) { + const t = Math.min(1, Math.max(0, (y / k) / 260)); + const a = 0.09 * (1 - t); + for (let x = 0; x < hi; x++) { + if (a > 0.002 && inRR(x / k, y / k, 0, 0, 512, 512, 116)) blendAt(x, y, 255, 255, 255, a); + } + } + + // ---- Stars ---- + const stars = [[96, 120, 2.6, 0.5], [150, 196, 1.7, 0.5], [420, 360, 2, 0.5], [356, 92, 1.6, 0.5], [88, 340, 1.8, 0.5], [452, 200, 2.2, 0.5]]; + for (const [sx, sy, sr, sa] of stars) { + const r0 = Math.max(0, Math.floor((sy - sr - 1) * k)), r1 = Math.min(hi, Math.ceil((sy + sr + 1) * k)); + const c0 = Math.max(0, Math.floor((sx - sr - 1) * k)), c1 = Math.min(hi, Math.ceil((sx + sr + 1) * k)); + for (let y = r0; y < r1; y++) for (let x = c0; x < c1; x++) { + if (inCircle(x / k, y / k, sx, sy, sr)) blendAt(x, y, 231, 236, 255, sa); + } + } + + // ---- Moon: glow + crescent ---- + // glow + for (let y = 0; y < hi; y++) for (let x = 0; x < hi; x++) { + const dx = x / k - 404, dy = y / k - 116; + const d = Math.sqrt(dx * dx + dy * dy); + if (d <= 62) blendAt(x, y, 238, 242, 255, 0.22 * (1 - d / 62)); + } + // crescent (rotate -18 deg around (404,116)) + const cosA = cA(-18 * Math.PI / 180), sinA = sA(-18 * Math.PI / 180); + for (let y = 0; y < hi; y++) for (let x = 0; x < hi; x++) { + const [lx, ly] = rotPoint(x / k, y / k, 404, 116, cosA, sinA); + if (inCircle(lx, ly, 404, 116, 40)) { + const t = Math.min(1, Math.max(0, (ly - 76) / 80)); + const mc = lerpColor([255, 255, 255], [201, 212, 242], t); + blendAt(x, y, mc[0], mc[1], mc[2], 1); + } + if (inCircle(lx, ly, 418, 106, 36)) blendAt(x, y, 0x1a, 0x1e, 0x2b, 1); + } + + // ---- Card drop shadow (behind card, dark blurred, offset down-right) ---- + const cardCos = cA(-6 * Math.PI / 180), cardSin = sA(-6 * Math.PI / 180); + const cardLocal = (x, y) => rotPoint(x / k, y / k, 256, 268, cardCos, cardSin); + const shadowBuf = new Uint8ClampedArray(W * W * 4); + for (let y = 0; y < hi; y++) for (let x = 0; x < hi; x++) { + const [lx, ly] = cardLocal(x, y); + if (inRR(lx + 4, ly + 12, 96, 150, 320, 236, 44)) { + const i = (y * W + x) * 4; + shadowBuf[i] = 0; shadowBuf[i + 1] = 0; shadowBuf[i + 2] = 0; shadowBuf[i + 3] = 255; + } + } + boxBlur(shadowBuf, W, Math.max(2, Math.round(18 * k / 4))); + for (let i = 0; i < W * W; i++) { + const a = shadowBuf[i * 4 + 3] / 255 * 0.5; + if (a > 0.002) { + const x = i % W, y = (i / W) | 0; + blend(buf, W, x, y, 0, 0, 0, a); + } + } + + // ---- Card group (rotate -6 deg around 256,268) ---- + // draw card (and inner bar + lines) via local coords + for (let y = 0; y < hi; y++) for (let x = 0; x < hi; x++) { + const [lx, ly] = cardLocal(x, y); + // card body: gradient white 0.16->0.07 + if (inRR(lx, ly, 96, 150, 320, 236, 44)) { + const t = Math.min(1, Math.max(0, (ly - 150) / 236)); + const a = 0.16 - 0.09 * t; + blendAt(x, y, 255, 255, 255, a); + } + // inner top light bar + if (inRR(lx, ly, 112, 164, 288, 30, 15)) blendAt(x, y, 255, 255, 255, 0.10); + // card border (ring): draw by testing just outside inner, inside outer + // note lines + if (inRR(lx, ly, 140, 338, 150, 11, 5.5)) blendAt(x, y, 255, 255, 255, 0.55); + if (inRR(lx, ly, 140, 360, 110, 11, 5.5)) blendAt(x, y, 255, 255, 255, 0.38); + // H monogram + const hStops = [[0, [0xf2, 0xf5, 0xff], 1], [1, [0xa7, 0xb8, 0xf0], 1]]; + const hc = vGradientColor(hStops, ly, 210, 330); + if (inRR(lx, ly, 204, 210, 46, 120, 23) || inRR(lx, ly, 286, 210, 46, 120, 23) || inRR(lx, ly, 227, 254, 82, 42, 21)) { + blendAt(x, y, hc[0], hc[1], hc[2], 1); + } + } + // card border: stroke ring (approx) - draw after body + for (let y = 0; y < hi; y++) for (let x = 0; x < hi; x++) { + const [lx, ly] = cardLocal(x, y); + if (inRR(lx, ly, 96, 150, 320, 236, 44) && !inRR(lx, ly, 100, 154, 312, 228, 40)) { + blendAt(x, y, 255, 255, 255, 0.4); + } + } + + // ---- Downsample hi -> size (box average, straight alpha) ---- + if (size === hi) return Buffer.from(buf); + const out = Buffer.alloc(size * size * 4); + const ratio = hi / size; + for (let y = 0; y < size; y++) { + for (let x = 0; x < size; x++) { + let pr = 0, pg = 0, pb = 0, pa = 0; + const x0 = Math.floor(x * ratio), y0 = Math.floor(y * ratio); + const x1 = Math.min(hi, Math.ceil((x + 1) * ratio)), y1 = Math.min(hi, Math.ceil((y + 1) * ratio)); + for (let yy = y0; yy < y1; yy++) for (let xx = x0; xx < x1; xx++) { + const i = (yy * W + xx) * 4; + const aa = buf[i + 3] / 255; + if (aa > 0) { pr += buf[i] * aa; pg += buf[i + 1] * aa; pb += buf[i + 2] * aa; pa += aa; } + } + const o = (y * size + x) * 4; + if (pa > 0) { out[o] = Math.round(pr / pa); out[o + 1] = Math.round(pg / pa); out[o + 2] = Math.round(pb / pa); out[o + 3] = Math.round(pa * 255); } + } + } + return out; +} + +// ---------- Main ---------- +async function main() { + const pngs = []; + for (const s of SIZES) { + const rgba = render(s); + const png = encodePNG(s, s, rgba); + await fs.writeFile(nodePath.join(OUT, 'icon', s + '.png'), png); + pngs.push(png); + console.log('rendered', s + 'px', png.length, 'bytes'); + } + const ico = encodeICO(SIZES, pngs); + await fs.writeFile(nodePath.join(OUT, 'icon.ico'), ico); + console.log('icon.ico', ico.length, 'bytes'); + console.log('DONE'); +} +await main();