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
@@ -11,11 +11,15 @@
11
11
  import { mkdirSync, readFileSync } from 'node:fs'
12
12
  import { join } from 'node:path'
13
13
  import { fileURLToPath } from 'node:url'
14
- import { app, BrowserWindow, Menu, MenuItem, screen, shell } from 'electron'
14
+ import { app, BrowserWindow, dialog, ipcMain, Menu, MenuItem, screen, shell } from 'electron'
15
15
  import type { App } from '../../src/app.ts'
16
16
  import '../acryl-node-server/src/register.ts'
17
- import { selectInstance } from '../instance/index.ts'
17
+ import { readProduct } from '../instance/bundle.ts'
18
+ import { type AppInstance, instanceEnv, selectInstance, withRunEnv } from '../instance/index.ts'
19
+ import { type AppStorage, openProductStorage } from '../instance/storage.ts'
20
+ import { APP_PAGE_CHANNEL, appPageHandler } from './app-page.ts'
18
21
  import { runningApp } from './attach.ts'
22
+ import { appTitle, iconFile } from './brand.ts'
19
23
  import {
20
24
  type DesktopPort,
21
25
  type MenuItemPort,
@@ -24,7 +28,7 @@ import {
24
28
  } from './door-shell.ts'
25
29
  import {
26
30
  type FrameHost,
27
- frameHome,
31
+ type HostEnd,
28
32
  isFrameApp,
29
33
  navigation,
30
34
  openFrameSettings,
@@ -32,13 +36,55 @@ import {
32
36
  startFrameHost,
33
37
  withoutToken,
34
38
  } from './frame.ts'
39
+ import { productGit, withGitFirst } from './git.ts'
40
+ import { appendHostLog, hostCrashed, nextRestart } from './host-supervisor.ts'
35
41
  import { hasSettings, settingsPlacement } from './menu.ts'
42
+ import { openProductApp, seedSource } from './product.ts'
36
43
  import { createDesktopShutdown, installShutdownRequests } from './shutdown.ts'
37
44
  import { resolveDesktopUserDataOverride } from './user-data.ts'
38
45
  import { cascade, windowOptions, windowSize } from './window.ts'
39
46
 
40
- const projectRoot = fileURLToPath(new URL('../..', import.meta.url))
41
- const chosen = selectInstance({ projectRoot, env: process.env })
47
+ /** The app shipped inside the bundle (a packaged product) or checked out by the person (an app made with `acrylblends init`). */
48
+ const bundledRoot = fileURLToPath(new URL('../..', import.meta.url))
49
+ // A product build keeps its apps in its own root in the OS per-user data folder, one sealed folder each, and runs on the source of the one it opens: the
50
+ // bundle is the read-only Program (plugins/instance/storage.ts). An app made with `acrylblends init` runs on its own folder.
51
+ const product = app.isPackaged ? readProduct(bundledRoot) : undefined
52
+ /** The git the product ships beside the app (Resources/git), first on the frame host's PATH; the person's own git otherwise. */
53
+ const git = product === undefined ? undefined : productGit(process.resourcesPath, process.platform)
54
+ let storage: AppStorage | undefined
55
+ /** The app the window shows. A product can switch to another of its apps (Settings > App: import, start over, open). */
56
+ let chosen: AppInstance
57
+ try {
58
+ storage =
59
+ product === undefined
60
+ ? undefined
61
+ : openProductStorage({
62
+ writtenBy: app.getVersion(),
63
+ ...(git === undefined ? {} : { git: git.executable }),
64
+ reveal: (path) => {
65
+ shell.showItemInFolder(path)
66
+ },
67
+ })
68
+ chosen =
69
+ storage === undefined || product === undefined
70
+ ? selectInstance({ projectRoot: bundledRoot, env: process.env })
71
+ : withRunEnv(openProductApp(storage, product, bundledRoot), process.env)
72
+ } catch (cause) {
73
+ // A root of a newer version, or one this version cannot read: said once, in a dialog, and nothing is started or written.
74
+ const message = cause instanceof Error ? cause.message : String(cause)
75
+ console.error(`desktop: ${message}`)
76
+ dialog.showErrorBox(`${product?.product ?? 'The app'} cannot open`, message)
77
+ app.exit(1)
78
+ throw cause
79
+ }
80
+ const projectRoot = chosen.root
81
+ /** Where the Program is: the bundle of a product (its apps' sources hold only the person's files), else the app folder itself. */
82
+ const programRoot = storage === undefined ? projectRoot : bundledRoot
83
+ if (git !== undefined)
84
+ app.setAboutPanelOptions({
85
+ credits:
86
+ 'Includes Git, free software under the GNU GPL version 2. Its licence and the offer of its source are in Settings > App.',
87
+ })
42
88
 
43
89
  /** Where the window opens: `acrylblends.desktopPath` in package.json (the todo template sets `/todo`), else the root. */
44
90
  function startPath(): string {
@@ -48,11 +94,12 @@ function startPath(): string {
48
94
  return manifest.acrylblends?.desktopPath ?? '/'
49
95
  }
50
96
 
51
- /** Electron's user-data folder: an explicit DSH_DESKTOP_USER_DATA, else a folder inside the app's own home. */
52
- const userData = resolveDesktopUserDataOverride(process.env) ?? join(chosen.home, 'electron')
97
+ /** Electron's user-data folder: an explicit DSH_DESKTOP_USER_DATA, else the product's own (beside its apps), else a folder inside the app's own home. */
98
+ const userData =
99
+ resolveDesktopUserDataOverride(process.env) ?? storage?.electron ?? join(chosen.home, 'electron')
53
100
  mkdirSync(userData, { recursive: true, mode: 0o700 })
54
101
  app.setPath('userData', userData)
55
- app.setName(chosen.name)
102
+ app.setName(appTitle(projectRoot))
56
103
 
57
104
  if (!app.requestSingleInstanceLock()) {
58
105
  // The same app is already open: its window takes focus (see `second-instance` below) and this process ends.
@@ -89,26 +136,120 @@ if (!app.requestSingleInstanceLock()) {
89
136
  })
90
137
 
91
138
  void app.whenReady().then(async () => {
139
+ // The Dock shows the app's own icon even when the bundle could not be branded (brand.ts); a no-op where there is no Dock.
140
+ app.dock?.setIcon(iconFile(projectRoot))
92
141
  // The hook above is registered before the app's modules are loaded, so the app may have .tsx pages: they are loaded here, not as a static import.
93
142
  // A dev server is already running from this folder (`npm run dev`): the door allows one process per folder, so the window opens on that server instead of starting a second one,
94
143
  // and closing the window leaves it running.
95
- const frame = isFrameApp(projectRoot)
144
+ const frame = isFrameApp(programRoot)
96
145
  const attached = frame ? undefined : await runningApp(projectRoot)
97
146
  let url: string
147
+ /** This window's own frame host on the chosen app; its address once it serves. */
148
+ const startHost = (): Promise<string> => {
149
+ // The host chooses the same home and port as this window: decided here once, never again from the folder it runs in. The product's git comes first
150
+ // on its PATH, so everything the host starts finds it by name.
151
+ const env = { ...process.env, ...instanceEnv(chosen) }
152
+ const logs = join(chosen.home, 'logs')
153
+ const started = startFrameHost(
154
+ chosen.root,
155
+ git === undefined ? env : withGitFirst(env, git, process.platform),
156
+ (text) => {
157
+ // Kept on disk too: a packaged app has no terminal, and a host that stops must leave its reason.
158
+ appendHostLog(logs, text)
159
+ process.stdout.write(text)
160
+ // The watcher that runs the host stays alive when the host crashes (it waits for a saved file): that line is the death.
161
+ recent = (recent + text).slice(-3000)
162
+ if (serving && hostCrashed(text))
163
+ void started.stop().then(() => recover(started, crashedEnd(recent)))
164
+ },
165
+ process.platform,
166
+ // A packaged product carries no npm: its host runs on this app's own Electron runtime.
167
+ app.isPackaged ? { runtime: process.execPath, program: programRoot } : {},
168
+ )
169
+ host = started
170
+ let serving = false
171
+ let recent = ''
172
+ // Watched once it serves: a host that ends before that fails the start itself (the caller quits with the reason).
173
+ return started.ready.then((address) => {
174
+ serving = true
175
+ void started.ended.then((end) => recover(started, end))
176
+ return address
177
+ })
178
+ }
179
+ const crashedEnd = (text: string): HostEnd => ({
180
+ code: null,
181
+ signal: null,
182
+ tail: text,
183
+ requested: false,
184
+ })
185
+ /** When the host stopped by itself (it did not die with the window or a switch of app): the times, so a host that keeps stopping is given up on. */
186
+ const stops: number[] = []
187
+ /** The host this window started ended without being asked to: start it again and show it in every window, or say why not. */
188
+ const recover = async (first: FrameHost, end: HostEnd): Promise<void> => {
189
+ if (end.requested || host !== first) return
190
+ /** The host this loop is answering for: the one that stopped, then each attempt that did not serve. A switch of app replaces `host`, and then it is not ours any more. */
191
+ let ours = first
192
+ const logs = join(chosen.home, 'logs')
193
+ let last = end
194
+ for (;;) {
195
+ const exit = last.signal ?? `code ${String(last.code)}`
196
+ appendHostLog(logs, `\n[desktop] the frame host ended by itself (${exit})\n`)
197
+ console.error(`desktop: the frame host ended by itself (${exit}); ${last.tail.slice(-400)}`)
198
+ const decision = nextRestart(stops, Date.now())
199
+ if (!decision.restart) {
200
+ const answer = await dialog.showMessageBox({
201
+ type: 'error',
202
+ message: `${chosen.name} keeps stopping`,
203
+ detail: `Its engine stopped by itself ${String(stops.length)} times in two minutes (${exit}).\n\nLast output:\n${last.tail.slice(-600)}\n\nThe whole output is in ${logs}`,
204
+ buttons: ['Try again', 'Quit'],
205
+ defaultId: 0,
206
+ cancelId: 1,
207
+ })
208
+ if (answer.response !== 0) {
209
+ await shutdown.request(1)
210
+ return
211
+ }
212
+ stops.length = 0
213
+ continue
214
+ }
215
+ stops.push(Date.now())
216
+ await new Promise((done) => setTimeout(done, decision.delayMs))
217
+ if (host !== ours) return
218
+ try {
219
+ url = await startHost()
220
+ } catch (cause) {
221
+ if (host !== undefined) ours = host
222
+ // It ended before it served: that is another stop, with the reason it gave.
223
+ last = {
224
+ code: null,
225
+ signal: null,
226
+ tail: cause instanceof Error ? cause.message : String(cause),
227
+ requested: false,
228
+ }
229
+ continue
230
+ }
231
+ origin = new URL(url).origin
232
+ for (const shown of live())
233
+ shown.loadURL(url).catch((cause: unknown) => {
234
+ console.warn(
235
+ `desktop: the window could not load the restarted app: ${cause instanceof Error ? cause.message : String(cause)}`,
236
+ )
237
+ })
238
+ console.log(`desktop: the frame host was started again at ${origin}`)
239
+ return
240
+ }
241
+ }
98
242
  if (frame) {
99
243
  // The frame that already runs for this folder (`npm run dev`, or another window's host), else a host of this window's own.
100
- const runningAddress = await runningFrame(frameHome(projectRoot, process.env))
244
+ const runningAddress = await runningFrame(chosen.home)
101
245
  if (runningAddress !== undefined) {
102
246
  console.log(
103
247
  `desktop: attached to the frame already running from this folder at ${new URL(runningAddress).origin} (it keeps running when the window closes)`,
104
248
  )
105
249
  url = runningAddress
106
250
  } else {
107
- host = startFrameHost(projectRoot, process.env, (text) => {
108
- process.stdout.write(text)
109
- })
110
251
  try {
111
- url = await host.ready
252
+ url = await startHost()
112
253
  } catch (cause) {
113
254
  console.error(`desktop: ${cause instanceof Error ? cause.message : String(cause)}`)
114
255
  await shutdown.request(1)
@@ -128,13 +269,16 @@ if (!app.requestSingleInstanceLock()) {
128
269
  running = await createApp(chosen)
129
270
  url = running.url
130
271
  }
131
- const origin = new URL(url).origin
272
+ // Both change when a product switches to another of its apps (each app has its own port); the handlers below read them when they run.
273
+ let origin = new URL(url).origin
132
274
  /** Where a window opens: the frame's address (with its token) for a frame app, else a path of the app (`acrylblends.desktopPath`, else the root). */
133
275
  const addressOf = (path: string | undefined): string =>
134
276
  frame && path === undefined ? url : new URL(path ?? startPath(), url).href
135
277
  /** One window on an address of the app. Navigation follows `navigation` in frame.ts. The user agent names the window to the door (`AcrylWindow/<id>`), so an agent can drive it with `--window <id>`. */
136
278
  // Where each window was opened: `window list` shows it until the page reports its own address (Electron gives none while the first load has not started).
137
279
  const openedOn = new Map<number, string>()
280
+ /** The App pages are a product's, on a frame host this window started: a host it attached to belongs to another process. */
281
+ const appPage = storage !== undefined && frame && host !== undefined
138
282
  const createWindow = (path: string | undefined): BrowserWindow => {
139
283
  // A window opened while another is open sits a step down and to the right of it, not exactly on top.
140
284
  const beside =
@@ -156,12 +300,25 @@ if (!app.requestSingleInstanceLock()) {
156
300
  .map(([x = 0, y = 0]) => ({ x, y }))
157
301
  return cascade(bounds, work, size, taken)
158
302
  })()
159
- const created = new BrowserWindow(windowOptions({ title: chosen.name, ...size, ...where }))
303
+ const created = new BrowserWindow(
304
+ windowOptions({
305
+ title: chosen.name,
306
+ ...size,
307
+ ...where,
308
+ // A product's window page gets the bridge of its App pages (Settings > App, Name your app).
309
+ ...(appPage ? { preload: fileURLToPath(new URL('./preload.cjs', import.meta.url)) } : {}),
310
+ }),
311
+ )
160
312
  const address = addressOf(path)
161
313
  openedOn.set(created.id, new URL(address).pathname)
162
314
  created.webContents.setUserAgent(
163
315
  `${created.webContents.getUserAgent()} AcrylWindow/${String(created.id)}`,
164
316
  )
317
+ // The window is named for the app it shows (its name, and a new one after a rename), not for the page's own `<title>`: the frame's page says "ACRYL".
318
+ created.on('page-title-updated', (event) => {
319
+ event.preventDefault()
320
+ created.setTitle(chosen.name)
321
+ })
165
322
  created.once('ready-to-show', () => created.show())
166
323
  // A new window of the page never opens inside the app: an http(s) or mailto address goes to the system browser, anything else nowhere.
167
324
  created.webContents.setWindowOpenHandler(({ url: target }) => {
@@ -274,6 +431,100 @@ if (!app.requestSingleInstanceLock()) {
274
431
  target?.insert(placement.at + 1, new MenuItem({ type: 'separator' }))
275
432
  Menu.setApplicationMenu(menu)
276
433
  }
277
- stopDoorShell = startDoorShell(projectRoot, { desktop, log: console.log })
434
+ stopDoorShell = startDoorShell(chosen.root, { desktop, log: console.log })
435
+
436
+ if (appPage && storage !== undefined && product !== undefined) {
437
+ const productStorage = storage
438
+ /** Stop the host, run `next`, start the host on the app it returns and show it in every window; the app that ran comes back when `next` fails. */
439
+ const switchTo = async (next: () => AppInstance): Promise<void> => {
440
+ const previous = chosen
441
+ await host?.stop()
442
+ host = undefined
443
+ let failure: unknown
444
+ try {
445
+ chosen = withRunEnv(next(), process.env)
446
+ } catch (cause) {
447
+ failure = cause
448
+ chosen = previous
449
+ }
450
+ try {
451
+ url = await startHost()
452
+ } catch (cause) {
453
+ console.error(`desktop: ${cause instanceof Error ? cause.message : String(cause)}`)
454
+ if (chosen === previous) {
455
+ await shutdown.request(1)
456
+ return
457
+ }
458
+ // The new app does not start: the one that ran comes back, if its folders are still there.
459
+ failure = cause
460
+ chosen = previous
461
+ url = await startHost()
462
+ }
463
+ origin = new URL(url).origin
464
+ app.setName(chosen.name)
465
+ stopDoorShell?.()
466
+ stopDoorShell = startDoorShell(chosen.root, { desktop, log: console.log })
467
+ for (const shown of live())
468
+ shown.loadURL(url).catch((cause: unknown) => {
469
+ console.warn(
470
+ `desktop: the window could not load the app: ${cause instanceof Error ? cause.message : String(cause)}`,
471
+ )
472
+ })
473
+ console.log(`desktop: now showing the app "${chosen.name}" (${chosen.id}) at ${origin}`)
474
+ if (failure !== undefined) throw failure
475
+ }
476
+ const dialogParent = (): BrowserWindow | undefined =>
477
+ BrowserWindow.getFocusedWindow() ?? live()[0]
478
+ const archiveFilter = [{ name: 'ACRYL Blends app', extensions: ['acrylblend'] }]
479
+ const handle = appPageHandler({
480
+ storage: productStorage,
481
+ current: () => chosen.id,
482
+ seed: (source) => {
483
+ seedSource(bundledRoot, source, product.program)
484
+ },
485
+ switchTo,
486
+ chooseExportFile: async (suggested) => {
487
+ const parent = dialogParent()
488
+ const options = { defaultPath: suggested, filters: archiveFilter }
489
+ const answer = await (parent === undefined
490
+ ? dialog.showSaveDialog(options)
491
+ : dialog.showSaveDialog(parent, options))
492
+ return answer.canceled || answer.filePath === undefined || answer.filePath === ''
493
+ ? undefined
494
+ : answer.filePath
495
+ },
496
+ chooseImportFile: async () => {
497
+ const parent = dialogParent()
498
+ const options = { filters: archiveFilter, properties: ['openFile' as const] }
499
+ const answer = await (parent === undefined
500
+ ? dialog.showOpenDialog(options)
501
+ : dialog.showOpenDialog(parent, options))
502
+ return answer.canceled ? undefined : answer.filePaths[0]
503
+ },
504
+ openPath: async (path) => {
505
+ const problem = await shell.openPath(path)
506
+ if (problem !== '') throw new Error(problem)
507
+ },
508
+ renamed: (renamed) => {
509
+ chosen = Object.freeze({ ...chosen, name: renamed.name })
510
+ app.setName(renamed.name)
511
+ for (const open of BrowserWindow.getAllWindows())
512
+ if (!open.isDestroyed()) open.setTitle(renamed.name)
513
+ },
514
+ platform: process.platform,
515
+ ...(git === undefined ? {} : { git }),
516
+ })
517
+ ipcMain.handle(APP_PAGE_CHANNEL, (event, op: unknown, arg: unknown) => {
518
+ // Only the window's own page on the frame's origin: never the app's iframe, never a page the window was sent to.
519
+ const sender = event.senderFrame
520
+ if (
521
+ sender === null ||
522
+ sender !== event.sender.mainFrame ||
523
+ new URL(sender.url).origin !== origin
524
+ )
525
+ return { ok: false, message: 'only the frame page can ask this' }
526
+ return handle(op, arg)
527
+ })
528
+ }
278
529
  })
279
530
  }
@@ -0,0 +1,8 @@
1
+ // The one thing a desktop product's window page gets from the shell: `window.acrylBlendsApp.call(op, arg)`, answered by `app-page.ts` in the main process
2
+ // (the frame's App pages: name, size, show, export, import, start over). A sandboxed preload is a CommonJS script and can only require electron; the
3
+ // channel name is `APP_PAGE_CHANNEL` in app-page.ts. It runs in the window's own page, never in the app's iframes.
4
+ const { contextBridge, ipcRenderer } = require('electron')
5
+
6
+ contextBridge.exposeInMainWorld('acrylBlendsApp', {
7
+ call: (op, arg) => ipcRenderer.invoke('acrylblends:app', op, arg),
8
+ })
@@ -0,0 +1,117 @@
1
+ /**
2
+ * A product build (M10 T002): a desktop package that ships an app as its own starting point. `product.json` at the bundle root marks it. The bundle is
3
+ * the Program, read-only (a mounted DMG can never be written to); the person's apps live in the product's root in the OS per-user data folder, one
4
+ * sealed folder each (plugins/instance/storage.ts, specs/049-framework-architecture/app-storage.md in ACRYL). A new app's source gets the person's
5
+ * part of the starting app only; the Program (product.json's `program`: the framework's plugins, the generated host, the packages) stays in the bundle
6
+ * and is served from there (plugins/instance/bundle.ts), so an update replaces it for every app.
7
+ */
8
+
9
+ import { appendFileSync, cpSync, existsSync, mkdirSync, readFileSync } from 'node:fs'
10
+ import { join, relative, sep } from 'node:path'
11
+ import { inProgram, type Product } from '../instance/bundle.ts'
12
+ import type { AppInstance } from '../instance/index.ts'
13
+ import type { AppStorage } from '../instance/storage.ts'
14
+
15
+ /**
16
+ * What a new app's source does not carry from the bundle besides the Program: build output, the bundle's own app data, and the carrier names the packager
17
+ * keeps files under. The scaffold's repository travels as `template-git` and becomes the source's `.git`; the other carriers become their files (see `CARRIED`).
18
+ */
19
+ const SKIPPED = new Set([
20
+ 'dist',
21
+ '.acrylblends',
22
+ 'product.json',
23
+ 'template-git',
24
+ 'gitignore-product',
25
+ 'gitattributes-product',
26
+ 'package-product.json',
27
+ ])
28
+
29
+ /**
30
+ * The app's own files that the packager drops or rewrites inside a bundle, and the plain names they travel under. electron-builder excludes `.gitignore` and `.gitattributes`
31
+ * by name, and it ships a filtered manifest (the `scripts` block goes), so neither can reach the source as itself; `seedSource` writes them back.
32
+ */
33
+ const CARRIED: readonly { readonly carrier: string; readonly file: string }[] = [
34
+ { carrier: 'gitignore-product', file: '.gitignore' },
35
+ { carrier: 'gitattributes-product', file: '.gitattributes' },
36
+ { carrier: 'package-product.json', file: 'package.json' },
37
+ ]
38
+
39
+ /** What the packager adds to the scaffold's repository: kept out of the source's `git status` through git's local exclude, never its tracked `.gitignore`. */
40
+ const PRODUCT_NAMES = [
41
+ 'product.json',
42
+ 'gitignore-product',
43
+ 'gitattributes-product',
44
+ 'package-product.json',
45
+ 'template-git/',
46
+ ] as const
47
+
48
+ /** Keep the product's own names out of the source's `git status`, the way git's local exclude is meant for. */
49
+ function ignoreProductFiles(source: string): void {
50
+ const git = join(source, '.git')
51
+ if (!existsSync(git)) return
52
+ const info = join(git, 'info')
53
+ const file = join(info, 'exclude')
54
+ const before = existsSync(file) ? readFileSync(file, 'utf8') : ''
55
+ const known = new Set(before.split('\n').map((line) => line.trim()))
56
+ const missing = PRODUCT_NAMES.filter((name) => !known.has(name))
57
+ if (missing.length === 0) return
58
+ mkdirSync(info, { recursive: true })
59
+ const separator = before === '' || before.endsWith('\n') ? '' : '\n'
60
+ appendFileSync(file, `${separator}${missing.join('\n')}\n`)
61
+ }
62
+
63
+ /**
64
+ * Fill a new app's empty source folder with the person's part of the bundle's starting app: its files, its repository and the files the packager could
65
+ * only carry under another name, without the Program. Run once, when the app is made (`AppStorage.create`); an app's source is the person's from then on
66
+ * and is never written here again.
67
+ * @param bundle - the bundled app (read-only).
68
+ * @param source - the new app's source folder.
69
+ * @param program - the Program's paths (product.json), which stay in the bundle.
70
+ */
71
+ export function seedSource(bundle: string, source: string, program: readonly string[]): void {
72
+ cpSync(bundle, source, {
73
+ recursive: true,
74
+ dereference: true,
75
+ errorOnExist: false,
76
+ force: true,
77
+ // The bundled root arrives as a URL's directory and can end in a separator: paths are taken relative to it, never by index.
78
+ filter: (path) => {
79
+ const parts = relative(bundle, path).split(sep)
80
+ if (parts[0] === '') return true
81
+ if (SKIPPED.has(parts[0] ?? '')) return false
82
+ // A folder is compared with its trailing separator, so `plugins/acryl-router` is the Program's `plugins/acryl-router/`.
83
+ const tree = parts.join('/')
84
+ return !inProgram(program, tree) && !inProgram(program, `${tree}/`)
85
+ },
86
+ })
87
+ const templateGit = join(bundle, 'template-git')
88
+ if (existsSync(templateGit))
89
+ cpSync(templateGit, join(source, '.git'), { recursive: true, force: true })
90
+ for (const { carrier, file } of CARRIED) {
91
+ const from = join(bundle, carrier)
92
+ if (existsSync(from)) cpSync(from, join(source, file), { force: true })
93
+ }
94
+ ignoreProductFiles(source)
95
+ }
96
+
97
+ /**
98
+ * The app a product opens: the one opened last, or on the first start a new one seeded from the bundle and named after the product's starting app.
99
+ * Choosing among several apps and naming the first one are the frame's pages (app-storage.md slice 4).
100
+ */
101
+ export function openProductApp(
102
+ storage: AppStorage,
103
+ product: Product,
104
+ bundle: string,
105
+ log: (line: string) => void = console.log,
106
+ ): AppInstance {
107
+ let app = storage.last()
108
+ if (app === undefined) {
109
+ log(`product: making the app "${product.appName}" from the bundled app (first start, one time)`)
110
+ // A product that is one Blend carries it in a folder of its bundle; the Blank product's starting app is the bundle's own tree.
111
+ const from = product.seed === undefined ? bundle : join(bundle, product.seed)
112
+ app = storage.create(product.appName, (source) => {
113
+ seedSource(from, source, product.program)
114
+ })
115
+ }
116
+ return storage.open(app.id)
117
+ }
@@ -7,6 +7,8 @@ export interface WindowSpec {
7
7
  /** Where the window's top left corner goes; Electron centers the window when these are left out. */
8
8
  readonly x?: number
9
9
  readonly y?: number
10
+ /** A product's preload (`preload.cjs`): the narrow bridge its App pages use. Sandboxed, isolated, main frame only. */
11
+ readonly preload?: string
10
12
  }
11
13
 
12
14
  export interface Rect {
@@ -84,6 +86,7 @@ export interface WindowOptions {
84
86
  readonly nodeIntegration: boolean
85
87
  readonly sandbox: boolean
86
88
  readonly webSecurity: boolean
89
+ readonly preload?: string
87
90
  }
88
91
  }
89
92
 
@@ -101,6 +104,7 @@ export function windowOptions(spec: WindowSpec): WindowOptions {
101
104
  nodeIntegration: false,
102
105
  sandbox: true,
103
106
  webSecurity: true,
107
+ ...(spec.preload === undefined ? {} : { preload: spec.preload }),
104
108
  },
105
109
  }
106
110
  }
@@ -13,6 +13,7 @@ import { fileURLToPath } from 'node:url'
13
13
  import { serve } from 'acrylblends/node-server'
14
14
  import { app, BrowserWindow, shell } from 'electron'
15
15
  import 'acrylblends/node-server/register'
16
+ import { appHomes } from '../instance/index.ts'
16
17
  import { createDesktopShutdown, installShutdownRequests } from './shutdown.ts'
17
18
  import { resolveDesktopUserDataOverride } from './user-data.ts'
18
19
  import { windowOptions } from './window.ts'
@@ -23,9 +24,9 @@ const manifest = JSON.parse(readFileSync(join(projectRoot, 'package.json'), 'utf
23
24
  acrylblends?: { desktopPath?: string }
24
25
  }
25
26
 
26
- /** Electron's user-data folder: an explicit DSH_DESKTOP_USER_DATA, else a folder inside the app's own folder. */
27
+ /** Electron's user-data folder: an explicit DSH_DESKTOP_USER_DATA, else the app's cache folder under ~/.acryl (what Chromium keeps can be rebuilt). */
27
28
  const userData =
28
- resolveDesktopUserDataOverride(process.env) ?? join(projectRoot, '.acrylblends', 'electron')
29
+ resolveDesktopUserDataOverride(process.env) ?? join(appHomes(projectRoot).cache, 'electron')
29
30
  mkdirSync(userData, { recursive: true, mode: 0o700 })
30
31
  app.setPath('userData', userData)
31
32
  app.setName(manifest.name ?? 'acryl-app')
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "desktop-web",
3
3
  "description": "Run a plain Acryl app (`export default app`) as a desktop app: an Electron window on the app's own server. `npm run desktop`; the app's code never imports electron. `acrylblends add desktop` picks this one for an app without the Blend plugins.",
4
- "requires": [],
4
+ "requires": ["instance"],
5
5
  "inject": [],
6
6
  "provides": [],
7
7
  "dependencies": {},