acrylblends 1.4.0 → 1.5.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.
Files changed (57) hide show
  1. package/README.md +5 -2
  2. package/dist/index.d.ts +7 -4
  3. package/dist/index.js +191 -62
  4. package/dist/index.js.map +1 -1
  5. package/legacy/README.md +1 -1
  6. package/legacy/agent/46c6eadf9230/src/app.ts +293 -0
  7. package/legacy/agent/46c6eadf9230/src/routes.ts +163 -0
  8. package/legacy/blank/a53d41e6ef42/src/app.ts +160 -0
  9. package/legacy/blank/a53d41e6ef42/src/routes.ts +79 -0
  10. package/legacy/layouts.json +30 -0
  11. package/lib/{client-modules-Bkdr83jO.js → client-modules-C5qiVroz.js} +1 -1
  12. package/lib/cordis/door.js +1 -1
  13. package/lib/node-server/register.js +6 -1
  14. package/lib/node-server.js +1 -1
  15. package/lib/{transpile-QUK9b3eG.js → transpile-CRRaixyS.js} +19 -4
  16. package/package.json +1 -1
  17. package/plugins/acryl-door/src/cli/acryl.ts +6 -2
  18. package/plugins/acryl-node-server/src/register.ts +10 -1
  19. package/plugins/acryl-node-server/src/transpile.ts +25 -6
  20. package/plugins/acryl-page/src/favicon.ts +19 -0
  21. package/plugins/acryl-page/src/index.ts +18 -13
  22. package/plugins/agent-panel/client/main.ts +9 -0
  23. package/plugins/agent-panel/headless.ts +2 -2
  24. package/plugins/desktop/app-page.ts +151 -0
  25. package/plugins/desktop/assets/acrylblends-logo.png +0 -0
  26. package/plugins/desktop/brand.ts +164 -0
  27. package/plugins/desktop/frame.ts +88 -7
  28. package/plugins/desktop/git.ts +44 -0
  29. package/plugins/desktop/host-supervisor.ts +63 -0
  30. package/plugins/desktop/launch.ts +17 -1
  31. package/plugins/desktop/main.ts +268 -17
  32. package/plugins/desktop/preload.cjs +8 -0
  33. package/plugins/desktop/product.ts +117 -0
  34. package/plugins/desktop/window.ts +4 -0
  35. package/plugins/desktop-web/main.ts +3 -2
  36. package/plugins/desktop-web/plugin.json +1 -1
  37. package/plugins/extensions/index.ts +52 -7
  38. package/plugins/instance/archive.ts +395 -0
  39. package/plugins/instance/bundle-overlay.ts +10 -0
  40. package/plugins/instance/bundle.ts +201 -0
  41. package/plugins/instance/home.ts +395 -0
  42. package/plugins/instance/index.ts +132 -24
  43. package/plugins/instance/storage.ts +413 -0
  44. package/shared/skills/acryl-remove-plugin/SKILL.md +1 -1
  45. package/templates/agent/README.md +1 -1
  46. package/templates/agent/gitignore +2 -0
  47. package/templates/blank/README.md +1 -1
  48. package/templates/blank/gitignore +2 -0
  49. package/templates/blank/src/routes.ts +9 -5
  50. package/templates/chat/gitignore +2 -0
  51. package/templates/chat/src/chat-app.ts +7 -3
  52. package/templates/chat/src/llm.ts +7 -1
  53. package/templates/chat/test/chat.test.ts +16 -1
  54. package/templates/nodejs/gitignore +2 -0
  55. package/templates/notes/gitignore +2 -0
  56. package/templates/todo/README.md +1 -1
  57. package/templates/todo/gitignore +2 -0
@@ -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
+ }
@@ -1,12 +1,14 @@
1
1
  /**
2
2
  * The desktop window on the ACRYL frame (docs/plans/ide-host-inversion.md, section 27). An app with `scripts/ide-host.mjs` runs inside the frame on `npm run dev`;
3
- * `npm run desktop` shows that same frame: it attaches to a host that already runs (the host's address record, `.acrylblends/frame-url.json`), or starts the host
3
+ * `npm run desktop` shows that same frame: it attaches to a host that already runs (the host's address record, `frame-url.json` in the app's data folder), or starts the host
4
4
  * the way `npm run dev` does and loads the address it prints. Nothing here imports electron: `main.ts` decides with these functions and owns the window.
5
5
  */
