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
|
@@ -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
|
+
}
|
package/plugins/desktop/frame.ts
CHANGED
|
@@ -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,
|
|
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
|
|
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
|
-
?
|
|
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
|
|
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: {
|
|
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
|
-
|
|
96
|
+
branded.binary,
|
|
81
97
|
electronArgs(join(import.meta.dirname, 'main.ts'), blocked, process.argv.slice(2)),
|
|
82
98
|
)
|
|
83
99
|
}
|