acrylblends 1.4.0 → 1.5.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/README.md +5 -2
- package/dist/index.d.ts +7 -4
- package/dist/index.js +83 -33
- package/dist/index.js.map +1 -1
- package/legacy/README.md +1 -1
- package/legacy/agent/46c6eadf9230/src/app.ts +293 -0
- package/legacy/agent/46c6eadf9230/src/routes.ts +163 -0
- package/legacy/layouts.json +15 -0
- package/lib/{client-modules-Bkdr83jO.js → client-modules-C5qiVroz.js} +1 -1
- package/lib/cordis/door.js +1 -1
- package/lib/node-server/register.js +6 -1
- package/lib/node-server.js +1 -1
- package/lib/{transpile-QUK9b3eG.js → transpile-CRRaixyS.js} +19 -4
- package/package.json +1 -1
- package/plugins/acryl-door/src/cli/acryl.ts +6 -2
- package/plugins/acryl-node-server/src/register.ts +10 -1
- package/plugins/acryl-node-server/src/transpile.ts +25 -6
- package/plugins/acryl-page/src/favicon.ts +19 -0
- package/plugins/acryl-page/src/index.ts +18 -13
- package/plugins/agent-panel/client/main.ts +9 -0
- package/plugins/agent-panel/headless.ts +2 -2
- package/plugins/desktop/app-page.ts +151 -0
- package/plugins/desktop/assets/acrylblends-logo.png +0 -0
- package/plugins/desktop/brand.ts +164 -0
- package/plugins/desktop/frame.ts +88 -7
- package/plugins/desktop/git.ts +44 -0
- package/plugins/desktop/host-supervisor.ts +63 -0
- package/plugins/desktop/launch.ts +17 -1
- package/plugins/desktop/main.ts +268 -17
- package/plugins/desktop/preload.cjs +8 -0
- package/plugins/desktop/product.ts +117 -0
- package/plugins/desktop/window.ts +4 -0
- package/plugins/desktop-web/main.ts +3 -2
- package/plugins/desktop-web/plugin.json +1 -1
- package/plugins/extensions/index.ts +52 -7
- package/plugins/instance/archive.ts +395 -0
- package/plugins/instance/bundle-overlay.ts +10 -0
- package/plugins/instance/bundle.ts +201 -0
- package/plugins/instance/home.ts +386 -0
- package/plugins/instance/index.ts +132 -24
- package/plugins/instance/storage.ts +413 -0
- package/shared/skills/acryl-remove-plugin/SKILL.md +1 -1
- package/templates/agent/README.md +1 -1
- package/templates/agent/gitignore +2 -0
- package/templates/blank/README.md +1 -1
- package/templates/blank/gitignore +2 -0
- package/templates/blank/src/routes.ts +9 -5
- package/templates/chat/gitignore +2 -0
- package/templates/chat/src/chat-app.ts +7 -3
- package/templates/chat/src/llm.ts +7 -1
- package/templates/chat/test/chat.test.ts +16 -1
- package/templates/nodejs/gitignore +2 -0
- package/templates/notes/gitignore +2 -0
- package/templates/todo/README.md +1 -1
- package/templates/todo/gitignore +2 -0
|
@@ -36,13 +36,32 @@ const importSources = new Map<string, string>()
|
|
|
36
36
|
* @returns the compiler.
|
|
37
37
|
* @throws MissingTypeScriptError when the app has no `typescript`.
|
|
38
38
|
*/
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
let
|
|
42
|
-
|
|
43
|
-
if (
|
|
44
|
-
else if (dirname(dir) === dir) throw new MissingTypeScriptError(file, what)
|
|
39
|
+
/** The nearest folder from `start` up whose node_modules has `typescript`, or undefined. */
|
|
40
|
+
function typescriptOwner(start: string): string | undefined {
|
|
41
|
+
for (let dir = start; ; dir = dirname(dir)) {
|
|
42
|
+
if (existsSync(join(dir, 'node_modules', 'typescript', 'package.json'))) return dir
|
|
43
|
+
if (dirname(dir) === dir) return undefined
|
|
45
44
|
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The Program's folder when this process is a desktop product's frame host: the person's source has no packages, the bundle has them. */
|
|
48
|
+
let programPackages: string | undefined
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* In a desktop product's frame host, compile with the Program's packages when a file's own chain has none (`register.ts` calls this once it finds the
|
|
52
|
+
* product's app tree, plugins/instance/bundle.ts). Nowhere else: an app without `typescript` is told so.
|
|
53
|
+
*/
|
|
54
|
+
export function usePackagesOfProgram(dir: string): void {
|
|
55
|
+
programPackages = dir
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function compilerFor(file: string, what: string): Compiler {
|
|
59
|
+
// Only the app's own node_modules chain counts: Node's global folders (NODE_PATH, which a package manager's shims may set) are not the app. In a
|
|
60
|
+
// desktop product the app is the person's source with the Program's packages.
|
|
61
|
+
const owner =
|
|
62
|
+
typescriptOwner(dirname(file)) ??
|
|
63
|
+
(programPackages === undefined ? undefined : typescriptOwner(programPackages))
|
|
64
|
+
if (owner === undefined) throw new MissingTypeScriptError(file, what)
|
|
46
65
|
const path = createRequire(join(owner, 'package.json')).resolve('typescript')
|
|
47
66
|
let compiler = compilers.get(path)
|
|
48
67
|
if (compiler === undefined) {
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The page's own icon: the ACRYL Blends logo (plugins/desktop/assets/acrylblends-logo.png at 64 px) inside an SVG, which is what a browser tab reads at 16 px. The wordmark is
|
|
3
|
+
* unreadable that small. The frontend package the page borrows its scripts from ships DeepSeek's whale as favicon.svg, so the page answers every icon request itself and
|
|
4
|
+
* never serves that file.
|
|
5
|
+
*/
|
|
6
|
+
export const ACRYL_FAVICON_SVG =
|
|
7
|
+
"<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" viewBox=\"0 0 64 64\" role=\"img\" aria-label=\"ACRYL Blends\"><image width=\"64\" height=\"64\" xlink:href=\"data:image/png;base64,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\"/></svg>"
|
|
8
|
+
|
|
9
|
+
/** The web manifest of an app page: its title and the icon above, not the frontend package's own. */
|
|
10
|
+
export function pageManifest(title: string): string {
|
|
11
|
+
return JSON.stringify({
|
|
12
|
+
name: title,
|
|
13
|
+
short_name: title,
|
|
14
|
+
start_url: './',
|
|
15
|
+
scope: './',
|
|
16
|
+
display: 'standalone',
|
|
17
|
+
icons: [{ src: 'favicon.svg', sizes: 'any', type: 'image/svg+xml', purpose: 'any' }],
|
|
18
|
+
})
|
|
19
|
+
}
|
|
@@ -10,6 +10,7 @@ import { dirname, extname, join, normalize, sep } from 'node:path'
|
|
|
10
10
|
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
11
11
|
import type { Context } from '@deepseek-ai/cordis'
|
|
12
12
|
import type {} from 'acryl-web-server'
|
|
13
|
+
import { ACRYL_FAVICON_SVG, pageManifest } from './favicon.ts'
|
|
13
14
|
|
|
14
15
|
/** Stable Cordis plugin name, equal to the package name and the Loader row id. */
|
|
15
16
|
export const name = 'acryl-page'
|
|
@@ -64,12 +65,17 @@ export function clientAssetTags(indexHtml: string): string {
|
|
|
64
65
|
|
|
65
66
|
/** Our document: minimal on purpose; the frame renders into `#root`, and the taps add the boot wire before `</head>`. */
|
|
66
67
|
export function pageDocument(tags: string, title: string, taps: (html: string) => string): string {
|
|
68
|
+
// The title is an app's name, typed by a person: markup in it is text, never the page's.
|
|
69
|
+
const text = title.replace(/[&<>"']/gu, (character) => `&#${String(character.charCodeAt(0))};`)
|
|
67
70
|
const document = `<!doctype html>
|
|
68
71
|
<html lang="en">
|
|
69
72
|
<head>
|
|
70
73
|
<meta charset="utf-8" />
|
|
71
74
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
72
|
-
<title>${
|
|
75
|
+
<title>${text}</title>
|
|
76
|
+
<meta name="application-name" content="${text}" />
|
|
77
|
+
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
|
78
|
+
<link rel="manifest" href="/manifest.webmanifest" />
|
|
73
79
|
${tags}
|
|
74
80
|
</head>
|
|
75
81
|
<body>
|
|
@@ -143,21 +149,20 @@ export function apply(ctx: Context, config: Partial<Config> = {}): void {
|
|
|
143
149
|
await serveFile(assets, pathname.slice('/assets'.length), res, req.method ?? 'GET')
|
|
144
150
|
},
|
|
145
151
|
})
|
|
146
|
-
// Browsers ask for /favicon.ico unconditionally
|
|
152
|
+
// Browsers ask for /favicon.ico unconditionally. The icon is ours (the ACRYL mark), never the frontend package's DeepSeek whale.
|
|
153
|
+
const sendIcon = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
|
|
154
|
+
res.writeHead(200, { 'content-type': 'image/svg+xml', 'content-length': String(Buffer.byteLength(ACRYL_FAVICON_SVG)), 'cache-control': 'no-cache' })
|
|
155
|
+
res.end(req.method === 'HEAD' ? undefined : ACRYL_FAVICON_SVG)
|
|
156
|
+
}
|
|
157
|
+
for (const path of ['/favicon.ico', '/favicon.svg', '/favicon-dark.svg'])
|
|
158
|
+
ctx.webServer.register({ kind: 'exact', path, handler: sendIcon })
|
|
147
159
|
ctx.webServer.register({
|
|
148
160
|
kind: 'exact',
|
|
149
|
-
path: '/
|
|
161
|
+
path: '/manifest.webmanifest',
|
|
150
162
|
handler: async (req: IncomingMessage, res: ServerResponse) => {
|
|
151
|
-
|
|
163
|
+
const manifest = pageManifest(title)
|
|
164
|
+
res.writeHead(200, { 'content-type': 'application/manifest+json', 'content-length': String(Buffer.byteLength(manifest)) })
|
|
165
|
+
res.end(req.method === 'HEAD' ? undefined : manifest)
|
|
152
166
|
},
|
|
153
167
|
})
|
|
154
|
-
for (const file of ['favicon.svg', 'favicon-dark.svg', 'manifest.webmanifest']) {
|
|
155
|
-
ctx.webServer.register({
|
|
156
|
-
kind: 'exact',
|
|
157
|
-
path: `/${file}`,
|
|
158
|
-
handler: async (req: IncomingMessage, res: ServerResponse) => {
|
|
159
|
-
await serveFile(dist, file, res, req.method ?? 'GET')
|
|
160
|
-
},
|
|
161
|
-
})
|
|
162
|
-
}
|
|
163
168
|
}
|
|
@@ -525,6 +525,8 @@ export async function start(prefix: string = '/api/agent-panel'): Promise<void>
|
|
|
525
525
|
const watch = async (source: RunSource, own: boolean): Promise<void> => {
|
|
526
526
|
let live = true
|
|
527
527
|
let changed = false
|
|
528
|
+
// Every run the server finishes ends with `done` or `error`; a stream that stops without either lost its server, and the run with it.
|
|
529
|
+
let finished = false
|
|
528
530
|
let answer: HTMLElement | undefined
|
|
529
531
|
let answered = ''
|
|
530
532
|
// The Assistant's steps on the page, shown under its answer in one line, as the person reads what it did.
|
|
@@ -592,11 +594,18 @@ export async function start(prefix: string = '/api/agent-panel'): Promise<void>
|
|
|
592
594
|
changed = true
|
|
593
595
|
line(`plugin ${String(data.extension)}: ${String(data.action)}`, 'tool')
|
|
594
596
|
} else if (event === 'error') {
|
|
597
|
+
finished = true
|
|
595
598
|
line(String(data.message ?? 'The run failed.'), 'err')
|
|
596
599
|
} else if (event === 'done') {
|
|
600
|
+
finished = true
|
|
597
601
|
changed = changed || data.changed === true
|
|
598
602
|
}
|
|
599
603
|
}
|
|
604
|
+
if (live && working !== undefined && !finished)
|
|
605
|
+
line(
|
|
606
|
+
'The app stopped while the agent was working, and the run ended with it (`npm run dev` restarts the app when a file it runs changes). Ask again to go on.',
|
|
607
|
+
'err',
|
|
608
|
+
)
|
|
600
609
|
} catch (error) {
|
|
601
610
|
line(`The request failed: ${error instanceof Error ? error.message : String(error)}`, 'err')
|
|
602
611
|
} finally {
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
/** The panel's own drivers, all `chat`: the app's agent, and the coding agents (`claude`, `codex`, `pi`) started headless in the app folder. */
|
|
2
2
|
import { type ChildProcess, spawn, spawnSync } from 'node:child_process'
|
|
3
3
|
import { readFileSync } from 'node:fs'
|
|
4
|
-
import { homedir } from 'node:os'
|
|
5
4
|
import { join } from 'node:path'
|
|
6
5
|
import type { Context } from '@deepseek-ai/cordis'
|
|
6
|
+
import { personHome } from '../instance/storage.ts'
|
|
7
7
|
import type { ChatDriver, ChatEvent } from './drivers.ts'
|
|
8
8
|
import { resolveLaunch } from './launch.ts'
|
|
9
9
|
|
|
@@ -78,7 +78,7 @@ export function onPath(program: string): boolean {
|
|
|
78
78
|
/** Whether pi can reach DeepSeek: the key in the environment, or the one pi stored for itself when its owner logged in (`~/.pi/agent/auth.json`, a `deepseek` entry). */
|
|
79
79
|
export function piHasDeepseekKey(
|
|
80
80
|
env: NodeJS.ProcessEnv = process.env,
|
|
81
|
-
home: string =
|
|
81
|
+
home: string = personHome(env),
|
|
82
82
|
): boolean {
|
|
83
83
|
if (env.DEEPSEEK_API_KEY) return true
|
|
84
84
|
try {
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the frame's App pages ask of a desktop product (app-storage.md, "What the person sees"): the first start's "Name your app" and Settings > App
|
|
3
|
+
* (name, size, show the folder, export, import, start over, the other apps, git's licence). The page asks through one IPC channel that `preload.cjs`
|
|
4
|
+
* exposes as `window.acrylBlendsApp`; this module answers, over `AppStorage` and the shell's own ports, so it is tested without Electron.
|
|
5
|
+
*
|
|
6
|
+
* Every answer is a plain result, never a thrown IPC error: the page shows `message` as it is.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { AppInstance } from '../instance/index.ts'
|
|
10
|
+
import type { AppSize, AppStorage, RegisteredApp } from '../instance/storage.ts'
|
|
11
|
+
import type { ProductGit } from './git.ts'
|
|
12
|
+
|
|
13
|
+
/** The IPC channel; `preload.cjs` names the same string. */
|
|
14
|
+
export const APP_PAGE_CHANNEL = 'acrylblends:app'
|
|
15
|
+
|
|
16
|
+
/** What the page shows. `named` is false until the person names the app (the first start asks). */
|
|
17
|
+
export interface AppPageState {
|
|
18
|
+
readonly id: string
|
|
19
|
+
readonly name: string
|
|
20
|
+
readonly named: boolean
|
|
21
|
+
/** The other apps on this computer, to open one instead. */
|
|
22
|
+
readonly others: readonly {
|
|
23
|
+
readonly id: string
|
|
24
|
+
readonly name: string
|
|
25
|
+
readonly created: string
|
|
26
|
+
}[]
|
|
27
|
+
/** What the OS calls its file manager: the Show button's label. */
|
|
28
|
+
readonly fileManager: 'Finder' | 'Explorer' | 'Files'
|
|
29
|
+
/** The product ships git (its licence and source are offered on the page). */
|
|
30
|
+
readonly git: boolean
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export type AppPageResult =
|
|
34
|
+
| { readonly ok: true; readonly value?: unknown }
|
|
35
|
+
| { readonly ok: false; readonly message: string }
|
|
36
|
+
|
|
37
|
+
export interface AppPagePorts {
|
|
38
|
+
readonly storage: AppStorage
|
|
39
|
+
/** The id of the app the window shows now. */
|
|
40
|
+
current(): string
|
|
41
|
+
/** A new app's starting files (the first start's seed). */
|
|
42
|
+
readonly seed: (source: string) => void
|
|
43
|
+
/**
|
|
44
|
+
* Stop the frame host, run `next` (which may move the old app's folders), and start the host on the app it returns, reloading the windows. When
|
|
45
|
+
* `next` fails, the app that ran comes back and the failure is the answer.
|
|
46
|
+
*/
|
|
47
|
+
switchTo(next: () => AppInstance): Promise<void>
|
|
48
|
+
/** The save dialog for an export (`suggested` is the file name), or undefined when the person cancels. */
|
|
49
|
+
chooseExportFile(suggested: string): Promise<string | undefined>
|
|
50
|
+
/** The open dialog for an import, or undefined when the person cancels. */
|
|
51
|
+
chooseImportFile(): Promise<string | undefined>
|
|
52
|
+
/** Open a file with the OS's own application for it. */
|
|
53
|
+
openPath(path: string): Promise<void>
|
|
54
|
+
/** The app's label changed: the process name and the window titles follow. */
|
|
55
|
+
renamed(app: RegisteredApp): void
|
|
56
|
+
readonly platform: NodeJS.Platform
|
|
57
|
+
readonly git?: ProductGit
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const fileManagerOf = (platform: NodeJS.Platform): AppPageState['fileManager'] =>
|
|
61
|
+
platform === 'darwin' ? 'Finder' : platform === 'win32' ? 'Explorer' : 'Files'
|
|
62
|
+
|
|
63
|
+
/** A file name every OS accepts, from the app's label: the export's suggested name. */
|
|
64
|
+
export function exportFileName(name: string): string {
|
|
65
|
+
const safe = [...name]
|
|
66
|
+
.filter((character) => (character.codePointAt(0) ?? 0) >= 0x20)
|
|
67
|
+
.join('')
|
|
68
|
+
.replace(/[<>:"/\\|?*]/gu, '-')
|
|
69
|
+
.replace(/[. ]+$/u, '')
|
|
70
|
+
.trim()
|
|
71
|
+
return `${safe === '' ? 'app' : safe}.acrylblend`
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** The handler of the page's channel: one operation and its argument, one result. */
|
|
75
|
+
export function appPageHandler(
|
|
76
|
+
ports: AppPagePorts,
|
|
77
|
+
): (op: unknown, arg: unknown) => Promise<AppPageResult> {
|
|
78
|
+
const state = (): AppPageState => {
|
|
79
|
+
const id = ports.current()
|
|
80
|
+
const app = ports.storage.app(id)
|
|
81
|
+
return {
|
|
82
|
+
id,
|
|
83
|
+
name: app.name,
|
|
84
|
+
named: app.named === true,
|
|
85
|
+
others: ports.storage
|
|
86
|
+
.apps()
|
|
87
|
+
.filter((other) => other.id !== id)
|
|
88
|
+
.map((other) => ({ id: other.id, name: other.name, created: other.created })),
|
|
89
|
+
fileManager: fileManagerOf(ports.platform),
|
|
90
|
+
git: ports.git !== undefined,
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
const run = async (op: unknown, arg: unknown): Promise<unknown> => {
|
|
94
|
+
switch (op) {
|
|
95
|
+
case 'state':
|
|
96
|
+
return state()
|
|
97
|
+
case 'size':
|
|
98
|
+
return (await ports.storage.size(ports.current())) satisfies AppSize
|
|
99
|
+
case 'rename': {
|
|
100
|
+
if (typeof arg !== 'string') throw new Error('a name is text')
|
|
101
|
+
ports.renamed(ports.storage.rename(ports.current(), arg))
|
|
102
|
+
return state()
|
|
103
|
+
}
|
|
104
|
+
case 'reveal':
|
|
105
|
+
ports.storage.reveal(ports.current())
|
|
106
|
+
return undefined
|
|
107
|
+
case 'export': {
|
|
108
|
+
const data = (arg as { data?: unknown } | undefined)?.data === true
|
|
109
|
+
const id = ports.current()
|
|
110
|
+
const file = await ports.chooseExportFile(exportFileName(ports.storage.app(id).name))
|
|
111
|
+
if (file === undefined) return { cancelled: true }
|
|
112
|
+
ports.storage.export(id, file, { data })
|
|
113
|
+
return { file }
|
|
114
|
+
}
|
|
115
|
+
case 'import': {
|
|
116
|
+
const file = await ports.chooseImportFile()
|
|
117
|
+
if (file === undefined) return { cancelled: true }
|
|
118
|
+
// Made beside the running app, which keeps running until the new one is ready to open.
|
|
119
|
+
const made = ports.storage.import(file)
|
|
120
|
+
await ports.switchTo(() => ports.storage.open(made.id))
|
|
121
|
+
return undefined
|
|
122
|
+
}
|
|
123
|
+
case 'startOver': {
|
|
124
|
+
const id = ports.current()
|
|
125
|
+
await ports.switchTo(() => ports.storage.open(ports.storage.reset(id, ports.seed).id))
|
|
126
|
+
return undefined
|
|
127
|
+
}
|
|
128
|
+
case 'open': {
|
|
129
|
+
if (typeof arg !== 'string' || !ports.storage.apps().some((app) => app.id === arg))
|
|
130
|
+
throw new Error('there is no such app on this computer')
|
|
131
|
+
if (arg !== ports.current()) await ports.switchTo(() => ports.storage.open(arg))
|
|
132
|
+
return undefined
|
|
133
|
+
}
|
|
134
|
+
case 'gitLicence':
|
|
135
|
+
case 'gitSource': {
|
|
136
|
+
if (ports.git === undefined) throw new Error('this app ships no git of its own')
|
|
137
|
+
await ports.openPath(op === 'gitLicence' ? ports.git.licence : ports.git.source)
|
|
138
|
+
return undefined
|
|
139
|
+
}
|
|
140
|
+
default:
|
|
141
|
+
throw new Error(`the app page cannot ${String(op)}`)
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return async (op, arg) => {
|
|
145
|
+
try {
|
|
146
|
+
return { ok: true, value: await run(op, arg) }
|
|
147
|
+
} catch (cause) {
|
|
148
|
+
return { ok: false, message: cause instanceof Error ? cause.message : String(cause) }
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
Binary file
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a person sees of the running desktop app: its name and its icon. In development the window runs on the Electron that npm unpacked, so macOS labels the Dock
|
|
3
|
+
* and Cmd+Tab "Electron" and draws the atom, however the window is titled. The label and the icon come from the .app bundle, so on macOS the launcher runs a copy of
|
|
4
|
+
* that bundle under the app's own name with the app's icon: `<acryl root>/electron/<app id>.app`, made once (an APFS clone, so it costs no disk and no time),
|
|
5
|
+
* and again when the name or the icon changes. Linux and Windows start the stock Electron as before.
|
|
6
|
+
*
|
|
7
|
+
* The icon is the app's own `assets/icon.png` when it has one, else the ACRYL Blends logo that ships beside this file.
|
|
8
|
+
*/
|
|
9
|
+
import { execFileSync } from 'node:child_process'
|
|
10
|
+
import { createHash } from 'node:crypto'
|
|
11
|
+
import { cpSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
|
12
|
+
import { basename, dirname, join, resolve } from 'node:path'
|
|
13
|
+
|
|
14
|
+
/** The logo an app without an icon of its own shows. */
|
|
15
|
+
export const DEFAULT_ICON = join(import.meta.dirname, 'assets', 'acrylblends-logo.png')
|
|
16
|
+
|
|
17
|
+
/** The app's name as a person knows it: the package's name (the frame's header says the same), else the folder's. */
|
|
18
|
+
export function appTitle(projectRoot: string): string {
|
|
19
|
+
try {
|
|
20
|
+
const manifest = JSON.parse(readFileSync(join(projectRoot, 'package.json'), 'utf8')) as {
|
|
21
|
+
name?: unknown
|
|
22
|
+
}
|
|
23
|
+
if (typeof manifest.name === 'string' && manifest.name.trim() !== '')
|
|
24
|
+
return manifest.name.trim()
|
|
25
|
+
} catch {
|
|
26
|
+
// No manifest, or not JSON: the folder names the app.
|
|
27
|
+
}
|
|
28
|
+
return basename(resolve(projectRoot))
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** The icon file: the app's `assets/icon.png`, else the logo of ACRYL Blends. */
|
|
32
|
+
export function iconFile(
|
|
33
|
+
projectRoot: string,
|
|
34
|
+
exists: (path: string) => boolean = existsSync,
|
|
35
|
+
): string {
|
|
36
|
+
const own = join(projectRoot, 'assets', 'icon.png')
|
|
37
|
+
return exists(own) ? own : DEFAULT_ICON
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The Info.plist keys that name the bundle. The identifier is per app, so two apps never share a Dock slot or a window state. */
|
|
41
|
+
export function plistNames(name: string, id: string): Record<string, string> {
|
|
42
|
+
return {
|
|
43
|
+
CFBundleName: name,
|
|
44
|
+
CFBundleDisplayName: name,
|
|
45
|
+
CFBundleIdentifier: `com.acrylblends.app.${id}`,
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The .app bundle around Electron's executable (`.../Electron.app/Contents/MacOS/Electron`), or undefined for a path of another shape. */
|
|
50
|
+
export function bundleOf(binary: string): string | undefined {
|
|
51
|
+
const bundle = dirname(dirname(dirname(binary)))
|
|
52
|
+
return bundle.endsWith('.app') ? bundle : undefined
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** What the copy was made from: when this changes (a new name, a new icon, a new Electron), the copy is made again. */
|
|
56
|
+
export function stampOf(name: string, id: string, icon: Uint8Array, electron: string): string {
|
|
57
|
+
return createHash('sha256')
|
|
58
|
+
.update(JSON.stringify([name, id, electron]))
|
|
59
|
+
.update(icon)
|
|
60
|
+
.digest('hex')
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The iconset sizes macOS reads, as [file, pixels]. */
|
|
64
|
+
const ICONSET: readonly (readonly [string, number])[] = [
|
|
65
|
+
['icon_16x16.png', 16],
|
|
66
|
+
['icon_16x16@2x.png', 32],
|
|
67
|
+
['icon_32x32.png', 32],
|
|
68
|
+
['icon_32x32@2x.png', 64],
|
|
69
|
+
['icon_128x128.png', 128],
|
|
70
|
+
['icon_128x128@2x.png', 256],
|
|
71
|
+
['icon_256x256.png', 256],
|
|
72
|
+
['icon_256x256@2x.png', 512],
|
|
73
|
+
['icon_512x512.png', 512],
|
|
74
|
+
]
|
|
75
|
+
|
|
76
|
+
export interface BrandedElectron {
|
|
77
|
+
/** The executable to start: the branded copy's, or the stock one when a copy could not be made. */
|
|
78
|
+
readonly binary: string
|
|
79
|
+
/** Why the stock Electron runs, when it does on macOS. */
|
|
80
|
+
readonly problem?: string
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The Electron executable to start. macOS: the branded copy of the bundle, made when missing or out of date. Anywhere else, or when the copy fails, the stock executable
|
|
85
|
+
* (a failure is returned, never thrown: a window with the wrong Dock label beats no window). `folder` is `<acryl root>/electron`; nothing is written outside it.
|
|
86
|
+
*/
|
|
87
|
+
export function brandedElectron(options: {
|
|
88
|
+
readonly binary: string
|
|
89
|
+
readonly folder: string
|
|
90
|
+
readonly id: string
|
|
91
|
+
readonly name: string
|
|
92
|
+
readonly icon: string
|
|
93
|
+
readonly platform?: NodeJS.Platform
|
|
94
|
+
}): BrandedElectron {
|
|
95
|
+
const platform = options.platform ?? process.platform
|
|
96
|
+
const stock = { binary: options.binary }
|
|
97
|
+
if (platform !== 'darwin') return stock
|
|
98
|
+
const source = bundleOf(options.binary)
|
|
99
|
+
if (source === undefined) return stock
|
|
100
|
+
const target = join(options.folder, `${options.id}.app`)
|
|
101
|
+
const binary = join(target, 'Contents', 'MacOS', basename(options.binary))
|
|
102
|
+
try {
|
|
103
|
+
const version = execFileSync(
|
|
104
|
+
'plutil',
|
|
105
|
+
['-extract', 'CFBundleVersion', 'raw', '-o', '-', join(source, 'Contents', 'Info.plist')],
|
|
106
|
+
{ encoding: 'utf8' },
|
|
107
|
+
).trim()
|
|
108
|
+
const stamp = stampOf(options.name, options.id, readFileSync(options.icon), version)
|
|
109
|
+
const stampFile = join(options.folder, `${options.id}.stamp`)
|
|
110
|
+
if (existsSync(binary) && readStamp(stampFile) === stamp) return { binary }
|
|
111
|
+
mkdirSync(options.folder, { recursive: true, mode: 0o700 })
|
|
112
|
+
rmSync(target, { recursive: true, force: true })
|
|
113
|
+
cloneBundle(source, target)
|
|
114
|
+
const plist = join(target, 'Contents', 'Info.plist')
|
|
115
|
+
for (const [key, value] of Object.entries(plistNames(options.name, options.id)))
|
|
116
|
+
execFileSync('plutil', ['-replace', key, '-string', value, plist])
|
|
117
|
+
const icns = join(target, 'Contents', 'Resources', 'electron.icns')
|
|
118
|
+
makeIcns(options.icon, icns, join(options.folder, `${options.id}.iconset`))
|
|
119
|
+
// The bundle's seal no longer matches its Info.plist and icon; a local ad-hoc signature makes it whole again so macOS has nothing to object to.
|
|
120
|
+
execFileSync('codesign', ['--force', '--deep', '--sign', '-', target], { stdio: 'ignore' })
|
|
121
|
+
writeFileSync(stampFile, stamp)
|
|
122
|
+
return { binary }
|
|
123
|
+
} catch (cause) {
|
|
124
|
+
return {
|
|
125
|
+
binary: options.binary,
|
|
126
|
+
problem: cause instanceof Error ? cause.message : String(cause),
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function readStamp(file: string): string | undefined {
|
|
132
|
+
try {
|
|
133
|
+
return readFileSync(file, 'utf8')
|
|
134
|
+
} catch {
|
|
135
|
+
return undefined
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** A copy of a bundle: an APFS clone where the volume allows it (instant, no extra disk), else a plain copy that keeps the symbolic links the bundle needs. */
|
|
140
|
+
function cloneBundle(source: string, target: string): void {
|
|
141
|
+
try {
|
|
142
|
+
execFileSync('cp', ['-c', '-R', source, target])
|
|
143
|
+
} catch {
|
|
144
|
+
rmSync(target, { recursive: true, force: true })
|
|
145
|
+
cpSync(source, target, { recursive: true, verbatimSymlinks: true })
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** An .icns from one PNG: the iconset sizes by `sips`, then `iconutil`. The iconset folder is removed after. */
|
|
150
|
+
function makeIcns(png: string, icns: string, iconset: string): void {
|
|
151
|
+
rmSync(iconset, { recursive: true, force: true })
|
|
152
|
+
mkdirSync(iconset, { recursive: true })
|
|
153
|
+
try {
|
|
154
|
+
for (const [file, pixels] of ICONSET)
|
|
155
|
+
execFileSync(
|
|
156
|
+
'sips',
|
|
157
|
+
['-z', String(pixels), String(pixels), png, '--out', join(iconset, file)],
|
|
158
|
+
{ stdio: 'ignore' },
|
|
159
|
+
)
|
|
160
|
+
execFileSync('iconutil', ['-c', 'icns', iconset, '-o', icns])
|
|
161
|
+
} finally {
|
|
162
|
+
rmSync(iconset, { recursive: true, force: true })
|
|
163
|
+
}
|
|
164
|
+
}
|