6
6
  import { type ChildProcess, spawn, spawnSync } from 'node:child_process'
7
7
  import { existsSync, readFileSync } from 'node:fs'
8
8
  import { connect } from 'node:net'
9
9
  import { join, resolve } from 'node:path'
10
+ import { pathToFileURL } from 'node:url'
11
+ import { appDataHome } from '../instance/index.ts'
10
12
 
11
13
  /** The launcher every frame app has; without it the app runs on its own server (made before the frame). */
12
14
  export const HOST_SCRIPT = join('scripts', 'ide-host.mjs')
@@ -23,11 +25,11 @@ export function isFrameApp(root: string, exists: (path: string) => boolean = exi
23
25
  return exists(join(root, HOST_SCRIPT))
24
26
  }
25
27
 
26
- /** The app home the host uses: `ACRYLBLENDS_HOME` when set, else `<app>/.acrylblends` (the launcher's own rule). */
28
+ /** The app home the host uses: `ACRYLBLENDS_HOME` when set, else the app's data folder under ~/.acryl (`appDataHome`, the one place that decides it). */
27
29
  export function frameHome(root: string, env: Record<string, string | undefined>): string {
28
30
  const pinned = env.ACRYLBLENDS_HOME?.trim()
29
31
  return pinned === undefined || pinned === ''
30
- ? join(resolve(root), '.acrylblends')
32
+ ? appDataHome(resolve(root), process.platform, env)
31
33
  : resolve(pinned)
32
34
  }
33
35
 
@@ -147,25 +149,93 @@ export function hostCommand(
147
149
  : { file: 'npm', args: ['run', 'dev'], shell: false }
148
150
  }
149
151
 
152
+ /** How a host ended: its exit, the last of its output, and whether the window asked for it (`stop`) or it ended by itself. */
153
+ export interface HostEnd {
154
+ readonly code: number | null
155
+ readonly signal: NodeJS.Signals | null
156
+ readonly tail: string
157
+ readonly requested: boolean
158
+ }
159
+
150
160
  export interface FrameHost {
151
161
  /** The frame address the host printed, or a rejection with the host's output when it ended first. */
152
162
  readonly ready: Promise<string>
163
+ /** Resolves once, when the host has ended, whenever that is: after it served the frame too (a stop it did not get asked for is a death). */
164
+ readonly ended: Promise<HostEnd>
153
165
  /** Stop the host and what it started; resolves once it has exited. */
154
166
  stop(): Promise<void>
155
167
  }
156
168
 
