akanjs 3.0.1 → 3.0.2-rc.0
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/CHANGELOG.md +11 -0
- package/base/base.ts +1 -1
- package/client/cookie.ts +4 -6
- package/common/Logger.ts +10 -0
- package/dictionary/base.dictionary.ts +7 -0
- package/dictionary/trans.ts +7 -1
- package/document/documentQuery.ts +6 -1
- package/document/index.ts +1 -0
- package/document/into.ts +15 -7
- package/document/liveRowScope.ts +79 -0
- package/fetch/client/fetchClient.ts +64 -12
- package/fetch/client/httpClient.ts +13 -4
- package/index.ts +22 -0
- package/package.json +1 -1
- package/server/akanOption.ts +20 -1
- package/server/akanServer.ts +5 -0
- package/server/mcp/McpAuth.ts +3 -2
- package/server/mcp/McpExecutionContext.ts +15 -14
- package/server/mcp/McpRateLimiter.ts +18 -67
- package/server/proxy/webProxyRunner.ts +2 -0
- package/server/resolver/database.resolver.ts +28 -13
- package/server/resolver/signal.resolver.ts +75 -35
- package/server/rscWorkerHost.ts +16 -7
- package/server/webRouter.ts +3 -0
- package/service/index.ts +1 -0
- package/service/predefinedAdaptor/index.ts +1 -0
- package/service/predefinedAdaptor/rateLimit.adaptor.ts +15 -0
- package/service/predefinedAdaptor/sql/SqlDocumentStore.ts +119 -27
- package/service/predefinedAdaptor/sql/types.ts +14 -12
- package/service/rateLimiter.ts +80 -0
- package/signal/base.signal.ts +7 -6
- package/signal/endpointRateLimit.ts +159 -0
- package/signal/index.ts +1 -0
- package/signal/middleware.ts +6 -5
- package/signal/serializer/fetch.serializer.ts +15 -4
- package/signal/signalContext.ts +148 -67
- package/signal/slice.ts +30 -9
- package/signal/types.ts +8 -0
- package/test/signalTestRuntime.ts +2 -1
- package/types/base/base.d.ts +1 -1
- package/types/client/cookie.d.ts +1 -1
- package/types/common/Logger.d.ts +2 -0
- package/types/dictionary/base.dictionary.d.ts +1 -1
- package/types/dictionary/dictionary.d.ts +8 -8
- package/types/document/documentQuery.d.ts +5 -1
- package/types/document/index.d.ts +1 -0
- package/types/document/into.d.ts +7 -7
- package/types/document/liveRowScope.d.ts +9 -0
- package/types/fetch/client/fetchClient.d.ts +9 -1
- package/types/index.d.ts +32 -0
- package/types/server/akanOption.d.ts +7 -1
- package/types/server/mcp/McpExecutionContext.d.ts +4 -1
- package/types/server/mcp/McpRateLimiter.d.ts +2 -6
- package/types/server/rscWorkerHost.d.ts +3 -0
- package/types/service/index.d.ts +1 -0
- package/types/service/predefinedAdaptor/index.d.ts +1 -0
- package/types/service/predefinedAdaptor/rateLimit.adaptor.d.ts +10 -0
- package/types/service/predefinedAdaptor/sql/SqlDocumentStore.d.ts +6 -10
- package/types/service/predefinedAdaptor/sql/types.d.ts +13 -17
- package/types/service/rateLimiter.d.ts +24 -0
- package/types/signal/endpointRateLimit.d.ts +37 -0
- package/types/signal/index.d.ts +1 -0
- package/types/signal/signalContext.d.ts +33 -15
- package/types/signal/slice.d.ts +17 -5
- package/types/signal/types.d.ts +8 -0
- package/types/test/signalTestRuntime.d.ts +1 -1
- package/types/ui/recipe/buttonRecipe.d.ts +2 -2
- package/types/ui/recipe/inputRecipe.d.ts +3 -3
- package/types/vendor/@akanjs/native/packages/cli/src/config.d.ts +33 -0
- package/types/vendor/@akanjs/native/packages/cli/src/lib/bplist.d.ts +11 -0
- package/types/vendor/@akanjs/native/packages/cli/src/lib/dmg.d.ts +59 -0
- package/types/vendor/@akanjs/native/packages/cli/src/lib/dsStore.d.ts +26 -0
- package/types/vendor/@akanjs/native/packages/cli/src/lib/macAlias.d.ts +24 -0
- package/types/vendor/@akanjs/native/packages/cli/src/lib/macossigning.d.ts +0 -1
- package/types/vendor/@akanjs/native/packages/cli/src/lib/project.d.ts +3 -1
- package/types/vendor/@akanjs/native/packages/cli/src/platforms/linux-appimage.d.ts +6 -1
- package/types/vendor/@akanjs/native/packages/desktop/src/deeplinks.d.ts +9 -2
- package/types/webkit/RenderFailure.d.ts +6 -0
- package/ui/Input.tsx +7 -1
- package/ui/recipe/buttonRecipe.ts +1 -0
- package/ui/recipe/inputRecipe.ts +13 -7
- package/ui/styles.css +1 -0
- package/vendor/@akanjs/native/native/desktop/src/lib.rs +8 -1
- package/vendor/@akanjs/native/native/macos/dmg/background.html +18 -0
- package/vendor/@akanjs/native/native/macos/dmg/background.png +0 -0
- package/vendor/@akanjs/native/native/macos/dmg/background@2x.png +0 -0
- package/vendor/@akanjs/native/packages/cli/src/config.ts +23 -0
- package/vendor/@akanjs/native/packages/cli/src/lib/bplist.ts +91 -0
- package/vendor/@akanjs/native/packages/cli/src/lib/configkeys.ts +9 -0
- package/vendor/@akanjs/native/packages/cli/src/lib/dmg.ts +331 -0
- package/vendor/@akanjs/native/packages/cli/src/lib/dsStore.ts +191 -0
- package/vendor/@akanjs/native/packages/cli/src/lib/macAlias.ts +101 -0
- package/vendor/@akanjs/native/packages/cli/src/lib/macossigning.ts +1 -28
- package/vendor/@akanjs/native/packages/cli/src/lib/project.ts +6 -1
- package/vendor/@akanjs/native/packages/cli/src/platforms/linux-appimage.ts +17 -7
- package/vendor/@akanjs/native/packages/cli/src/platforms/linux.ts +6 -1
- package/vendor/@akanjs/native/packages/cli/src/platforms/macos.ts +5 -2
- package/vendor/@akanjs/native/packages/desktop/src/deeplinks.ts +33 -10
- package/vendor/@akanjs/native/packages/desktop/src/host.ts +1 -1
- package/webkit/RenderFailure.tsx +32 -0
- package/webkit/RenderLayer.tsx +22 -2
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
|
|
2
|
+
|
|
3
|
+
/** A number written as a real even when it is whole: Finder reads `iconSize` and `textSize` as reals. */
|
|
4
|
+
export class Real {
|
|
5
|
+
constructor(readonly value: number) {}
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export type BplistValue = string | number | boolean | Uint8Array | Real | { [key: string]: BplistValue };
|
|
9
|
+
|
|
10
|
+
class DictNode {
|
|
11
|
+
readonly keys: number[] = [];
|
|
12
|
+
readonly values: number[] = [];
|
|
13
|
+
}
|
|
14
|
+
type Node = Exclude<BplistValue, { [key: string]: BplistValue }> | DictNode;
|
|
15
|
+
|
|
16
|
+
const sizeOf = (max: number) => (max < 2 ** 8 ? 1 : max < 2 ** 16 ? 2 : 4);
|
|
17
|
+
|
|
18
|
+
function uint(value: number, size: number): number[] {
|
|
19
|
+
const out: number[] = [];
|
|
20
|
+
for (let i = size - 1; i >= 0; i--) out.push(Math.floor(value / 2 ** (8 * i)) & 0xff);
|
|
21
|
+
return out;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function float64(value: number): number[] {
|
|
25
|
+
const data = new Uint8Array(8);
|
|
26
|
+
new DataView(data.buffer).setFloat64(0, value);
|
|
27
|
+
return [0x23, ...data];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function lengthMarker(kind: number, length: number): number[] {
|
|
31
|
+
if (length < 15) return [kind | length];
|
|
32
|
+
const size = length < 2 ** 8 ? 0 : length < 2 ** 16 ? 1 : 2;
|
|
33
|
+
return [kind | 0x0f, 0x10 | size, ...uint(length, 2 ** size)];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function encode(node: Node, refSize: number): number[] {
|
|
37
|
+
if (typeof node === "boolean") return [node ? 0x09 : 0x08];
|
|
38
|
+
if (node instanceof Real) return float64(node.value);
|
|
39
|
+
if (typeof node === "number") {
|
|
40
|
+
if (!Number.isInteger(node)) return float64(node);
|
|
41
|
+
if (node < 0) {
|
|
42
|
+
const data = new Uint8Array(8);
|
|
43
|
+
new DataView(data.buffer).setBigInt64(0, BigInt(node));
|
|
44
|
+
return [0x13, ...data];
|
|
45
|
+
}
|
|
46
|
+
const size = node < 2 ** 8 ? 0 : node < 2 ** 16 ? 1 : node < 2 ** 32 ? 2 : 3;
|
|
47
|
+
return [0x10 | size, ...uint(node, 2 ** size)];
|
|
48
|
+
}
|
|
49
|
+
if (typeof node === "string") {
|
|
50
|
+
if ([...node].every((c) => c.charCodeAt(0) < 0x80))
|
|
51
|
+
return [...lengthMarker(0x50, node.length), ...[...node].map((c) => c.charCodeAt(0))];
|
|
52
|
+
const out = lengthMarker(0x60, node.length);
|
|
53
|
+
for (let i = 0; i < node.length; i++) out.push(...uint(node.charCodeAt(i), 2));
|
|
54
|
+
return out;
|
|
55
|
+
}
|
|
56
|
+
if (node instanceof Uint8Array) return [...lengthMarker(0x40, node.length), ...node];
|
|
57
|
+
return [
|
|
58
|
+
...lengthMarker(0xd0, node.keys.length),
|
|
59
|
+
...[...node.keys, ...node.values].flatMap((ref) => uint(ref, refSize)),
|
|
60
|
+
];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function writeBplist(root: { [key: string]: BplistValue }): Uint8Array {
|
|
64
|
+
const nodes: Node[] = [];
|
|
65
|
+
const add = (value: BplistValue): number => {
|
|
66
|
+
const idx = nodes.length;
|
|
67
|
+
if (typeof value !== "object" || value instanceof Uint8Array || value instanceof Real) {
|
|
68
|
+
nodes.push(value);
|
|
69
|
+
return idx;
|
|
70
|
+
}
|
|
71
|
+
const dict = new DictNode();
|
|
72
|
+
nodes.push(dict);
|
|
73
|
+
const entries = Object.entries(value);
|
|
74
|
+
for (const [key] of entries) dict.keys.push(add(key));
|
|
75
|
+
for (const [, item] of entries) dict.values.push(add(item));
|
|
76
|
+
return idx;
|
|
77
|
+
};
|
|
78
|
+
add(root);
|
|
79
|
+
const refSize = sizeOf(nodes.length);
|
|
80
|
+
const bytes: number[] = [..."bplist00"].map((c) => c.charCodeAt(0));
|
|
81
|
+
const offsets: number[] = [];
|
|
82
|
+
for (const node of nodes) {
|
|
83
|
+
offsets.push(bytes.length);
|
|
84
|
+
bytes.push(...encode(node, refSize));
|
|
85
|
+
}
|
|
86
|
+
const tableOffset = bytes.length;
|
|
87
|
+
const offsetSize = sizeOf(tableOffset);
|
|
88
|
+
for (const offset of offsets) bytes.push(...uint(offset, offsetSize));
|
|
89
|
+
bytes.push(0, 0, 0, 0, 0, 0, offsetSize, refSize, ...uint(nodes.length, 8), ...uint(0, 8), ...uint(tableOffset, 8));
|
|
90
|
+
return Uint8Array.from(bytes);
|
|
91
|
+
}
|
|
@@ -37,6 +37,15 @@ const KNOWN: Shape = {
|
|
|
37
37
|
screenCapture: true,
|
|
38
38
|
server: { dir: true, entry: true, env: true },
|
|
39
39
|
bin: true,
|
|
40
|
+
dmg: {
|
|
41
|
+
background: true,
|
|
42
|
+
background2x: true,
|
|
43
|
+
window: { x: true, y: true, width: true, height: true },
|
|
44
|
+
iconSize: true,
|
|
45
|
+
textSize: true,
|
|
46
|
+
app: { x: true, y: true },
|
|
47
|
+
applications: { x: true, y: true },
|
|
48
|
+
},
|
|
40
49
|
},
|
|
41
50
|
keyboard: { resize: true },
|
|
42
51
|
push: {
|
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
closeSync,
|
|
5
|
+
copyFileSync,
|
|
6
|
+
existsSync,
|
|
7
|
+
lstatSync,
|
|
8
|
+
mkdirSync,
|
|
9
|
+
openSync,
|
|
10
|
+
readFileSync,
|
|
11
|
+
readSync,
|
|
12
|
+
rmSync,
|
|
13
|
+
statSync,
|
|
14
|
+
symlinkSync,
|
|
15
|
+
writeFileSync,
|
|
16
|
+
} from "node:fs";
|
|
17
|
+
import { basename, extname, join, resolve } from "node:path";
|
|
18
|
+
import type { DmgConfig } from "../config.ts";
|
|
19
|
+
import { Real, writeBplist } from "./bplist.ts";
|
|
20
|
+
import { type DsStoreRecord, writeDsStore } from "./dsStore.ts";
|
|
21
|
+
import { exec, execOrThrow } from "./exec.ts";
|
|
22
|
+
import { aliasRecord } from "./macAlias.ts";
|
|
23
|
+
import { PACKAGE_ROOT } from "./root.ts";
|
|
24
|
+
|
|
25
|
+
interface Point {
|
|
26
|
+
x: number;
|
|
27
|
+
y: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface ResolvedDmg {
|
|
31
|
+
/** Absolute; null for a plain window. */
|
|
32
|
+
background: { image: string; image2x?: string } | null;
|
|
33
|
+
/** The window's size defaults to the background's. */
|
|
34
|
+
window: { x: number; y: number; width?: number; height?: number };
|
|
35
|
+
iconSize: number;
|
|
36
|
+
textSize: number;
|
|
37
|
+
app: Point;
|
|
38
|
+
applications: Point;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const DEFAULT_BACKGROUND = {
|
|
42
|
+
image: join(PACKAGE_ROOT, "native", "macos", "dmg", "background.png"),
|
|
43
|
+
image2x: join(PACKAGE_ROOT, "native", "macos", "dmg", "background@2x.png"),
|
|
44
|
+
};
|
|
45
|
+
const DEFAULT_SIZE = { width: 660, height: 400 };
|
|
46
|
+
|
|
47
|
+
const TITLE_BAR = 28;
|
|
48
|
+
export const DMG_DEFAULTS = {
|
|
49
|
+
window: { x: 200, y: 120 },
|
|
50
|
+
iconSize: 128,
|
|
51
|
+
textSize: 13,
|
|
52
|
+
app: { x: 180, y: 170 },
|
|
53
|
+
applications: { x: 480, y: 170 },
|
|
54
|
+
} as const;
|
|
55
|
+
|
|
56
|
+
const isNumber = (value: unknown, min = 0): value is number =>
|
|
57
|
+
typeof value === "number" && Number.isFinite(value) && value >= min;
|
|
58
|
+
|
|
59
|
+
export function validateDmg(dmg: DmgConfig | undefined, appDir: string, problems: string[]): ResolvedDmg {
|
|
60
|
+
const raw: DmgConfig = dmg && typeof dmg === "object" ? dmg : {};
|
|
61
|
+
if (dmg !== undefined && (!dmg || typeof dmg !== "object")) problems.push("desktop.dmg must be an object");
|
|
62
|
+
const file = (key: "background" | "background2x", value: unknown): string | undefined => {
|
|
63
|
+
if (value === undefined) return undefined;
|
|
64
|
+
const path = typeof value === "string" && value ? resolve(appDir, value) : null;
|
|
65
|
+
if (!path || !existsSync(path) || !statSync(path).isFile()) {
|
|
66
|
+
problems.push(`desktop.dmg.${key} must be an image file (got ${JSON.stringify(value)})`);
|
|
67
|
+
return undefined;
|
|
68
|
+
}
|
|
69
|
+
return path;
|
|
70
|
+
};
|
|
71
|
+
if (raw.background !== undefined && raw.background !== false && typeof raw.background !== "string")
|
|
72
|
+
problems.push(`desktop.dmg.background must be an image file or false (got ${JSON.stringify(raw.background)})`);
|
|
73
|
+
const image = raw.background === false ? undefined : file("background", raw.background);
|
|
74
|
+
const image2x = file("background2x", raw.background2x);
|
|
75
|
+
if (raw.background2x !== undefined && typeof raw.background !== "string")
|
|
76
|
+
problems.push("desktop.dmg.background2x needs desktop.dmg.background, the same picture at its size in points");
|
|
77
|
+
const window = { ...DMG_DEFAULTS.window, ...(raw.window && typeof raw.window === "object" ? raw.window : {}) };
|
|
78
|
+
for (const [key, value] of Object.entries(window))
|
|
79
|
+
if (!isNumber(value, key === "width" || key === "height" ? 1 : 0))
|
|
80
|
+
problems.push(`desktop.dmg.window.${key} must be a positive number of points (got ${JSON.stringify(value)})`);
|
|
81
|
+
for (const key of ["iconSize", "textSize"] as const)
|
|
82
|
+
if (raw[key] !== undefined && !isNumber(raw[key], 1))
|
|
83
|
+
problems.push(`desktop.dmg.${key} must be a positive number of points (got ${JSON.stringify(raw[key])})`);
|
|
84
|
+
const point = (key: "app" | "applications"): Point => {
|
|
85
|
+
const value = raw[key];
|
|
86
|
+
if (value === undefined) return DMG_DEFAULTS[key];
|
|
87
|
+
if (!value || typeof value !== "object" || !isNumber(value.x) || !isNumber(value.y)) {
|
|
88
|
+
problems.push(`desktop.dmg.${key} must be { x, y }, the icon's center in points (got ${JSON.stringify(value)})`);
|
|
89
|
+
return DMG_DEFAULTS[key];
|
|
90
|
+
}
|
|
91
|
+
return { x: value.x, y: value.y };
|
|
92
|
+
};
|
|
93
|
+
return {
|
|
94
|
+
background:
|
|
95
|
+
raw.background === false
|
|
96
|
+
? null
|
|
97
|
+
: raw.background === undefined
|
|
98
|
+
? DEFAULT_BACKGROUND
|
|
99
|
+
: image
|
|
100
|
+
? { image, ...(image2x ? { image2x } : {}) }
|
|
101
|
+
: null,
|
|
102
|
+
window,
|
|
103
|
+
iconSize: raw.iconSize ?? DMG_DEFAULTS.iconSize,
|
|
104
|
+
textSize: raw.textSize ?? DMG_DEFAULTS.textSize,
|
|
105
|
+
app: point("app"),
|
|
106
|
+
applications: point("applications"),
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** A PNG's size from its header, anything else's from sips. */
|
|
111
|
+
async function imageSize(path: string): Promise<{ width: number; height: number }> {
|
|
112
|
+
const head = readFileSync(path).subarray(0, 24);
|
|
113
|
+
if (head.length === 24 && head.readUInt32BE(0) === 0x89504e47)
|
|
114
|
+
return { width: head.readUInt32BE(16), height: head.readUInt32BE(20) };
|
|
115
|
+
const { stdout } = await execOrThrow(["sips", "-g", "pixelWidth", "-g", "pixelHeight", path], { echo: false });
|
|
116
|
+
const width = Number(/pixelWidth: (\d+)/.exec(stdout)?.[1]);
|
|
117
|
+
const height = Number(/pixelHeight: (\d+)/.exec(stdout)?.[1]);
|
|
118
|
+
if (!width || !height) throw new Error(`cannot read the size of ${path}`);
|
|
119
|
+
return { width, height };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** The picture the image carries: a 1x and 2x pair as one multi-resolution TIFF, which Finder picks from per display. */
|
|
123
|
+
async function stageBackground(background: NonNullable<ResolvedDmg["background"]>, dir: string): Promise<string> {
|
|
124
|
+
mkdirSync(dir, { recursive: true });
|
|
125
|
+
if (background.image2x) {
|
|
126
|
+
const out = join(dir, "background.tiff");
|
|
127
|
+
await execOrThrow(["tiffutil", "-cathidpicheck", background.image, background.image2x, "-out", out], {
|
|
128
|
+
echo: false,
|
|
129
|
+
});
|
|
130
|
+
return out;
|
|
131
|
+
}
|
|
132
|
+
const out = join(dir, `background${extname(background.image).toLowerCase() || ".png"}`);
|
|
133
|
+
copyFileSync(background.image, out);
|
|
134
|
+
return out;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function dmgRecords(
|
|
138
|
+
layout: ResolvedDmg,
|
|
139
|
+
{
|
|
140
|
+
appName,
|
|
141
|
+
size,
|
|
142
|
+
backgroundAlias,
|
|
143
|
+
}: { appName: string; size: { width: number; height: number }; backgroundAlias?: Uint8Array },
|
|
144
|
+
): DsStoreRecord[] {
|
|
145
|
+
const { x, y } = layout.window;
|
|
146
|
+
const blob = (value: Uint8Array) => ({ type: "blob" as const, value });
|
|
147
|
+
const iconAt = ({ x, y }: Point) => {
|
|
148
|
+
const value = new Uint8Array(16);
|
|
149
|
+
const view = new DataView(value.buffer);
|
|
150
|
+
view.setUint32(0, Math.round(x));
|
|
151
|
+
view.setUint32(4, Math.round(y));
|
|
152
|
+
view.setUint32(8, 0xffffffff);
|
|
153
|
+
view.setUint32(12, 0xffff0000);
|
|
154
|
+
return blob(value);
|
|
155
|
+
};
|
|
156
|
+
const bwsp = writeBplist({
|
|
157
|
+
WindowBounds: `{{${x}, ${y}}, {${size.width}, ${size.height + TITLE_BAR}}}`,
|
|
158
|
+
ShowToolbar: false,
|
|
159
|
+
ShowSidebar: false,
|
|
160
|
+
ShowStatusBar: false,
|
|
161
|
+
ShowPathbar: false,
|
|
162
|
+
ShowTabView: false,
|
|
163
|
+
ContainerShowSidebar: false,
|
|
164
|
+
PreviewPaneVisibility: false,
|
|
165
|
+
SidebarWidth: 0,
|
|
166
|
+
});
|
|
167
|
+
const icvp = writeBplist({
|
|
168
|
+
viewOptionsVersion: 1,
|
|
169
|
+
backgroundType: backgroundAlias ? 2 : 0,
|
|
170
|
+
...(backgroundAlias ? { backgroundImageAlias: backgroundAlias } : {}),
|
|
171
|
+
backgroundColorRed: new Real(1),
|
|
172
|
+
backgroundColorGreen: new Real(1),
|
|
173
|
+
backgroundColorBlue: new Real(1),
|
|
174
|
+
gridOffsetX: new Real(0),
|
|
175
|
+
gridOffsetY: new Real(0),
|
|
176
|
+
gridSpacing: new Real(100),
|
|
177
|
+
arrangeBy: "none",
|
|
178
|
+
showIconPreview: true,
|
|
179
|
+
showItemInfo: false,
|
|
180
|
+
labelOnBottom: true,
|
|
181
|
+
textSize: new Real(layout.textSize),
|
|
182
|
+
iconSize: new Real(layout.iconSize),
|
|
183
|
+
scrollPositionX: new Real(0),
|
|
184
|
+
scrollPositionY: new Real(0),
|
|
185
|
+
});
|
|
186
|
+
return [
|
|
187
|
+
{ name: ".", code: "bwsp", value: blob(bwsp) },
|
|
188
|
+
{ name: ".", code: "icvp", value: blob(icvp) },
|
|
189
|
+
{ name: ".", code: "icvl", value: { type: "type", value: "icnv" } },
|
|
190
|
+
{ name: ".", code: "vSrn", value: { type: "long", value: 1 } },
|
|
191
|
+
{ name: appName, code: "Iloc", value: iconAt(layout.app) },
|
|
192
|
+
{ name: "Applications", code: "Iloc", value: iconAt(layout.applications) },
|
|
193
|
+
];
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const CUSTOM_ICON_FINDER_INFO = `${"0".repeat(16)}0400${"0".repeat(44)}`;
|
|
197
|
+
|
|
198
|
+
async function detach(mountPoint: string): Promise<void> {
|
|
199
|
+
for (let attempt = 0; ; attempt++) {
|
|
200
|
+
const result = await exec(["hdiutil", "detach", mountPoint, ...(attempt >= 3 ? ["-force"] : [])], { echo: false });
|
|
201
|
+
if (result.code === 0) return;
|
|
202
|
+
|
|
203
|
+
if (attempt >= 5)
|
|
204
|
+
throw new Error(`hdiutil detach ${mountPoint} failed: ${(result.stderr || result.stdout).trim()}`);
|
|
205
|
+
await Bun.sleep(1000 * (attempt + 1));
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export async function buildDmg(
|
|
210
|
+
appPath: string,
|
|
211
|
+
dmgPath: string,
|
|
212
|
+
volumeName: string,
|
|
213
|
+
workDir: string,
|
|
214
|
+
{ layout, volumeIcon }: { layout: ResolvedDmg; volumeIcon?: string },
|
|
215
|
+
): Promise<void> {
|
|
216
|
+
const staging = join(workDir, "dmg");
|
|
217
|
+
const writable = join(workDir, "dmg-rw.dmg");
|
|
218
|
+
const mountPoint = join(workDir, "dmg-mount");
|
|
219
|
+
rmSync(staging, { recursive: true, force: true });
|
|
220
|
+
rmSync(writable, { force: true });
|
|
221
|
+
mkdirSync(staging, { recursive: true });
|
|
222
|
+
const appName = basename(appPath);
|
|
223
|
+
await execOrThrow(["ditto", appPath, join(staging, appName)], { echo: false });
|
|
224
|
+
symlinkSync("/Applications", join(staging, "Applications"));
|
|
225
|
+
|
|
226
|
+
mkdirSync(join(staging, ".fseventsd"));
|
|
227
|
+
writeFileSync(join(staging, ".fseventsd", "no_log"), "");
|
|
228
|
+
const background = layout.background ? await stageBackground(layout.background, join(staging, ".background")) : null;
|
|
229
|
+
if (volumeIcon && existsSync(volumeIcon)) copyFileSync(volumeIcon, join(staging, ".VolumeIcon.icns"));
|
|
230
|
+
const size = {
|
|
231
|
+
...(layout.background ? await imageSize(layout.background.image) : DEFAULT_SIZE),
|
|
232
|
+
...(layout.window.width ? { width: layout.window.width } : {}),
|
|
233
|
+
...(layout.window.height ? { height: layout.window.height } : {}),
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
const { stdout } = await execOrThrow(["du", "-sk", staging], { echo: false });
|
|
237
|
+
const kilobytes = Number(stdout.trim().split(/\s+/)[0]) || 0;
|
|
238
|
+
await execOrThrow(
|
|
239
|
+
[
|
|
240
|
+
"hdiutil",
|
|
241
|
+
"create",
|
|
242
|
+
"-volname",
|
|
243
|
+
volumeName,
|
|
244
|
+
"-srcfolder",
|
|
245
|
+
staging,
|
|
246
|
+
"-fs",
|
|
247
|
+
"HFS+",
|
|
248
|
+
"-format",
|
|
249
|
+
"UDRW",
|
|
250
|
+
"-size",
|
|
251
|
+
`${Math.ceil(kilobytes * 1.1) + 10 * 1024}k`,
|
|
252
|
+
"-ov",
|
|
253
|
+
writable,
|
|
254
|
+
],
|
|
255
|
+
{ echo: false },
|
|
256
|
+
);
|
|
257
|
+
const volumeCreated = volumeHeaderCreateDate(writable);
|
|
258
|
+
rmSync(mountPoint, { recursive: true, force: true });
|
|
259
|
+
mkdirSync(mountPoint, { recursive: true });
|
|
260
|
+
await execOrThrow(
|
|
261
|
+
["hdiutil", "attach", writable, "-readwrite", "-noverify", "-noautoopen", "-nobrowse", "-mountpoint", mountPoint],
|
|
262
|
+
{ echo: false },
|
|
263
|
+
);
|
|
264
|
+
try {
|
|
265
|
+
const backgroundAlias = background
|
|
266
|
+
? aliasFor(mountPoint, { volumeName, volumeCreated }, join(".background", basename(background)))
|
|
267
|
+
: undefined;
|
|
268
|
+
const records = dmgRecords(layout, { appName, size, ...(backgroundAlias ? { backgroundAlias } : {}) });
|
|
269
|
+
writeFileSync(join(mountPoint, ".DS_Store"), writeDsStore(records));
|
|
270
|
+
const hidden = [".background", ".VolumeIcon.icns", ".DS_Store", ".fseventsd"].map((name) => join(mountPoint, name));
|
|
271
|
+
await execOrThrow(["chflags", "hidden", ...hidden.filter((path) => existsSync(path))], { echo: false });
|
|
272
|
+
if (existsSync(join(mountPoint, ".VolumeIcon.icns")))
|
|
273
|
+
await execOrThrow(["xattr", "-wx", "com.apple.FinderInfo", CUSTOM_ICON_FINDER_INFO, mountPoint], { echo: false });
|
|
274
|
+
rmSync(join(mountPoint, ".Trashes"), { recursive: true, force: true });
|
|
275
|
+
} finally {
|
|
276
|
+
await detach(mountPoint);
|
|
277
|
+
}
|
|
278
|
+
rmSync(dmgPath, { force: true });
|
|
279
|
+
await execOrThrow(
|
|
280
|
+
["hdiutil", "convert", writable, "-format", "UDZO", "-imagekey", "zlib-level=9", "-ov", "-o", dmgPath],
|
|
281
|
+
{ echo: false },
|
|
282
|
+
);
|
|
283
|
+
rmSync(writable, { force: true });
|
|
284
|
+
rmSync(staging, { recursive: true, force: true });
|
|
285
|
+
rmSync(mountPoint, { recursive: true, force: true });
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* The createDate of the HFS+ volume a read-write image holds: its header ("H+", version 4) sits 1024 bytes into the
|
|
290
|
+
* partition, which `hdiutil create` puts within the image's first mebibyte, its data stored as is.
|
|
291
|
+
*/
|
|
292
|
+
export function volumeHeaderCreateDate(image: string): number {
|
|
293
|
+
const head = new Uint8Array(1024 * 1024);
|
|
294
|
+
const fd = openSync(image, "r");
|
|
295
|
+
const length = (() => {
|
|
296
|
+
try {
|
|
297
|
+
return readSync(fd, head, 0, head.length, 0);
|
|
298
|
+
} finally {
|
|
299
|
+
closeSync(fd);
|
|
300
|
+
}
|
|
301
|
+
})();
|
|
302
|
+
for (let offset = 1024; offset + 512 <= length; offset += 512)
|
|
303
|
+
if (head[offset] === 0x48 && head[offset + 1] === 0x2b && head[offset + 2] === 0 && head[offset + 3] === 4)
|
|
304
|
+
return new DataView(head.buffer, offset).getUint32(16);
|
|
305
|
+
throw new Error(`no HFS+ volume header in the first MiB of ${image}`);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** The background's alias as the mounted image names it, while it is mounted where it is being written. */
|
|
309
|
+
function aliasFor(
|
|
310
|
+
mountPoint: string,
|
|
311
|
+
{ volumeName, volumeCreated }: { volumeName: string; volumeCreated: number },
|
|
312
|
+
relative: string,
|
|
313
|
+
): Uint8Array {
|
|
314
|
+
const target = join(mountPoint, relative);
|
|
315
|
+
const parent = join(target, "..");
|
|
316
|
+
const file = lstatSync(target);
|
|
317
|
+
const folder = lstatSync(parent);
|
|
318
|
+
return aliasRecord({
|
|
319
|
+
volumeName,
|
|
320
|
+
volumeCreated,
|
|
321
|
+
volumePath: join("/Volumes", volumeName),
|
|
322
|
+
kind: "file",
|
|
323
|
+
name: basename(target),
|
|
324
|
+
cnid: file.ino,
|
|
325
|
+
created: file.birthtime,
|
|
326
|
+
parentName: basename(parent),
|
|
327
|
+
parentCnid: folder.ino,
|
|
328
|
+
cnidPath: [folder.ino],
|
|
329
|
+
posixPath: `/${relative}`,
|
|
330
|
+
});
|
|
331
|
+
}
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
|
|
2
|
+
|
|
3
|
+
export type DsStoreValue =
|
|
4
|
+
| { type: "long"; value: number }
|
|
5
|
+
| { type: "bool"; value: boolean }
|
|
6
|
+
| { type: "type"; value: string }
|
|
7
|
+
| { type: "blob"; value: Uint8Array }
|
|
8
|
+
| { type: "ustr"; value: string };
|
|
9
|
+
|
|
10
|
+
export interface DsStoreRecord {
|
|
11
|
+
/** The file the record is about, "." for the folder itself. */
|
|
12
|
+
name: string;
|
|
13
|
+
/** Four letters: Iloc (icon location), bwsp (window), icvp (icon view), vSrn, icvl … */
|
|
14
|
+
code: string;
|
|
15
|
+
value: DsStoreValue;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const PAGE_SIZE = 4096;
|
|
19
|
+
|
|
20
|
+
class Writer {
|
|
21
|
+
#chunks: Uint8Array[] = [];
|
|
22
|
+
length = 0;
|
|
23
|
+
bytes(data: Uint8Array): this {
|
|
24
|
+
this.#chunks.push(data);
|
|
25
|
+
this.length += data.length;
|
|
26
|
+
return this;
|
|
27
|
+
}
|
|
28
|
+
u8(value: number): this {
|
|
29
|
+
return this.bytes(Uint8Array.of(value));
|
|
30
|
+
}
|
|
31
|
+
u32(value: number): this {
|
|
32
|
+
const data = new Uint8Array(4);
|
|
33
|
+
new DataView(data.buffer).setUint32(0, value >>> 0);
|
|
34
|
+
return this.bytes(data);
|
|
35
|
+
}
|
|
36
|
+
fourCC(code: string): this {
|
|
37
|
+
if (!/^[\x20-\x7e]{4}$/.test(code)) throw new Error(`not a four-letter code: ${JSON.stringify(code)}`);
|
|
38
|
+
return this.bytes(new TextEncoder().encode(code));
|
|
39
|
+
}
|
|
40
|
+
utf16(text: string): this {
|
|
41
|
+
const data = new Uint8Array(text.length * 2);
|
|
42
|
+
const view = new DataView(data.buffer);
|
|
43
|
+
for (let i = 0; i < text.length; i++) view.setUint16(i * 2, text.charCodeAt(i));
|
|
44
|
+
return this.bytes(data);
|
|
45
|
+
}
|
|
46
|
+
toBytes(): Uint8Array {
|
|
47
|
+
const out = new Uint8Array(this.length);
|
|
48
|
+
let at = 0;
|
|
49
|
+
for (const chunk of this.#chunks) {
|
|
50
|
+
out.set(chunk, at);
|
|
51
|
+
at += chunk.length;
|
|
52
|
+
}
|
|
53
|
+
return out;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Finder compares names as HFS+ does, case-insensitively; a window's few ASCII names need nothing more. */
|
|
58
|
+
function compareRecords(a: DsStoreRecord, b: DsStoreRecord): number {
|
|
59
|
+
const left = a.name.toLowerCase();
|
|
60
|
+
const right = b.name.toLowerCase();
|
|
61
|
+
if (left !== right) return left < right ? -1 : 1;
|
|
62
|
+
return a.code < b.code ? -1 : a.code > b.code ? 1 : 0;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function writeRecord(out: Writer, { name, code, value }: DsStoreRecord): void {
|
|
66
|
+
out.u32(name.length).utf16(name).fourCC(code).fourCC(value.type);
|
|
67
|
+
if (value.type === "long") out.u32(value.value);
|
|
68
|
+
else if (value.type === "bool") out.u8(value.value ? 1 : 0);
|
|
69
|
+
else if (value.type === "type") out.fourCC(value.value);
|
|
70
|
+
else if (value.type === "blob") out.u32(value.value.length).bytes(value.value);
|
|
71
|
+
else out.u32(value.value.length).utf16(value.value);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** The buddy allocator's free lists after the given allocations, each address a power-of-two block. */
|
|
75
|
+
class Buddy {
|
|
76
|
+
readonly free: number[][] = Array.from({ length: 32 }, () => []);
|
|
77
|
+
constructor() {
|
|
78
|
+
this.free[31]?.push(0);
|
|
79
|
+
}
|
|
80
|
+
allocate(size: number): { offset: number; width: number } {
|
|
81
|
+
const width = Math.max(5, Math.ceil(Math.log2(size)));
|
|
82
|
+
let w = width;
|
|
83
|
+
while (w < 32 && !this.free[w]?.length) w++;
|
|
84
|
+
const offset = this.free[w]?.shift();
|
|
85
|
+
if (offset === undefined) throw new Error(`no free block of ${size} bytes`);
|
|
86
|
+
while (w > width) {
|
|
87
|
+
w--;
|
|
88
|
+
this.free[w]?.push(offset + 2 ** w);
|
|
89
|
+
this.free[w]?.sort((a, b) => a - b);
|
|
90
|
+
}
|
|
91
|
+
return { offset, width };
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function writeDsStore(records: DsStoreRecord[]): Uint8Array {
|
|
96
|
+
const sorted = [...records].sort(compareRecords);
|
|
97
|
+
const leaf = new Writer().u32(0).u32(sorted.length);
|
|
98
|
+
for (const record of sorted) writeRecord(leaf, record);
|
|
99
|
+
if (leaf.length > PAGE_SIZE)
|
|
100
|
+
throw new Error(
|
|
101
|
+
`${sorted.length} .DS_Store records need ${leaf.length} bytes, more than one ${PAGE_SIZE}-byte node`,
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
const buddy = new Buddy();
|
|
105
|
+
buddy.allocate(32);
|
|
106
|
+
const root = buddy.allocate(2048);
|
|
107
|
+
const header = buddy.allocate(32);
|
|
108
|
+
const node = buddy.allocate(PAGE_SIZE);
|
|
109
|
+
const address = ({ offset, width }: { offset: number; width: number }) => offset | width;
|
|
110
|
+
|
|
111
|
+
const dsdb = new Writer().u32(2).u32(0).u32(sorted.length).u32(1).u32(PAGE_SIZE);
|
|
112
|
+
const blocks = [root, header, node];
|
|
113
|
+
const allocator = new Writer().u32(blocks.length).u32(0);
|
|
114
|
+
for (const block of blocks) allocator.u32(address(block));
|
|
115
|
+
for (let i = blocks.length; i < 256; i++) allocator.u32(0);
|
|
116
|
+
allocator.u32(1).u8(4).fourCC("DSDB").u32(1);
|
|
117
|
+
for (const list of buddy.free) {
|
|
118
|
+
allocator.u32(list.length);
|
|
119
|
+
for (const offset of list) allocator.u32(offset);
|
|
120
|
+
}
|
|
121
|
+
if (allocator.length > 2 ** root.width) throw new Error(".DS_Store allocator block overflows");
|
|
122
|
+
|
|
123
|
+
const end = Math.max(...blocks.map(({ offset, width }) => offset + 2 ** width));
|
|
124
|
+
const file = new Uint8Array(4 + end);
|
|
125
|
+
const view = new DataView(file.buffer);
|
|
126
|
+
view.setUint32(0, 1);
|
|
127
|
+
file.set(new TextEncoder().encode("Bud1"), 4);
|
|
128
|
+
view.setUint32(8, root.offset);
|
|
129
|
+
view.setUint32(12, 2 ** root.width);
|
|
130
|
+
view.setUint32(16, root.offset);
|
|
131
|
+
file.set(allocator.toBytes(), 4 + root.offset);
|
|
132
|
+
file.set(dsdb.toBytes(), 4 + header.offset);
|
|
133
|
+
file.set(leaf.toBytes(), 4 + node.offset);
|
|
134
|
+
return file;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Reads back what writeDsStore wrote (one leaf): the tests' check, and a way to look at a .DS_Store. */
|
|
138
|
+
export function readDsStore(file: Uint8Array): DsStoreRecord[] {
|
|
139
|
+
const view = new DataView(file.buffer, file.byteOffset, file.byteLength);
|
|
140
|
+
if (view.getUint32(0) !== 1 || new TextDecoder().decode(file.subarray(4, 8)) !== "Bud1")
|
|
141
|
+
throw new Error("not a .DS_Store");
|
|
142
|
+
const rootOffset = view.getUint32(8);
|
|
143
|
+
const blockAt = (index: number) => {
|
|
144
|
+
const raw = view.getUint32(4 + rootOffset + 8 + index * 4);
|
|
145
|
+
return 4 + (raw & ~0x1f);
|
|
146
|
+
};
|
|
147
|
+
const dsdb = blockAt(1);
|
|
148
|
+
const leafIndex = view.getUint32(dsdb);
|
|
149
|
+
let at = blockAt(leafIndex);
|
|
150
|
+
if (view.getUint32(at) !== 0) throw new Error("only a single-leaf .DS_Store is read");
|
|
151
|
+
const count = view.getUint32(at + 4);
|
|
152
|
+
at += 8;
|
|
153
|
+
const fourCC = () => {
|
|
154
|
+
const code = new TextDecoder().decode(file.subarray(at, at + 4));
|
|
155
|
+
at += 4;
|
|
156
|
+
return code;
|
|
157
|
+
};
|
|
158
|
+
const utf16 = (units: number) => {
|
|
159
|
+
let text = "";
|
|
160
|
+
for (let i = 0; i < units; i++) text += String.fromCharCode(view.getUint16(at + i * 2));
|
|
161
|
+
at += units * 2;
|
|
162
|
+
return text;
|
|
163
|
+
};
|
|
164
|
+
const records: DsStoreRecord[] = [];
|
|
165
|
+
for (let i = 0; i < count; i++) {
|
|
166
|
+
const nameLength = view.getUint32(at);
|
|
167
|
+
at += 4;
|
|
168
|
+
const name = utf16(nameLength);
|
|
169
|
+
const code = fourCC();
|
|
170
|
+
const type = fourCC();
|
|
171
|
+
let value: DsStoreValue;
|
|
172
|
+
if (type === "long") {
|
|
173
|
+
value = { type, value: view.getUint32(at) };
|
|
174
|
+
at += 4;
|
|
175
|
+
} else if (type === "bool") {
|
|
176
|
+
value = { type, value: file[at] === 1 };
|
|
177
|
+
at += 1;
|
|
178
|
+
} else if (type === "type") value = { type, value: fourCC() };
|
|
179
|
+
else if (type === "blob") {
|
|
180
|
+
const length = view.getUint32(at);
|
|
181
|
+
value = { type, value: file.slice(at + 4, at + 4 + length) };
|
|
182
|
+
at += 4 + length;
|
|
183
|
+
} else if (type === "ustr") {
|
|
184
|
+
const length = view.getUint32(at);
|
|
185
|
+
at += 4;
|
|
186
|
+
value = { type, value: utf16(length) };
|
|
187
|
+
} else throw new Error(`unsupported .DS_Store type ${type}`);
|
|
188
|
+
records.push({ name, code, value });
|
|
189
|
+
}
|
|
190
|
+
return records;
|
|
191
|
+
}
|