@n1h/sweetview 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/LICENSE-0BSD +12 -0
- package/README.md +268 -3
- package/docs/README.md +26 -0
- package/docs/app.md +523 -0
- package/docs/build.md +169 -0
- package/docs/ipc-ws.md +202 -0
- package/docs/ipc.md +323 -0
- package/docs/native.md +1066 -0
- package/docs/page.md +71 -0
- package/docs/settings-store.md +125 -0
- package/docs/webview-library.md +104 -0
- package/docs/window-chrome.md +221 -0
- package/docs/worker.md +72 -0
- package/file-imports.d.ts +19 -0
- package/package.json +66 -4
- package/src/app.ts +350 -0
- package/src/build.ts +118 -0
- package/src/html.ts +71 -0
- package/src/index.ts +8 -0
- package/src/ipc/bridge.ts +41 -0
- package/src/ipc/frame.ts +16 -0
- package/src/ipc/index.ts +16 -0
- package/src/ipc/memory.ts +33 -0
- package/src/ipc/service.ts +75 -0
- package/src/ipc-ws/client.ts +111 -0
- package/src/ipc-ws/index.ts +5 -0
- package/src/ipc-ws/server.ts +133 -0
- package/src/make-app.ts +93 -0
- package/src/messages.ts +16 -0
- package/src/native/app-icon.ts +23 -0
- package/src/native/app-menu.ts +18 -0
- package/src/native/darwin/app-icon.ts +10 -0
- package/src/native/darwin/app-menu.ts +88 -0
- package/src/native/darwin/folder-picker.ts +29 -0
- package/src/native/darwin/image.ts +47 -0
- package/src/native/darwin/menu.ts +89 -0
- package/src/native/darwin/notifier.ts +95 -0
- package/src/native/darwin/objc.ts +246 -0
- package/src/native/darwin/overlay.ts +352 -0
- package/src/native/darwin/tray.ts +129 -0
- package/src/native/darwin/window.ts +64 -0
- package/src/native/folder-picker.ts +15 -0
- package/src/native/hit-regions.ts +162 -0
- package/src/native/icon.ts +530 -0
- package/src/native/index.ts +38 -0
- package/src/native/menu.ts +43 -0
- package/src/native/notifications.ts +40 -0
- package/src/native/overlay.ts +182 -0
- package/src/native/platform.ts +25 -0
- package/src/native/tray.ts +41 -0
- package/src/native/win32/api.ts +234 -0
- package/src/native/win32/app-icon.ts +5 -0
- package/src/native/win32/app-menu.ts +4 -0
- package/src/native/win32/folder-picker.ts +121 -0
- package/src/native/win32/icon.ts +85 -0
- package/src/native/win32/menu.ts +107 -0
- package/src/native/win32/message-window.ts +72 -0
- package/src/native/win32/notifier.ts +28 -0
- package/src/native/win32/overlay.ts +499 -0
- package/src/native/win32/tray.ts +228 -0
- package/src/native/win32/window.ts +77 -0
- package/src/native/window.ts +40 -0
- package/src/page.ts +48 -0
- package/src/ring.ts +110 -0
- package/src/settings-store.ts +101 -0
- package/src/webview-lib.ts +60 -0
- package/src/window-chrome.ts +118 -0
- package/src/worker.ts +72 -0
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
// Hit regions: the polygons of a window that take clicks, packed flat so a
|
|
2
|
+
// cursor poll tests them without allocating. Platform-free; both overlays use
|
|
3
|
+
// it on every timer tick. A rectangle is a four-point polygon; a mesh's
|
|
4
|
+
// silhouette is the convex hull of its projected bounding-box corners.
|
|
5
|
+
|
|
6
|
+
import type { HitRefiner, Overlay, Point } from "./overlay";
|
|
7
|
+
|
|
8
|
+
/** One region: a simple polygon in window-local units, any winding. */
|
|
9
|
+
export type HitPolygon = Point[];
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Polygons as one flat number array, the shape to send from the page in an
|
|
13
|
+
* IPC method (JSON of a flat array is a fraction of an array of point
|
|
14
|
+
* objects): per polygon a point count, then that many x, y pairs.
|
|
15
|
+
*/
|
|
16
|
+
export function flattenRegions(polygons: HitPolygon[]): number[] {
|
|
17
|
+
const flat: number[] = [];
|
|
18
|
+
for (const poly of polygons) {
|
|
19
|
+
flat.push(poly.length);
|
|
20
|
+
for (const p of poly) flat.push(p.x, p.y);
|
|
21
|
+
}
|
|
22
|
+
return flat;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** The inverse of `flattenRegions`, for the UI-thread method that receives them. */
|
|
26
|
+
export function unpackRegions(flat: readonly number[]): HitPolygon[] {
|
|
27
|
+
const polygons: HitPolygon[] = [];
|
|
28
|
+
for (let i = 0; i < flat.length; ) {
|
|
29
|
+
const count = flat[i++]!;
|
|
30
|
+
const polygon: HitPolygon = [];
|
|
31
|
+
for (let k = 0; k < count; k++) polygon.push({ x: flat[i++]!, y: flat[i++]! });
|
|
32
|
+
polygons.push(polygon);
|
|
33
|
+
}
|
|
34
|
+
return polygons;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** A question for the page: is its drawing solid at this window-local point? `id` comes back in the answer. */
|
|
38
|
+
export type HitProbe = Point & { id: number };
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The UI-thread side of exact click-through: the page sends its polygons
|
|
42
|
+
* (coarse), the overlay asks for the exact answer inside one through
|
|
43
|
+
* `probe` (an event to the page), and the page's reply comes back through
|
|
44
|
+
* `answer`. See `Overlay.setHitRegions`. A reply to anything but the latest
|
|
45
|
+
* probe is dropped; a page that never replies leaves polygon precision.
|
|
46
|
+
*/
|
|
47
|
+
export type HitRelay = {
|
|
48
|
+
/** The page's latest polygons (window-local units). Applied at once when enabled. */
|
|
49
|
+
setRegions(polygons: HitPolygon[]): void;
|
|
50
|
+
/** The page's reply to the probe with that id. */
|
|
51
|
+
answer(reply: { id: number; solid: boolean }): void;
|
|
52
|
+
/** Off: the whole window takes clicks, whatever the regions say (a "click through" toggle). Default on. */
|
|
53
|
+
setEnabled(on: boolean): void;
|
|
54
|
+
enabled(): boolean;
|
|
55
|
+
/** Re-sends the current state to the overlay; after creating the webview, say. */
|
|
56
|
+
apply(): void;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
export function createHitRelay(overlay: Pick<Overlay, "setHitRegions">, probe: (question: HitProbe) => void): HitRelay {
|
|
60
|
+
let regions: HitPolygon[] = [];
|
|
61
|
+
let on = true;
|
|
62
|
+
let seq = 0;
|
|
63
|
+
let pending: { id: number; answer: (solid: boolean) => void } | undefined;
|
|
64
|
+
const refine: HitRefiner = (local, reply) => {
|
|
65
|
+
const id = ++seq;
|
|
66
|
+
pending = { id, answer: reply };
|
|
67
|
+
probe({ id, x: local.x, y: local.y });
|
|
68
|
+
};
|
|
69
|
+
const apply = () => overlay.setHitRegions(on ? regions : null, refine);
|
|
70
|
+
return {
|
|
71
|
+
setRegions(polygons) {
|
|
72
|
+
regions = polygons;
|
|
73
|
+
apply();
|
|
74
|
+
},
|
|
75
|
+
answer({ id, solid }) {
|
|
76
|
+
if (pending?.id === id) pending.answer(solid);
|
|
77
|
+
},
|
|
78
|
+
setEnabled(next) {
|
|
79
|
+
on = next;
|
|
80
|
+
apply();
|
|
81
|
+
},
|
|
82
|
+
enabled: () => on,
|
|
83
|
+
apply,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Per polygon: [count, minX, minY, maxX, maxY, x0, y0, x1, y1, ...]. The
|
|
89
|
+
* bounds come first so most polygons are rejected with four comparisons.
|
|
90
|
+
* Polygons with fewer than three points are dropped.
|
|
91
|
+
*/
|
|
92
|
+
export function packRegions(polygons: HitPolygon[]): Int32Array {
|
|
93
|
+
const kept = polygons.filter((p) => p.length >= 3);
|
|
94
|
+
const packed = new Int32Array(kept.reduce((n, p) => n + 5 + p.length * 2, 0));
|
|
95
|
+
let o = 0;
|
|
96
|
+
for (const poly of kept) {
|
|
97
|
+
let minX = Infinity;
|
|
98
|
+
let minY = Infinity;
|
|
99
|
+
let maxX = -Infinity;
|
|
100
|
+
let maxY = -Infinity;
|
|
101
|
+
for (const p of poly) {
|
|
102
|
+
minX = Math.min(minX, p.x);
|
|
103
|
+
minY = Math.min(minY, p.y);
|
|
104
|
+
maxX = Math.max(maxX, p.x);
|
|
105
|
+
maxY = Math.max(maxY, p.y);
|
|
106
|
+
}
|
|
107
|
+
packed[o++] = poly.length;
|
|
108
|
+
packed[o++] = Math.floor(minX);
|
|
109
|
+
packed[o++] = Math.floor(minY);
|
|
110
|
+
packed[o++] = Math.ceil(maxX);
|
|
111
|
+
packed[o++] = Math.ceil(maxY);
|
|
112
|
+
for (const p of poly) {
|
|
113
|
+
packed[o++] = Math.round(p.x);
|
|
114
|
+
packed[o++] = Math.round(p.y);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return packed;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Is the point inside any packed polygon? Bounds first, then even-odd ray casting. Hundreds of polygons cost microseconds. */
|
|
121
|
+
export function hitRegions(packed: Int32Array, x: number, y: number): boolean {
|
|
122
|
+
let o = 0;
|
|
123
|
+
while (o < packed.length) {
|
|
124
|
+
const count = packed[o]!;
|
|
125
|
+
const inBounds = x >= packed[o + 1]! && x <= packed[o + 3]! && y >= packed[o + 2]! && y <= packed[o + 4]!;
|
|
126
|
+
const first = o + 5;
|
|
127
|
+
if (inBounds) {
|
|
128
|
+
let inside = false;
|
|
129
|
+
for (let i = 0, j = count - 1; i < count; j = i++) {
|
|
130
|
+
const xi = packed[first + i * 2]!;
|
|
131
|
+
const yi = packed[first + i * 2 + 1]!;
|
|
132
|
+
const xj = packed[first + j * 2]!;
|
|
133
|
+
const yj = packed[first + j * 2 + 1]!;
|
|
134
|
+
if (yi > y !== yj > y && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi) inside = !inside;
|
|
135
|
+
}
|
|
136
|
+
if (inside) return true;
|
|
137
|
+
}
|
|
138
|
+
o = first + count * 2;
|
|
139
|
+
}
|
|
140
|
+
return false;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** The convex hull of some points (Andrew's monotone chain), counter-clockwise, no duplicates. Fewer than three distinct points give fewer than three back. */
|
|
144
|
+
export function convexHull(points: Point[]): Point[] {
|
|
145
|
+
const sorted = [...points].sort((a, b) => a.x - b.x || a.y - b.y).filter((p, i, all) => i === 0 || p.x !== all[i - 1]!.x || p.y !== all[i - 1]!.y);
|
|
146
|
+
if (sorted.length < 3) return sorted;
|
|
147
|
+
const cross = (o: Point, a: Point, b: Point) => (a.x - o.x) * (b.y - o.y) - (a.y - o.y) * (b.x - o.x);
|
|
148
|
+
const lower: Point[] = [];
|
|
149
|
+
for (const p of sorted) {
|
|
150
|
+
while (lower.length >= 2 && cross(lower[lower.length - 2]!, lower[lower.length - 1]!, p) <= 0) lower.pop();
|
|
151
|
+
lower.push(p);
|
|
152
|
+
}
|
|
153
|
+
const upper: Point[] = [];
|
|
154
|
+
for (let i = sorted.length - 1; i >= 0; i--) {
|
|
155
|
+
const p = sorted[i]!;
|
|
156
|
+
while (upper.length >= 2 && cross(upper[upper.length - 2]!, upper[upper.length - 1]!, p) <= 0) upper.pop();
|
|
157
|
+
upper.push(p);
|
|
158
|
+
}
|
|
159
|
+
lower.pop();
|
|
160
|
+
upper.pop();
|
|
161
|
+
return lower.concat(upper);
|
|
162
|
+
}
|
|
@@ -0,0 +1,530 @@
|
|
|
1
|
+
// Icons as data, with no image library: the per-platform byte formats an app
|
|
2
|
+
// hands to sweetnative, encoders for the two container formats the OSes want
|
|
3
|
+
// (.ico for Windows, .icns for a macOS app bundle) plus PNG, and a filled disc
|
|
4
|
+
// renderer so an app has a passable icon before it has real art.
|
|
5
|
+
|
|
6
|
+
import { crc32, deflateSync, inflateSync } from "node:zlib";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* An icon, one section per platform; each platform reads only its own. Omit a
|
|
10
|
+
* section for that platform's stock image. The same shape serves the tray,
|
|
11
|
+
* menu items, windows and the app icon.
|
|
12
|
+
*/
|
|
13
|
+
export type Icon = {
|
|
14
|
+
windows?: {
|
|
15
|
+
/** A .ico file's bytes. The best-fitting image is picked for each use (16px tray and menu, 32px toasts, both for a window). */
|
|
16
|
+
ico: Uint8Array;
|
|
17
|
+
};
|
|
18
|
+
macos?: {
|
|
19
|
+
/** An SF Symbol name; preferred over `png` when both are given. */
|
|
20
|
+
symbol?: string;
|
|
21
|
+
/** PNG bytes. Sized by the use: 18x18 points in the menu bar, 16x16 in menus, as-is for the Dock. Supply 2x pixels for Retina. */
|
|
22
|
+
png?: Uint8Array;
|
|
23
|
+
/** Monochrome, recoloured by the system for light and dark. Default true in the menu bar and in menus, false for the Dock. */
|
|
24
|
+
template?: boolean;
|
|
25
|
+
};
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export type DotIconOptions = {
|
|
29
|
+
/** CSS-style hex colour, "#rrggbb". Default violet. */
|
|
30
|
+
color?: string;
|
|
31
|
+
/** Hex colour of a thin outer ring; omitted = no ring. */
|
|
32
|
+
ring?: string;
|
|
33
|
+
/** Image sizes to include. Default 16, 24, 32. */
|
|
34
|
+
sizes?: number[];
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
function hexToRgb(hex: string): [number, number, number] {
|
|
38
|
+
const h = hex.replace("#", "");
|
|
39
|
+
const full = h.length === 3 ? h.split("").map((c) => c + c).join("") : h;
|
|
40
|
+
if (!/^[0-9a-fA-F]{6}$/.test(full)) throw new Error(`not a #rgb or #rrggbb colour: ${hex}`);
|
|
41
|
+
const n = parseInt(full, 16);
|
|
42
|
+
return [(n >> 16) & 0xff, (n >> 8) & 0xff, n & 0xff];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Anti-aliased disc as top-down RGBA. */
|
|
46
|
+
export function renderDot(size: number, opts: DotIconOptions = {}): Uint8Array {
|
|
47
|
+
const fill = hexToRgb(opts.color ?? "#8b5cf6");
|
|
48
|
+
const ring = opts.ring ? hexToRgb(opts.ring) : null;
|
|
49
|
+
const px = new Uint8Array(size * size * 4);
|
|
50
|
+
const c = size / 2;
|
|
51
|
+
const radius = size / 2 - 0.5;
|
|
52
|
+
const ringWidth = Math.max(1, size / 10);
|
|
53
|
+
for (let y = 0; y < size; y++) {
|
|
54
|
+
for (let x = 0; x < size; x++) {
|
|
55
|
+
const d = Math.hypot(x + 0.5 - c, y + 0.5 - c);
|
|
56
|
+
const coverage = Math.min(1, Math.max(0, radius - d + 0.5));
|
|
57
|
+
if (coverage <= 0) continue;
|
|
58
|
+
const rgb = ring !== null && d > radius - ringWidth ? ring : fill;
|
|
59
|
+
const i = (y * size + x) * 4;
|
|
60
|
+
px[i] = rgb[0];
|
|
61
|
+
px[i + 1] = rgb[1];
|
|
62
|
+
px[i + 2] = rgb[2];
|
|
63
|
+
px[i + 3] = Math.round(coverage * 255);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return px;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** One 32bpp icon image: BITMAPINFOHEADER + bottom-up BGRA + (empty) AND mask. */
|
|
70
|
+
function dibImage(size: number, rgba: Uint8Array): Uint8Array {
|
|
71
|
+
const bgra = new Uint8Array(size * size * 4);
|
|
72
|
+
for (let y = 0; y < size; y++) {
|
|
73
|
+
const src = (size - 1 - y) * size * 4; // DIB rows are bottom-up
|
|
74
|
+
const dst = y * size * 4;
|
|
75
|
+
for (let x = 0; x < size; x++) {
|
|
76
|
+
bgra[dst + x * 4] = rgba[src + x * 4 + 2]!;
|
|
77
|
+
bgra[dst + x * 4 + 1] = rgba[src + x * 4 + 1]!;
|
|
78
|
+
bgra[dst + x * 4 + 2] = rgba[src + x * 4]!;
|
|
79
|
+
bgra[dst + x * 4 + 3] = rgba[src + x * 4 + 3]!;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
const maskStride = Math.ceil(size / 32) * 4;
|
|
83
|
+
const mask = new Uint8Array(maskStride * size); // all zero: the alpha channel governs
|
|
84
|
+
const hdr = new DataView(new ArrayBuffer(40));
|
|
85
|
+
hdr.setUint32(0, 40, true);
|
|
86
|
+
hdr.setInt32(4, size, true);
|
|
87
|
+
hdr.setInt32(8, size * 2, true); // XOR + AND
|
|
88
|
+
hdr.setUint16(12, 1, true);
|
|
89
|
+
hdr.setUint16(14, 32, true);
|
|
90
|
+
hdr.setUint32(20, bgra.length + mask.length, true);
|
|
91
|
+
const out = new Uint8Array(40 + bgra.length + mask.length);
|
|
92
|
+
out.set(new Uint8Array(hdr.buffer), 0);
|
|
93
|
+
out.set(bgra, 40);
|
|
94
|
+
out.set(mask, 40 + bgra.length);
|
|
95
|
+
return out;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Wraps rendered images (size -> top-down RGBA) as a .ico file. */
|
|
99
|
+
export function encodeIco(images: Map<number, Uint8Array>): Uint8Array {
|
|
100
|
+
const entries = [...images.entries()].sort((a, b) => a[0] - b[0]);
|
|
101
|
+
const dir = new Uint8Array(6 + 16 * entries.length);
|
|
102
|
+
const dv = new DataView(dir.buffer);
|
|
103
|
+
dv.setUint16(2, 1, true); // type: icon
|
|
104
|
+
dv.setUint16(4, entries.length, true);
|
|
105
|
+
const blobs: Uint8Array[] = [];
|
|
106
|
+
let offset = dir.length;
|
|
107
|
+
entries.forEach(([size, rgba], i) => {
|
|
108
|
+
if (size < 1 || size > 256) throw new Error(`an .ico image is 1 to 256 pixels a side, not ${size}`);
|
|
109
|
+
const img = dibImage(size, rgba);
|
|
110
|
+
const e = 6 + i * 16;
|
|
111
|
+
dir[e] = size === 256 ? 0 : size;
|
|
112
|
+
dir[e + 1] = size === 256 ? 0 : size;
|
|
113
|
+
dv.setUint16(e + 4, 1, true); // planes
|
|
114
|
+
dv.setUint16(e + 6, 32, true); // bits per pixel
|
|
115
|
+
dv.setUint32(e + 8, img.length, true);
|
|
116
|
+
dv.setUint32(e + 12, offset, true);
|
|
117
|
+
blobs.push(img);
|
|
118
|
+
offset += img.length;
|
|
119
|
+
});
|
|
120
|
+
const out = new Uint8Array(offset);
|
|
121
|
+
out.set(dir, 0);
|
|
122
|
+
let pos = dir.length;
|
|
123
|
+
for (const b of blobs) {
|
|
124
|
+
out.set(b, pos);
|
|
125
|
+
pos += b.length;
|
|
126
|
+
}
|
|
127
|
+
return out;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** A coloured disc as a multi-size .ico, for `Icon.windows.ico`. */
|
|
131
|
+
export function dotIcon(opts: DotIconOptions = {}): Uint8Array {
|
|
132
|
+
const sizes = opts.sizes ?? [16, 24, 32];
|
|
133
|
+
return encodeIco(new Map(sizes.map((s) => [s, renderDot(s, opts)])));
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export type IcoEntry = { width: number; height: number; offset: number; size: number };
|
|
137
|
+
|
|
138
|
+
/** Reads the directory of a .ico file. */
|
|
139
|
+
export function parseIco(bytes: Uint8Array): IcoEntry[] {
|
|
140
|
+
const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
141
|
+
if (bytes.length < 6 || dv.getUint16(2, true) !== 1) throw new Error("not a .ico file");
|
|
142
|
+
const count = dv.getUint16(4, true);
|
|
143
|
+
const entries: IcoEntry[] = [];
|
|
144
|
+
for (let i = 0; i < count; i++) {
|
|
145
|
+
const e = 6 + i * 16;
|
|
146
|
+
if (e + 16 > bytes.length) throw new Error("truncated .ico directory");
|
|
147
|
+
const width = bytes[e]! || 256;
|
|
148
|
+
const height = bytes[e + 1]! || 256;
|
|
149
|
+
const size = dv.getUint32(e + 8, true);
|
|
150
|
+
const offset = dv.getUint32(e + 12, true);
|
|
151
|
+
if (offset + size > bytes.length) throw new Error(`.ico image ${i} runs past the end of the file`);
|
|
152
|
+
entries.push({ width, height, offset, size });
|
|
153
|
+
}
|
|
154
|
+
return entries;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** The entry matching `want` exactly, else the smallest larger one, else the largest. */
|
|
158
|
+
export function pickIcoEntry(entries: IcoEntry[], want: number): IcoEntry {
|
|
159
|
+
if (entries.length === 0) throw new Error(".ico has no images");
|
|
160
|
+
const exact = entries.find((e) => e.width === want);
|
|
161
|
+
if (exact) return exact;
|
|
162
|
+
const larger = entries.filter((e) => e.width > want).sort((a, b) => a.width - b.width)[0];
|
|
163
|
+
return larger ?? entries.slice().sort((a, b) => b.width - a.width)[0]!;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** The pixels of a .ico entry as top-down RGBA: a 32bpp DIB entry, or a PNG-compressed one (the usual 256px form). */
|
|
167
|
+
export function decodeIcoEntry(bytes: Uint8Array, entry: IcoEntry): Uint8Array {
|
|
168
|
+
const dv = new DataView(bytes.buffer, bytes.byteOffset + entry.offset, entry.size);
|
|
169
|
+
if (entry.size >= 8 && dv.getUint32(0, false) === 0x89504e47) {
|
|
170
|
+
const png = decodePng(bytes.subarray(entry.offset, entry.offset + entry.size));
|
|
171
|
+
if (png.width !== entry.width || png.height !== entry.height) throw new Error(`.ico entry says ${entry.width}x${entry.height} but its PNG is ${png.width}x${png.height}`);
|
|
172
|
+
return png.rgba;
|
|
173
|
+
}
|
|
174
|
+
if (dv.getUint32(0, true) !== 40) throw new Error(`.ico entry ${entry.width}px: unexpected BITMAPINFOHEADER size`);
|
|
175
|
+
const bpp = dv.getUint16(14, true);
|
|
176
|
+
if (bpp !== 32) throw new Error(`.ico entry ${entry.width}px is ${bpp}bpp; only 32bpp decodes here`);
|
|
177
|
+
const { width, height } = entry;
|
|
178
|
+
const rgba = new Uint8Array(width * height * 4);
|
|
179
|
+
const pixels = entry.offset + 40;
|
|
180
|
+
for (let y = 0; y < height; y++) {
|
|
181
|
+
const src = pixels + (height - 1 - y) * width * 4; // bottom-up BGRA
|
|
182
|
+
const dst = y * width * 4;
|
|
183
|
+
for (let x = 0; x < width; x++) {
|
|
184
|
+
rgba[dst + x * 4] = bytes[src + x * 4 + 2]!;
|
|
185
|
+
rgba[dst + x * 4 + 1] = bytes[src + x * 4 + 1]!;
|
|
186
|
+
rgba[dst + x * 4 + 2] = bytes[src + x * 4]!;
|
|
187
|
+
rgba[dst + x * 4 + 3] = bytes[src + x * 4 + 3]!;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
return rgba;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Bilinear resample of a square top-down RGBA image. Returns the input when the size already matches. */
|
|
194
|
+
export function resampleRgba(src: Uint8Array, from: number, to: number): Uint8Array {
|
|
195
|
+
if (from === to) return src;
|
|
196
|
+
const out = new Uint8Array(to * to * 4);
|
|
197
|
+
const scale = from / to;
|
|
198
|
+
for (let y = 0; y < to; y++) {
|
|
199
|
+
const sy = Math.min(from - 1, Math.max(0, (y + 0.5) * scale - 0.5));
|
|
200
|
+
const y0 = Math.floor(sy);
|
|
201
|
+
const y1 = Math.min(from - 1, y0 + 1);
|
|
202
|
+
const fy = sy - y0;
|
|
203
|
+
for (let x = 0; x < to; x++) {
|
|
204
|
+
const sx = Math.min(from - 1, Math.max(0, (x + 0.5) * scale - 0.5));
|
|
205
|
+
const x0 = Math.floor(sx);
|
|
206
|
+
const x1 = Math.min(from - 1, x0 + 1);
|
|
207
|
+
const fx = sx - x0;
|
|
208
|
+
const o = (y * to + x) * 4;
|
|
209
|
+
for (let c = 0; c < 4; c++) {
|
|
210
|
+
const p00 = src[(y0 * from + x0) * 4 + c]!;
|
|
211
|
+
const p10 = src[(y0 * from + x1) * 4 + c]!;
|
|
212
|
+
const p01 = src[(y1 * from + x0) * 4 + c]!;
|
|
213
|
+
const p11 = src[(y1 * from + x1) * 4 + c]!;
|
|
214
|
+
const top = p00 + (p10 - p00) * fx;
|
|
215
|
+
const bottom = p01 + (p11 - p01) * fx;
|
|
216
|
+
out[o + c] = Math.round(top + (bottom - top) * fy);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
return out;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** The RGBA of a .ico at exactly `size` px: the matching entry, or the next larger one scaled down. */
|
|
224
|
+
export function icoRgba(ico: Uint8Array, size: number): Uint8Array {
|
|
225
|
+
const entry = pickIcoEntry(parseIco(ico), size);
|
|
226
|
+
if (entry.width !== entry.height) throw new Error(`.ico entry ${entry.width}x${entry.height} is not square`);
|
|
227
|
+
return resampleRgba(decodeIcoEntry(ico, entry), entry.width, size);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// ---- PNG -----------------------------------------------------------------------
|
|
231
|
+
|
|
232
|
+
function pngChunk(type: string, data: Uint8Array): Uint8Array {
|
|
233
|
+
const out = new Uint8Array(12 + data.length);
|
|
234
|
+
const dv = new DataView(out.buffer);
|
|
235
|
+
dv.setUint32(0, data.length, false);
|
|
236
|
+
out.set(new TextEncoder().encode(type), 4);
|
|
237
|
+
out.set(data, 8);
|
|
238
|
+
dv.setUint32(8 + data.length, crc32(out.subarray(4, 8 + data.length)), false);
|
|
239
|
+
return out;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** A square top-down RGBA image as an 8-bit RGBA PNG. */
|
|
243
|
+
export function encodePng(size: number, rgba: Uint8Array): Uint8Array {
|
|
244
|
+
if (rgba.length !== size * size * 4) throw new Error(`encodePng: ${rgba.length} bytes is not ${size}x${size} RGBA`);
|
|
245
|
+
const ihdr = new Uint8Array(13);
|
|
246
|
+
const idv = new DataView(ihdr.buffer);
|
|
247
|
+
idv.setUint32(0, size, false);
|
|
248
|
+
idv.setUint32(4, size, false);
|
|
249
|
+
ihdr[8] = 8; // bit depth
|
|
250
|
+
ihdr[9] = 6; // colour type: RGBA
|
|
251
|
+
// compression, filter, interlace: 0
|
|
252
|
+
const stride = size * 4;
|
|
253
|
+
const raw = new Uint8Array((stride + 1) * size); // filter byte 0 (None) before every row
|
|
254
|
+
for (let y = 0; y < size; y++) raw.set(rgba.subarray(y * stride, (y + 1) * stride), y * (stride + 1) + 1);
|
|
255
|
+
const idat = new Uint8Array(deflateSync(raw));
|
|
256
|
+
const signature = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
|
257
|
+
const chunks = [signature, pngChunk("IHDR", ihdr), pngChunk("IDAT", idat), pngChunk("IEND", new Uint8Array(0))];
|
|
258
|
+
const out = new Uint8Array(chunks.reduce((n, c) => n + c.length, 0));
|
|
259
|
+
let pos = 0;
|
|
260
|
+
for (const c of chunks) {
|
|
261
|
+
out.set(c, pos);
|
|
262
|
+
pos += c.length;
|
|
263
|
+
}
|
|
264
|
+
return out;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// ---- ICNS ----------------------------------------------------------------------
|
|
268
|
+
|
|
269
|
+
/** The .icns element type that holds a PNG of this pixel size (the 1x types; Finder scales for Retina). */
|
|
270
|
+
export const ICNS_TYPES: Record<number, string> = {
|
|
271
|
+
16: "icp4",
|
|
272
|
+
32: "icp5",
|
|
273
|
+
64: "icp6",
|
|
274
|
+
128: "ic07",
|
|
275
|
+
256: "ic08",
|
|
276
|
+
512: "ic09",
|
|
277
|
+
1024: "ic10",
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
/** Wraps PNGs (pixel size -> PNG bytes) as an .icns file, for CFBundleIconFile. Sizes without an element type throw. */
|
|
281
|
+
export function encodeIcns(images: Map<number, Uint8Array>): Uint8Array {
|
|
282
|
+
const elements: Uint8Array[] = [];
|
|
283
|
+
for (const [size, png] of [...images.entries()].sort((a, b) => a[0] - b[0])) {
|
|
284
|
+
const type = ICNS_TYPES[size];
|
|
285
|
+
if (type === undefined) throw new Error(`encodeIcns: no element type for ${size}px (use ${Object.keys(ICNS_TYPES).join(", ")})`);
|
|
286
|
+
const el = new Uint8Array(8 + png.length);
|
|
287
|
+
el.set(new TextEncoder().encode(type), 0);
|
|
288
|
+
new DataView(el.buffer).setUint32(4, el.length, false);
|
|
289
|
+
el.set(png, 8);
|
|
290
|
+
elements.push(el);
|
|
291
|
+
}
|
|
292
|
+
const total = 8 + elements.reduce((n, e) => n + e.length, 0);
|
|
293
|
+
const out = new Uint8Array(total);
|
|
294
|
+
out.set(new TextEncoder().encode("icns"), 0);
|
|
295
|
+
new DataView(out.buffer).setUint32(4, total, false);
|
|
296
|
+
let pos = 8;
|
|
297
|
+
for (const e of elements) {
|
|
298
|
+
out.set(e, pos);
|
|
299
|
+
pos += e.length;
|
|
300
|
+
}
|
|
301
|
+
return out;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/** The disc as a PNG of `size` px, for `Icon.macos.png`. */
|
|
305
|
+
export function dotPng(size: number, opts: DotIconOptions = {}): Uint8Array {
|
|
306
|
+
return encodePng(size, renderDot(size, opts));
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/** The disc as an .icns with every standard size, for a macOS app bundle. */
|
|
310
|
+
export function dotIcns(opts: DotIconOptions = {}): Uint8Array {
|
|
311
|
+
const sizes = [16, 32, 64, 128, 256, 512, 1024];
|
|
312
|
+
return encodeIcns(new Map(sizes.map((s) => [s, dotPng(s, opts)])));
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// ---- PNG decoding ----------------------------------------------------------------
|
|
316
|
+
|
|
317
|
+
export type DecodedPng = { width: number; height: number; rgba: Uint8Array };
|
|
318
|
+
|
|
319
|
+
const PNG_SIGNATURE = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* A PNG as top-down RGBA: 8-bit greyscale, RGB, palette (with tRNS alpha),
|
|
323
|
+
* greyscale+alpha and RGBA, non-interlaced, which covers what image editors
|
|
324
|
+
* and asset packs write. Anything else throws with the reason.
|
|
325
|
+
*/
|
|
326
|
+
export function decodePng(bytes: Uint8Array): DecodedPng {
|
|
327
|
+
if (bytes.length < 8 || PNG_SIGNATURE.some((b, i) => bytes[i] !== b)) throw new Error("not a PNG");
|
|
328
|
+
const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
329
|
+
let width = 0;
|
|
330
|
+
let height = 0;
|
|
331
|
+
let depth = 0;
|
|
332
|
+
let colour = 0;
|
|
333
|
+
let palette: Uint8Array | undefined;
|
|
334
|
+
let trns: Uint8Array | undefined;
|
|
335
|
+
const idat: Uint8Array[] = [];
|
|
336
|
+
for (let pos = 8; pos + 8 <= bytes.length; ) {
|
|
337
|
+
const length = dv.getUint32(pos, false);
|
|
338
|
+
const type = String.fromCharCode(bytes[pos + 4]!, bytes[pos + 5]!, bytes[pos + 6]!, bytes[pos + 7]!);
|
|
339
|
+
const data = bytes.subarray(pos + 8, pos + 8 + length);
|
|
340
|
+
if (data.length !== length) throw new Error(`PNG ${type} chunk runs past the end of the file`);
|
|
341
|
+
switch (type) {
|
|
342
|
+
case "IHDR":
|
|
343
|
+
width = dv.getUint32(pos + 8, false);
|
|
344
|
+
height = dv.getUint32(pos + 12, false);
|
|
345
|
+
depth = data[8]!;
|
|
346
|
+
colour = data[9]!;
|
|
347
|
+
if (data[12] !== 0) throw new Error("interlaced PNGs are not supported");
|
|
348
|
+
if (depth !== 8) throw new Error(`${depth}-bit PNGs are not supported (8-bit only)`);
|
|
349
|
+
break;
|
|
350
|
+
case "PLTE":
|
|
351
|
+
palette = data;
|
|
352
|
+
break;
|
|
353
|
+
case "tRNS":
|
|
354
|
+
trns = data;
|
|
355
|
+
break;
|
|
356
|
+
case "IDAT":
|
|
357
|
+
idat.push(data);
|
|
358
|
+
break;
|
|
359
|
+
}
|
|
360
|
+
pos += 12 + length;
|
|
361
|
+
if (type === "IEND") break;
|
|
362
|
+
}
|
|
363
|
+
if (width === 0 || height === 0) throw new Error("PNG has no IHDR");
|
|
364
|
+
const channels = { 0: 1, 2: 3, 3: 1, 4: 2, 6: 4 }[colour];
|
|
365
|
+
if (channels === undefined) throw new Error(`PNG colour type ${colour} is not supported`);
|
|
366
|
+
if (colour === 3 && palette === undefined) throw new Error("palette PNG has no PLTE");
|
|
367
|
+
const compressed = new Uint8Array(idat.reduce((n, c) => n + c.length, 0));
|
|
368
|
+
let at = 0;
|
|
369
|
+
for (const c of idat) {
|
|
370
|
+
compressed.set(c, at);
|
|
371
|
+
at += c.length;
|
|
372
|
+
}
|
|
373
|
+
const raw = new Uint8Array(inflateSync(compressed));
|
|
374
|
+
const stride = width * channels;
|
|
375
|
+
if (raw.length < (stride + 1) * height) throw new Error("PNG image data is short");
|
|
376
|
+
|
|
377
|
+
// Undo the per-row filters in place, into `rows` (no filter bytes).
|
|
378
|
+
const rows = new Uint8Array(stride * height);
|
|
379
|
+
for (let y = 0; y < height; y++) {
|
|
380
|
+
const filter = raw[y * (stride + 1)]!;
|
|
381
|
+
const src = y * (stride + 1) + 1;
|
|
382
|
+
const dst = y * stride;
|
|
383
|
+
const prev = y > 0 ? dst - stride : -1;
|
|
384
|
+
for (let x = 0; x < stride; x++) {
|
|
385
|
+
const a = x >= channels ? rows[dst + x - channels]! : 0; // left
|
|
386
|
+
const b = prev >= 0 ? rows[prev + x]! : 0; // up
|
|
387
|
+
const c = prev >= 0 && x >= channels ? rows[prev + x - channels]! : 0; // up-left
|
|
388
|
+
let predictor: number;
|
|
389
|
+
switch (filter) {
|
|
390
|
+
case 0:
|
|
391
|
+
predictor = 0;
|
|
392
|
+
break;
|
|
393
|
+
case 1:
|
|
394
|
+
predictor = a;
|
|
395
|
+
break;
|
|
396
|
+
case 2:
|
|
397
|
+
predictor = b;
|
|
398
|
+
break;
|
|
399
|
+
case 3:
|
|
400
|
+
predictor = (a + b) >> 1;
|
|
401
|
+
break;
|
|
402
|
+
case 4: {
|
|
403
|
+
const p = a + b - c;
|
|
404
|
+
const pa = Math.abs(p - a);
|
|
405
|
+
const pb = Math.abs(p - b);
|
|
406
|
+
const pc = Math.abs(p - c);
|
|
407
|
+
predictor = pa <= pb && pa <= pc ? a : pb <= pc ? b : c;
|
|
408
|
+
break;
|
|
409
|
+
}
|
|
410
|
+
default:
|
|
411
|
+
throw new Error(`PNG row ${y} has unknown filter ${filter}`);
|
|
412
|
+
}
|
|
413
|
+
rows[dst + x] = (raw[src + x]! + predictor) & 0xff;
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
const rgba = new Uint8Array(width * height * 4);
|
|
418
|
+
for (let i = 0, o = 0; i < width * height; i++, o += 4) {
|
|
419
|
+
const s = i * channels;
|
|
420
|
+
switch (colour) {
|
|
421
|
+
case 0: {
|
|
422
|
+
const g = rows[s]!;
|
|
423
|
+
rgba[o] = rgba[o + 1] = rgba[o + 2] = g;
|
|
424
|
+
rgba[o + 3] = trns !== undefined && trns.length >= 2 && ((trns[0]! << 8) | trns[1]!) === g ? 0 : 255;
|
|
425
|
+
break;
|
|
426
|
+
}
|
|
427
|
+
case 2: {
|
|
428
|
+
rgba[o] = rows[s]!;
|
|
429
|
+
rgba[o + 1] = rows[s + 1]!;
|
|
430
|
+
rgba[o + 2] = rows[s + 2]!;
|
|
431
|
+
const keyed =
|
|
432
|
+
trns !== undefined &&
|
|
433
|
+
trns.length >= 6 &&
|
|
434
|
+
((trns[0]! << 8) | trns[1]!) === rows[s]! &&
|
|
435
|
+
((trns[2]! << 8) | trns[3]!) === rows[s + 1]! &&
|
|
436
|
+
((trns[4]! << 8) | trns[5]!) === rows[s + 2]!;
|
|
437
|
+
rgba[o + 3] = keyed ? 0 : 255;
|
|
438
|
+
break;
|
|
439
|
+
}
|
|
440
|
+
case 3: {
|
|
441
|
+
const index = rows[s]!;
|
|
442
|
+
rgba[o] = palette![index * 3] ?? 0;
|
|
443
|
+
rgba[o + 1] = palette![index * 3 + 1] ?? 0;
|
|
444
|
+
rgba[o + 2] = palette![index * 3 + 2] ?? 0;
|
|
445
|
+
rgba[o + 3] = trns !== undefined && index < trns.length ? trns[index]! : 255;
|
|
446
|
+
break;
|
|
447
|
+
}
|
|
448
|
+
case 4:
|
|
449
|
+
rgba[o] = rgba[o + 1] = rgba[o + 2] = rows[s]!;
|
|
450
|
+
rgba[o + 3] = rows[s + 1]!;
|
|
451
|
+
break;
|
|
452
|
+
case 6:
|
|
453
|
+
rgba[o] = rows[s]!;
|
|
454
|
+
rgba[o + 1] = rows[s + 1]!;
|
|
455
|
+
rgba[o + 2] = rows[s + 2]!;
|
|
456
|
+
rgba[o + 3] = rows[s + 3]!;
|
|
457
|
+
break;
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
return { width, height, rgba };
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/** A square PNG's pixels, or a throw naming the shape. */
|
|
464
|
+
function squarePng(png: Uint8Array): { size: number; rgba: Uint8Array } {
|
|
465
|
+
const { width, height, rgba } = decodePng(png);
|
|
466
|
+
if (width !== height) throw new Error(`PNG is ${width}x${height}; icons want a square image`);
|
|
467
|
+
return { size: width, rgba };
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/** A PNG as a multi-size .ico (32bpp entries, resampled from the PNG), for `Icon.windows.ico`. */
|
|
471
|
+
export function icoFromPng(png: Uint8Array, sizes?: number[]): Uint8Array {
|
|
472
|
+
const { size, rgba } = squarePng(png);
|
|
473
|
+
return icoFromRgba(size, rgba, sizes);
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/** A PNG as an .icns (resampled to each size), for CFBundleIconFile. Sizes above the PNG's own are upscaled. */
|
|
477
|
+
export function icnsFromPng(png: Uint8Array, sizes?: number[]): Uint8Array {
|
|
478
|
+
const { size, rgba } = squarePng(png);
|
|
479
|
+
return icnsFromRgba(size, rgba, sizes);
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/**
|
|
483
|
+
* The image cropped to its opaque pixels (alpha above `threshold`), as a
|
|
484
|
+
* square centred on them with `margin` transparent pixels around, so an
|
|
485
|
+
* asset with padding around its subject fills a 16px menu icon instead of
|
|
486
|
+
* leaving a speck in the middle. The square is padded with transparency
|
|
487
|
+
* where it runs past the source. A fully transparent image comes back as is.
|
|
488
|
+
*/
|
|
489
|
+
export function trimTransparent(image: DecodedPng, margin = 1, threshold = 8): DecodedPng {
|
|
490
|
+
const { width, height, rgba } = image;
|
|
491
|
+
let x0 = width;
|
|
492
|
+
let y0 = height;
|
|
493
|
+
let x1 = -1;
|
|
494
|
+
let y1 = -1;
|
|
495
|
+
for (let y = 0; y < height; y++) {
|
|
496
|
+
for (let x = 0; x < width; x++) {
|
|
497
|
+
if (rgba[(y * width + x) * 4 + 3]! > threshold) {
|
|
498
|
+
if (x < x0) x0 = x;
|
|
499
|
+
if (x > x1) x1 = x;
|
|
500
|
+
if (y < y0) y0 = y;
|
|
501
|
+
if (y > y1) y1 = y;
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
if (x1 < 0) return image;
|
|
506
|
+
const side = Math.max(x1 - x0 + 1, y1 - y0 + 1) + 2 * margin;
|
|
507
|
+
const left = Math.round((x0 + x1 + 1) / 2 - side / 2);
|
|
508
|
+
const top = Math.round((y0 + y1 + 1) / 2 - side / 2);
|
|
509
|
+
const out = new Uint8Array(side * side * 4);
|
|
510
|
+
for (let y = 0; y < side; y++) {
|
|
511
|
+
const sy = top + y;
|
|
512
|
+
if (sy < 0 || sy >= height) continue;
|
|
513
|
+
for (let x = 0; x < side; x++) {
|
|
514
|
+
const sx = left + x;
|
|
515
|
+
if (sx < 0 || sx >= width) continue;
|
|
516
|
+
out.set(rgba.subarray((sy * width + sx) * 4, (sy * width + sx) * 4 + 4), (y * side + x) * 4);
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
return { width: side, height: side, rgba: out };
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/** Square RGBA as a multi-size .ico (32bpp entries, resampled), for `Icon.windows.ico`. */
|
|
523
|
+
export function icoFromRgba(size: number, rgba: Uint8Array, sizes: number[] = [16, 24, 32, 48, 64]): Uint8Array {
|
|
524
|
+
return encodeIco(new Map(sizes.map((s) => [s, resampleRgba(rgba, size, s)])));
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
/** Square RGBA as an .icns (resampled to each size), for CFBundleIconFile. Sizes above the source are upscaled. */
|
|
528
|
+
export function icnsFromRgba(size: number, rgba: Uint8Array, sizes: number[] = [16, 32, 64, 128, 256]): Uint8Array {
|
|
529
|
+
return encodeIcns(new Map(sizes.map((s) => [s, encodePng(s, resampleRgba(rgba, size, s))])));
|
|
530
|
+
}
|