169
+ export interface FrameHostLaunch {
170
+ /**
171
+ * A packaged product has no npm to run `npm run dev` with: the host runs on the app's own Electron runtime as Node, with the same loader the dev script uses.
172
+ */
173
+ readonly runtime?: string
174
+ /**
175
+ * Where the Program is when it is not in the app folder: a product's bundle. The host, its loader and the resolver that serves the Program from there
176
+ * and the person's files from the app's source (plugins/instance/bundle-overlay.ts) start from it; the host still runs in the app folder.
177
+ */
178
+ readonly program?: string
179
+ }
180
+
181
+ /**
182
+ * What a packaged host watches: the person's own files that make the app, and nothing else. `node --watch` alone follows only the modules the host has loaded,
183
+ * so a new plugin folder, or a row added to `blend.yaml`, stayed unseen until some loaded file changed (found on a packed app: a plugin written into
184
+ * `plugins/notes/` was not served after 15 s). Naming a path turns the module watching off, so the folders are named whole; `.git` (a commit must not restart
185
+ * the app) and the app's data are not among them.
186
+ */
187
+ export const WATCHED_IN_SOURCE = ['src', 'plugins', 'blend.yaml'] as const
188
+
189
+ /**
190
+ * The arguments of a packaged product's host (the runtime is Electron as Node): `--watch` first, as the app's `dev` script has it, so a saved change in the
191
+ * person's source or plugins restarts the host and the app shows it, with no extensions plugin in the frame to do it (found on a packed app: an edit of
192
+ * `src/routes.ts` stayed unseen until the app was quit). Then the loader and, when the Program is in a bundle, the overlay that serves it.
193
+ */
194
+ export function packagedHostArgs(
195
+ program: string,
196
+ root: string,
197
+ exists: (path: string) => boolean = existsSync,
198
+ ): string[] {
199
+ return [
200
+ '--watch',
201
+ ...WATCHED_IN_SOURCE.map((name) => join(root, name))
202
+ .filter((path) => exists(path))
203
+ .map((path) => `--watch-path=${path}`),
204
+ // The loader first and the overlay second: a hook registered later runs first, so the overlay sees every import before the loader's aliases.
205
+ '--import',
206
+ pathToFileURL(join(program, 'plugins', 'acryl-node-server', 'src', 'register.ts')).href,
207
+ ...(program === root
208
+ ? []
209
+ : [
210
+ '--import',
211
+ pathToFileURL(join(program, 'plugins', 'instance', 'bundle-overlay.ts')).href,
212
+ ]),
213
+ join(program, HOST_SCRIPT),
214
+ ]
215
+ }
216
+
157
217
  /** Start the app's frame host as `npm run dev` does, in the app folder; its output is passed through to `write`. */
