acrylblends 1.3.2 → 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 +8 -5
- package/dist/index.js +97 -38
- 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 +26 -0
- package/lib/{client-modules-Bkdr83jO.js → client-modules-C5qiVroz.js} +1 -1
- package/lib/cordis/door.d.ts +27 -6
- package/lib/cordis/door.js +41 -10
- 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 +13 -4
- package/plugins/acryl-door/src/host/door.ts +41 -3
- package/plugins/acryl-door/src/host/verbs.ts +3 -3
- 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 +64 -13
- 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/plugins/plugin-share/index.ts +99 -1
- package/plugins/plugin-share/publish.ts +17 -3
- package/plugins/plugin-share/pull-check.ts +2 -0
- package/plugins/plugin-share/pull-list.ts +71 -0
- package/plugins/showcase/cards.ts +162 -0
- package/plugins/showcase/index.ts +116 -0
- package/plugins/showcase/plugin.json +11 -0
- package/shared/skills/acryl-remove-plugin/SKILL.md +1 -1
- package/templates/agent/AGENTS.md +4 -0
- package/templates/agent/README.md +1 -1
- package/templates/agent/blend.yaml +3 -0
- package/templates/agent/gitignore +2 -0
- package/templates/agent/src/app.ts +2 -0
- package/templates/agent/src/routes.ts +23 -5
- package/templates/agent/template.json +1 -0
- package/templates/agent/test/app.test.ts +26 -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
package/plugins/desktop/main.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
|
|
41
|
-
const
|
|
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 =
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
|
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
|
|
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": {},
|