158
218
  export function startFrameHost(
159
219
  root: string,
160
220
  env: NodeJS.ProcessEnv,
161
221
  write: (text: string) => void,
162
222
  platform: NodeJS.Platform = process.platform,
223
+ launch: FrameHostLaunch = {},
163
224
  ): FrameHost {
164
- const command = hostCommand(env, platform)
225
+ const program = launch.program ?? root
226
+ const command =
227
+ launch.runtime === undefined
228
+ ? hostCommand(env, platform)
229
+ : { file: launch.runtime, args: packagedHostArgs(program, root), shell: false }
165
230
  // The host leads its own process group on macOS and Linux, so a stop reaches npm, the host and every terminal it runs; Windows stops the tree with taskkill.
166
231
  const child: ChildProcess = spawn(command.file, command.args, {
167
232
  cwd: root,
168
- env: { ...env, ELECTRON_RUN_AS_NODE: undefined },
233
+ env: {
234
+ ...env,
235
+ ...(launch.runtime === undefined
236
+ ? { ELECTRON_RUN_AS_NODE: undefined }
237
+ : { ELECTRON_RUN_AS_NODE: '1' }),
238
+ },
169
239
  stdio: ['ignore', 'pipe', 'pipe'],
170
240
  shell: command.shell,
171
241
  detached: platform !== 'win32',
@@ -173,16 +243,21 @@ export function startFrameHost(
173
243
  })
174
244
  let output = ''
175
245
  let exited = false
246
+ let requested = false
247
+ let outcome: Pick<HostEnd, 'code' | 'signal'> = { code: null, signal: null }
176
248
  const exit = new Promise<void>((done) => {
177
- child.once('exit', () => {
249
+ child.once('exit', (code, signal) => {
178
250
  exited = true
251
+ outcome = { code, signal }
179
252
  done()
180
253
  })
181
- child.once('error', () => {
254
+ child.once('error', (cause) => {
182
255
  exited = true
256
+ output += `\n${cause.message}`
183
257
  done()
184
258
  })
185
259
  })
260
+ const ended = exit.then((): HostEnd => ({ ...outcome, tail: output.slice(-3000), requested }))
186
261
  const ready = new Promise<string>((done, fail) => {
187
262
  const take = (chunk: Buffer) => {
188
263
  const text = chunk.toString()
@@ -213,9 +288,15 @@ export function startFrameHost(
213
288
  // The group is already gone.
214
289
  }
215
290
  }
291
+ // What the host left running (its terminals, the watcher's child) does not outlive a host that ended by itself.
292
+ void exit.then(() => {
293
+ if (!requested) signal('SIGTERM')
294
+ })
216
295
  return {
217
296
  ready,
297
+ ended,
218
298
  async stop() {
299
+ requested = true
219
300
  if (exited) return
220
301
  signal('SIGTERM')
221
302
  const graceful = await Promise.race([
@@ -0,0 +1,44 @@
1
+ /**
2
+ * The git a desktop product ships (desktop/dugite-native, packed by scripts/pack-blank-mac.mjs into `Resources/git`, beside the app). A clean Mac has only
3
+ * the shim that asks to install the Command Line Tools, and Windows has no git, while versions, export, the terminals and every agent run `git`. The
4
+ * frame host gets it first on its PATH, so everything it starts (versions, the workspace's git, terminals, the chat's shell, PTY agents) finds the same git
5
+ * by name. Outside a product, or in a product packed without it, the person's own git is used as before.
6
+ */
7
+
8
+ import { existsSync } from 'node:fs'
9
+ import { join } from 'node:path'
10
+
11
+ export interface ProductGit {
12
+ /** The executable to run (on macOS and Linux a wrapper that finds git's helpers from where it lies). */
13
+ readonly executable: string
14
+ /** The folder to put first on PATH. */
15
+ readonly bin: string
16
+ /** Git's licence and the offer of its source, shown from Settings > App. */
17
+ readonly licence: string
18
+ readonly source: string
19
+ }
20
+
21
+ /** The product's git under `resources` (Electron's `process.resourcesPath`), or undefined when the product carries none. */
22
+ export function productGit(resources: string, platform: NodeJS.Platform): ProductGit | undefined {
23
+ const dir = join(resources, 'git')
24
+ // dugite-native's Windows build is MinGit: `cmd/git.exe` finds its own helpers.
25
+ const bin = join(dir, platform === 'win32' ? 'cmd' : 'bin')
26
+ const executable = join(bin, platform === 'win32' ? 'git.exe' : 'git')
27
+ if (!existsSync(executable)) return undefined
28
+ return { executable, bin, licence: join(dir, 'COPYING.txt'), source: join(dir, 'SOURCE.txt') }
29
+ }
30
+
31
+ /** `env` with the product's git first on its PATH (Windows names the variable `Path`, in any case; the existing name is kept). */
32
+ export function withGitFirst(
33
+ env: Readonly<Record<string, string | undefined>>,
34
+ git: ProductGit,
35
+ platform: NodeJS.Platform,
36
+ ): Record<string, string | undefined> {
37
+ const key =
38
+ platform === 'win32'
39
+ ? (Object.keys(env).find((name) => name.toUpperCase() === 'PATH') ?? 'Path')
40
+ : 'PATH'
41
+ const separator = platform === 'win32' ? ';' : ':'
42
+ const rest = (env[key] ?? '').split(separator).filter((dir) => dir !== '' && dir !== git.bin)
43
+ return { ...env, [key]: [git.bin, ...rest].join(separator) }
44
+ }
@@ -0,0 +1,63 @@
1
+ /**
2
+ * What the window does when its frame host stops by itself (found on an installed app 2026-11-09: the host had ended days before, nothing restarted it, and
3
+ * the window kept asking a closed port, a thousand errors and a dead page). Two small rules, both pure of Electron so they are tested without a window:
4
+ * when to start the host again, and where its output is kept so the next stop has a cause on disk.
5
+ */
6
+ import { appendFileSync, mkdirSync, readdirSync, rmSync, statSync } from 'node:fs'
7
+ import { join } from 'node:path'
8
+
9
+ export interface RestartPolicy {
10
+ /** Stops inside `windowMs` that are forgiven; one more and the host is not started again by itself. */
11
+ readonly maxInWindow: number
12
+ readonly windowMs: number
13
+ /** The wait before the n-th restart inside the window: short first, then longer. */
14
+ readonly delaysMs: readonly number[]
15
+ }
16
+
17
+ export const RESTART_POLICY: RestartPolicy = Object.freeze({
18
+ maxInWindow: 4,
19
+ windowMs: 120_000,
20
+ delaysMs: [500, 2_000, 5_000, 10_000],
21
+ })
22
+
23
+ export type RestartDecision = { restart: true; delayMs: number } | { restart: false }
24
+
25
+ /** `stops` are the times (ms) the host stopped by itself, oldest or newest first; those older than the window are forgotten. */
26
+ export function nextRestart(
27
+ stops: readonly number[],
28
+ now: number,
29
+ policy: RestartPolicy = RESTART_POLICY,
30
+ ): RestartDecision {
31
+ const recent = stops.filter((at) => now - at < policy.windowMs).length
32
+ if (recent >= policy.maxInWindow) return { restart: false }
33
+ return {
34
+ restart: true,
35
+ delayMs: policy.delaysMs[Math.min(recent, policy.delaysMs.length - 1)] ?? 0,
36
+ }
37
+ }
38
+
39
+ const KEEP_DAYS = 14
40
+
41
+ /** The host's output, one file a day (`host-YYYY-MM-DD.log`) in the app's logs folder; files older than two weeks go. Never throws: a log must not stop the app. */
42
+ export function appendHostLog(dir: string, text: string, now: Date = new Date()): void {
43
+ try {
44
+ mkdirSync(dir, { recursive: true })
45
+ appendFileSync(join(dir, `host-${now.toISOString().slice(0, 10)}.log`), text)
46
+ for (const name of readdirSync(dir)) {
47
+ if (!/^host-\d{4}-\d{2}-\d{2}\.log$/u.test(name)) continue
48
+ const file = join(dir, name)
49
+ if (now.getTime() - statSync(file).mtimeMs > KEEP_DAYS * 86_400_000)
50
+ rmSync(file, { force: true })
51
+ }
52
+ } catch {
53
+ // The disk is full or the folder is gone: the host goes on.
54
+ }
55
+ }
56
+
57
+ /**
58
+ * `node --watch` does not end when the host it runs crashes: it prints this line and waits for a saved file (reproduced on a packed app by killing the host's
59
+ * child: the watcher stayed, the port was closed, nothing answered and nothing restarted). So the window reads the line as the host's death.
60
+ */
61
+ export function hostCrashed(output: string): boolean {
62
+ return /Failed running '/u.test(output)
63
+ }
@@ -8,6 +8,8 @@ import { readFileSync } from 'node:fs'
8
8
  import { createRequire } from 'node:module'
9
9
  import { join } from 'node:path'
10
10
  import { fileURLToPath } from 'node:url'
11
+ import { acrylRoot, appIdOf, personHome } from '../instance/home.ts'
12
+ import { appTitle, brandedElectron, iconFile } from './brand.ts'
11
13
 
12
14
  /** The sysctl that makes the sandbox fail for an Electron without an AppArmor profile; its value, or `undefined` where it does not exist (every system but a recent Ubuntu). */
13
15
  export const RESTRICT_USERNS = '/proc/sys/kernel/apparmor_restrict_unprivileged_userns'
@@ -76,8 +78,22 @@ if (process.argv[1] === fileURLToPath(import.meta.url)) {
76
78
  const blocked =
77
79
  process.env.ACRYLBLENDS_DESKTOP_SANDBOX !== '1' && sandboxBlocked(process.platform)
78
80
  if (blocked) console.warn(SANDBOX_NOTICE)
81
+ // macOS: the Dock and Cmd+Tab name the app and draw its icon only when the bundle says so (brand.ts); a copy that cannot be made leaves the stock Electron, with a note.
82
+ const projectRoot = fileURLToPath(new URL('../..', import.meta.url))
83
+ const branded = brandedElectron({
84
+ binary: electron,
85
+ folder: join(
86
+ acrylRoot(process.platform, process.env, personHome(process.env, process.platform)),
87
+ 'electron',
88
+ ),
89
+ id: appIdOf(projectRoot),
90
+ name: appTitle(projectRoot),
91
+ icon: iconFile(projectRoot),
92
+ })
93
+ if (branded.problem !== undefined)
94
+ console.warn(`desktop: the Dock shows Electron's own name and icon (${branded.problem})`)
79
95
  runElectron(
80
- electron,
96
+ branded.binary,
81
97
  electronArgs(join(import.meta.dirname, 'main.ts'), blocked, process.argv.slice(2)),
82
98
  )
83
99
  }