@namzu/sandbox 24.0.0 → 26.0.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/CHANGELOG.md +284 -0
- package/dist/backends/http-worker-client.d.ts +3 -1
- package/dist/backends/http-worker-client.d.ts.map +1 -1
- package/dist/backends/http-worker-client.js +15 -4
- package/dist/backends/http-worker-client.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/local-virtual-computer/client.d.ts +21 -0
- package/dist/local-virtual-computer/client.d.ts.map +1 -0
- package/dist/local-virtual-computer/client.js +503 -0
- package/dist/local-virtual-computer/client.js.map +1 -0
- package/dist/local-virtual-computer/detached-process.d.ts +15 -0
- package/dist/local-virtual-computer/detached-process.d.ts.map +1 -0
- package/dist/local-virtual-computer/detached-process.js +111 -0
- package/dist/local-virtual-computer/detached-process.js.map +1 -0
- package/dist/local-virtual-computer/detached-worker.d.ts +11 -0
- package/dist/local-virtual-computer/detached-worker.d.ts.map +1 -0
- package/dist/local-virtual-computer/detached-worker.js +97 -0
- package/dist/local-virtual-computer/detached-worker.js.map +1 -0
- package/dist/local-virtual-computer/dialect.d.ts +26 -0
- package/dist/local-virtual-computer/dialect.d.ts.map +1 -0
- package/dist/local-virtual-computer/dialect.js +61 -0
- package/dist/local-virtual-computer/dialect.js.map +1 -0
- package/dist/local-virtual-computer/engine.d.ts +63 -0
- package/dist/local-virtual-computer/engine.d.ts.map +1 -0
- package/dist/local-virtual-computer/engine.js +125 -0
- package/dist/local-virtual-computer/engine.js.map +1 -0
- package/dist/local-virtual-computer/guest-stdio.d.ts +15 -0
- package/dist/local-virtual-computer/guest-stdio.d.ts.map +1 -0
- package/dist/local-virtual-computer/guest-stdio.js +330 -0
- package/dist/local-virtual-computer/guest-stdio.js.map +1 -0
- package/dist/local-virtual-computer/index.d.ts +36 -0
- package/dist/local-virtual-computer/index.d.ts.map +1 -0
- package/dist/local-virtual-computer/index.js +341 -0
- package/dist/local-virtual-computer/index.js.map +1 -0
- package/dist/local-virtual-computer/podman-machine.d.ts +17 -0
- package/dist/local-virtual-computer/podman-machine.d.ts.map +1 -0
- package/dist/local-virtual-computer/podman-machine.js +181 -0
- package/dist/local-virtual-computer/podman-machine.js.map +1 -0
- package/dist/local-virtual-computer/setup/contract.d.ts +360 -0
- package/dist/local-virtual-computer/setup/contract.d.ts.map +1 -0
- package/dist/local-virtual-computer/setup/contract.js +3 -0
- package/dist/local-virtual-computer/setup/contract.js.map +1 -0
- package/dist/local-virtual-computer/setup/elevated.d.ts +78 -0
- package/dist/local-virtual-computer/setup/elevated.d.ts.map +1 -0
- package/dist/local-virtual-computer/setup/elevated.js +548 -0
- package/dist/local-virtual-computer/setup/elevated.js.map +1 -0
- package/dist/local-virtual-computer/setup/host.d.ts +12 -0
- package/dist/local-virtual-computer/setup/host.d.ts.map +1 -0
- package/dist/local-virtual-computer/setup/host.js +93 -0
- package/dist/local-virtual-computer/setup/host.js.map +1 -0
- package/dist/local-virtual-computer/setup/idle.d.ts +18 -0
- package/dist/local-virtual-computer/setup/idle.d.ts.map +1 -0
- package/dist/local-virtual-computer/setup/idle.js +38 -0
- package/dist/local-virtual-computer/setup/idle.js.map +1 -0
- package/dist/local-virtual-computer/setup/image.d.ts +7 -0
- package/dist/local-virtual-computer/setup/image.d.ts.map +1 -0
- package/dist/local-virtual-computer/setup/image.js +124 -0
- package/dist/local-virtual-computer/setup/image.js.map +1 -0
- package/dist/local-virtual-computer/setup/index.d.ts +17 -0
- package/dist/local-virtual-computer/setup/index.d.ts.map +1 -0
- package/dist/local-virtual-computer/setup/index.js +17 -0
- package/dist/local-virtual-computer/setup/index.js.map +1 -0
- package/dist/local-virtual-computer/setup/limits.d.ts +30 -0
- package/dist/local-virtual-computer/setup/limits.d.ts.map +1 -0
- package/dist/local-virtual-computer/setup/limits.js +58 -0
- package/dist/local-virtual-computer/setup/limits.js.map +1 -0
- package/dist/local-virtual-computer/setup/plan.d.ts +8 -0
- package/dist/local-virtual-computer/setup/plan.d.ts.map +1 -0
- package/dist/local-virtual-computer/setup/plan.js +131 -0
- package/dist/local-virtual-computer/setup/plan.js.map +1 -0
- package/dist/local-virtual-computer/setup/readiness.d.ts +11 -0
- package/dist/local-virtual-computer/setup/readiness.d.ts.map +1 -0
- package/dist/local-virtual-computer/setup/readiness.js +320 -0
- package/dist/local-virtual-computer/setup/readiness.js.map +1 -0
- package/dist/local-virtual-computer/setup/registry.d.ts +18 -0
- package/dist/local-virtual-computer/setup/registry.d.ts.map +1 -0
- package/dist/local-virtual-computer/setup/registry.js +50 -0
- package/dist/local-virtual-computer/setup/registry.js.map +1 -0
- package/dist/local-virtual-computer/setup/restart.d.ts +8 -0
- package/dist/local-virtual-computer/setup/restart.d.ts.map +1 -0
- package/dist/local-virtual-computer/setup/restart.js +60 -0
- package/dist/local-virtual-computer/setup/restart.js.map +1 -0
- package/dist/local-virtual-computer/setup/resume.d.ts +14 -0
- package/dist/local-virtual-computer/setup/resume.d.ts.map +1 -0
- package/dist/local-virtual-computer/setup/resume.js +58 -0
- package/dist/local-virtual-computer/setup/resume.js.map +1 -0
- package/dist/local-virtual-computer/setup/run.d.ts +8 -0
- package/dist/local-virtual-computer/setup/run.d.ts.map +1 -0
- package/dist/local-virtual-computer/setup/run.js +200 -0
- package/dist/local-virtual-computer/setup/run.js.map +1 -0
- package/dist/local-virtual-computer/setup/windows-folder.d.ts +11 -0
- package/dist/local-virtual-computer/setup/windows-folder.d.ts.map +1 -0
- package/dist/local-virtual-computer/setup/windows-folder.js +14 -0
- package/dist/local-virtual-computer/setup/windows-folder.js.map +1 -0
- package/dist/local-virtual-computer/setup/wsl-package.d.ts +19 -0
- package/dist/local-virtual-computer/setup/wsl-package.d.ts.map +1 -0
- package/dist/local-virtual-computer/setup/wsl-package.js +242 -0
- package/dist/local-virtual-computer/setup/wsl-package.js.map +1 -0
- package/dist/local-virtual-computer/setup/wslc-shared.d.ts +27 -0
- package/dist/local-virtual-computer/setup/wslc-shared.d.ts.map +1 -0
- package/dist/local-virtual-computer/setup/wslc-shared.js +137 -0
- package/dist/local-virtual-computer/setup/wslc-shared.js.map +1 -0
- package/dist/local-virtual-computer/ssh-forward.d.ts +19 -0
- package/dist/local-virtual-computer/ssh-forward.d.ts.map +1 -0
- package/dist/local-virtual-computer/ssh-forward.js +179 -0
- package/dist/local-virtual-computer/ssh-forward.js.map +1 -0
- package/dist/local-virtual-computer/wslc-engine.d.ts +27 -0
- package/dist/local-virtual-computer/wslc-engine.d.ts.map +1 -0
- package/dist/local-virtual-computer/wslc-engine.js +98 -0
- package/dist/local-virtual-computer/wslc-engine.js.map +1 -0
- package/local-computer/Dockerfile +99 -0
- package/local-computer/app-catalog.cjs +240 -0
- package/local-computer/app-native-host.cjs +144 -0
- package/local-computer/browser-home.cjs +249 -0
- package/local-computer/build-image.mjs +310 -0
- package/local-computer/desktop-worker.cjs +477 -0
- package/local-computer/entrypoint.sh +161 -0
- package/local-computer/home.html +28 -0
- package/local-computer/new-tab/home.css +255 -0
- package/local-computer/new-tab/home.js +65 -0
- package/local-computer/new-tab/manifest.json +12 -0
- package/local-computer/new-tab/wordmark.svg +33 -0
- package/local-computer/openbox-rc.xml +41 -0
- package/local-computer/prepare-home.cjs +121 -0
- package/local-computer/tint2rc +44 -0
- package/package.json +13 -4
- package/src/backends/http-worker-client.ts +31 -3
- package/src/index.ts +13 -0
- package/src/local-virtual-computer/client.ts +573 -0
- package/src/local-virtual-computer/detached-process.ts +133 -0
- package/src/local-virtual-computer/detached-worker.ts +124 -0
- package/src/local-virtual-computer/dialect.ts +83 -0
- package/src/local-virtual-computer/engine.ts +197 -0
- package/src/local-virtual-computer/guest-stdio.ts +332 -0
- package/src/local-virtual-computer/index.ts +447 -0
- package/src/local-virtual-computer/podman-machine.ts +264 -0
- package/src/local-virtual-computer/setup/contract.ts +395 -0
- package/src/local-virtual-computer/setup/elevated.ts +655 -0
- package/src/local-virtual-computer/setup/host.ts +100 -0
- package/src/local-virtual-computer/setup/idle.ts +39 -0
- package/src/local-virtual-computer/setup/image.ts +159 -0
- package/src/local-virtual-computer/setup/index.ts +16 -0
- package/src/local-virtual-computer/setup/limits.ts +68 -0
- package/src/local-virtual-computer/setup/plan.ts +144 -0
- package/src/local-virtual-computer/setup/readiness.ts +386 -0
- package/src/local-virtual-computer/setup/registry.ts +50 -0
- package/src/local-virtual-computer/setup/restart.ts +64 -0
- package/src/local-virtual-computer/setup/resume.ts +65 -0
- package/src/local-virtual-computer/setup/run.ts +225 -0
- package/src/local-virtual-computer/setup/windows-folder.ts +13 -0
- package/src/local-virtual-computer/setup/wsl-package.ts +242 -0
- package/src/local-virtual-computer/setup/wslc-shared.ts +147 -0
- package/src/local-virtual-computer/ssh-forward.ts +195 -0
- package/src/local-virtual-computer/wslc-engine.ts +124 -0
- package/worker/server.js +1499 -0
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
const { constants, accessSync, readFileSync } = require('node:fs')
|
|
2
|
+
const { join } = require('node:path')
|
|
3
|
+
|
|
4
|
+
// The image is built in stages and each stage advertises only what it installed.
|
|
5
|
+
// `base` is the light Pal computer; `creative` is built FROM it and adds the heavy
|
|
6
|
+
// applications, so its catalogue is every application of both stages.
|
|
7
|
+
const STAGES = Object.freeze(['base', 'creative'])
|
|
8
|
+
|
|
9
|
+
// Application ids never become commands, paths, argv or environment variables.
|
|
10
|
+
// Each stage installs its own entries and fails its build if an executable
|
|
11
|
+
// or icon is absent. No id is advertised as a simulated application.
|
|
12
|
+
const APPLICATIONS = Object.freeze(
|
|
13
|
+
[
|
|
14
|
+
{
|
|
15
|
+
id: 'blender',
|
|
16
|
+
stage: 'creative',
|
|
17
|
+
name: 'Blender',
|
|
18
|
+
binary: '/usr/local/bin/blender',
|
|
19
|
+
args: [],
|
|
20
|
+
desktop: ['blender.desktop'],
|
|
21
|
+
software: true,
|
|
22
|
+
required: true,
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
id: 'freecad',
|
|
26
|
+
stage: 'creative',
|
|
27
|
+
name: 'FreeCAD',
|
|
28
|
+
binary: '/usr/bin/freecad',
|
|
29
|
+
args: [],
|
|
30
|
+
desktop: ['org.freecadweb.FreeCAD.desktop', 'freecad.desktop'],
|
|
31
|
+
software: true,
|
|
32
|
+
required: true,
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
id: 'gimp',
|
|
36
|
+
stage: 'creative',
|
|
37
|
+
name: 'GIMP',
|
|
38
|
+
binary: '/usr/bin/gimp',
|
|
39
|
+
args: [],
|
|
40
|
+
desktop: ['gimp.desktop'],
|
|
41
|
+
required: true,
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
id: 'inkscape',
|
|
45
|
+
stage: 'creative',
|
|
46
|
+
name: 'Inkscape',
|
|
47
|
+
binary: '/usr/bin/inkscape',
|
|
48
|
+
args: [],
|
|
49
|
+
desktop: ['org.inkscape.Inkscape.desktop', 'inkscape.desktop'],
|
|
50
|
+
required: true,
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
id: 'draw',
|
|
54
|
+
stage: 'creative',
|
|
55
|
+
name: 'LibreOffice Draw',
|
|
56
|
+
binary: '/usr/bin/libreoffice',
|
|
57
|
+
args: ['--draw'],
|
|
58
|
+
desktop: ['libreoffice-draw.desktop'],
|
|
59
|
+
required: true,
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
id: 'editor',
|
|
63
|
+
stage: 'base',
|
|
64
|
+
name: 'Text Editor',
|
|
65
|
+
binary: '/usr/bin/mousepad',
|
|
66
|
+
args: [],
|
|
67
|
+
desktop: ['org.xfce.mousepad.desktop', 'mousepad.desktop'],
|
|
68
|
+
required: true,
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
id: 'files',
|
|
72
|
+
stage: 'base',
|
|
73
|
+
name: 'Files',
|
|
74
|
+
binary: '/usr/bin/pcmanfm',
|
|
75
|
+
args: ['/home/namzu/workspace'],
|
|
76
|
+
desktop: ['pcmanfm.desktop'],
|
|
77
|
+
required: true,
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
id: 'terminal',
|
|
81
|
+
stage: 'base',
|
|
82
|
+
name: 'Terminal',
|
|
83
|
+
binary: '/usr/bin/xterm',
|
|
84
|
+
args: [
|
|
85
|
+
'-fa',
|
|
86
|
+
'DejaVu Sans Mono',
|
|
87
|
+
'-fs',
|
|
88
|
+
'11',
|
|
89
|
+
'-bg',
|
|
90
|
+
'#171c18',
|
|
91
|
+
'-fg',
|
|
92
|
+
'#e7eee8',
|
|
93
|
+
'-cr',
|
|
94
|
+
'#76d77d',
|
|
95
|
+
'-title',
|
|
96
|
+
'Terminal',
|
|
97
|
+
],
|
|
98
|
+
desktop: ['debian-xterm.desktop', 'xterm.desktop'],
|
|
99
|
+
// The dock's own terminal glyph, not the plain xterm pixmap.
|
|
100
|
+
icon: 'utilities-terminal',
|
|
101
|
+
required: true,
|
|
102
|
+
},
|
|
103
|
+
{
|
|
104
|
+
id: 'openscad',
|
|
105
|
+
stage: 'creative',
|
|
106
|
+
name: 'OpenSCAD',
|
|
107
|
+
binary: '/usr/bin/openscad',
|
|
108
|
+
args: [],
|
|
109
|
+
desktop: ['openscad.desktop'],
|
|
110
|
+
software: true,
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
id: 'kdenlive',
|
|
114
|
+
stage: 'creative',
|
|
115
|
+
name: 'Kdenlive',
|
|
116
|
+
binary: '/usr/bin/kdenlive',
|
|
117
|
+
args: [],
|
|
118
|
+
desktop: ['org.kde.kdenlive.desktop', 'kdenlive.desktop'],
|
|
119
|
+
software: true,
|
|
120
|
+
},
|
|
121
|
+
{
|
|
122
|
+
id: 'godot',
|
|
123
|
+
stage: 'creative',
|
|
124
|
+
name: 'Godot',
|
|
125
|
+
binary: '/usr/local/bin/godot4',
|
|
126
|
+
args: ['--rendering-method', 'gl_compatibility'],
|
|
127
|
+
desktop: ['godot3.desktop'],
|
|
128
|
+
software: true,
|
|
129
|
+
},
|
|
130
|
+
{
|
|
131
|
+
id: 'solitaire',
|
|
132
|
+
stage: 'creative',
|
|
133
|
+
name: 'Solitaire',
|
|
134
|
+
binary: '/usr/games/sol',
|
|
135
|
+
args: [],
|
|
136
|
+
desktop: ['sol.desktop', 'org.gnome.Aisleriot.desktop', 'aisleriot.desktop'],
|
|
137
|
+
},
|
|
138
|
+
{
|
|
139
|
+
id: 'qgis',
|
|
140
|
+
stage: 'creative',
|
|
141
|
+
name: 'QGIS',
|
|
142
|
+
binary: '/usr/bin/qgis',
|
|
143
|
+
args: [],
|
|
144
|
+
desktop: ['org.qgis.qgis.desktop', 'qgis.desktop'],
|
|
145
|
+
software: true,
|
|
146
|
+
},
|
|
147
|
+
{
|
|
148
|
+
id: 'kicad',
|
|
149
|
+
stage: 'creative',
|
|
150
|
+
name: 'KiCad',
|
|
151
|
+
binary: '/usr/bin/kicad',
|
|
152
|
+
args: [],
|
|
153
|
+
desktop: ['org.kicad.kicad.desktop', 'kicad.desktop'],
|
|
154
|
+
software: true,
|
|
155
|
+
},
|
|
156
|
+
{
|
|
157
|
+
id: 'paraview',
|
|
158
|
+
stage: 'creative',
|
|
159
|
+
name: 'ParaView',
|
|
160
|
+
binary: '/usr/bin/paraview',
|
|
161
|
+
args: [],
|
|
162
|
+
desktop: ['paraview.desktop'],
|
|
163
|
+
software: true,
|
|
164
|
+
},
|
|
165
|
+
].map((app) =>
|
|
166
|
+
Object.freeze({ ...app, args: Object.freeze(app.args), desktop: Object.freeze(app.desktop) }),
|
|
167
|
+
),
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
/** Applications a stage installs: its own and those of every stage it is built FROM. */
|
|
171
|
+
function applicationsForStage(stage) {
|
|
172
|
+
const rank = STAGES.indexOf(stage)
|
|
173
|
+
if (rank < 0) throw new Error(`Unknown image stage: ${stage}`)
|
|
174
|
+
return APPLICATIONS.filter((app) => STAGES.indexOf(app.stage) <= rank)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* The stage this image was built as, written by prepare-home at build time. A tree
|
|
179
|
+
* without the marker (a source checkout, a test) is the whole catalogue.
|
|
180
|
+
*/
|
|
181
|
+
function activeStage(read = () => readFileSync(join(__dirname, 'stage'), 'utf8')) {
|
|
182
|
+
try {
|
|
183
|
+
const stage = read().trim()
|
|
184
|
+
return STAGES.includes(stage) ? stage : STAGES[STAGES.length - 1]
|
|
185
|
+
} catch {
|
|
186
|
+
return STAGES[STAGES.length - 1]
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function activeApplications(read) {
|
|
191
|
+
return applicationsForStage(activeStage(read))
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function installedApplication(
|
|
195
|
+
id,
|
|
196
|
+
access = (binary) => accessSync(binary, constants.X_OK),
|
|
197
|
+
applications = activeApplications(),
|
|
198
|
+
) {
|
|
199
|
+
const app = applications.find((entry) => entry.id === id)
|
|
200
|
+
if (!app) return undefined
|
|
201
|
+
try {
|
|
202
|
+
access(app.binary)
|
|
203
|
+
return app
|
|
204
|
+
} catch {
|
|
205
|
+
return undefined
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function applicationEnvironment(app, inherited = process.env) {
|
|
210
|
+
const env = { ...inherited }
|
|
211
|
+
for (const key of Object.keys(env))
|
|
212
|
+
if (key.startsWith('NAMZU_') || key === 'NODE_OPTIONS' || key.startsWith('LD_')) delete env[key]
|
|
213
|
+
// Chromium disables its own D-Bus use. Desktop applications still need the
|
|
214
|
+
// guest session bus or their normal local autolaunch when no bus is set.
|
|
215
|
+
if (env.DBUS_SESSION_BUS_ADDRESS === 'disabled:')
|
|
216
|
+
Reflect.deleteProperty(env, 'DBUS_SESSION_BUS_ADDRESS')
|
|
217
|
+
// Explicit guest-only values. No host display, profile or caller cwd is used.
|
|
218
|
+
env.HOME = '/home/namzu'
|
|
219
|
+
env.DISPLAY = ':99'
|
|
220
|
+
env.PATH = '/usr/local/bin:/usr/bin:/bin:/usr/games'
|
|
221
|
+
if (app.software) env.LIBGL_ALWAYS_SOFTWARE = '1'
|
|
222
|
+
if (app.id === 'blender') {
|
|
223
|
+
env.BLENDERMCP_NO_UPDATE_CHECK = '1'
|
|
224
|
+
env.BLENDER_MCP_DISABLE_TELEMETRY = '1'
|
|
225
|
+
}
|
|
226
|
+
// The isolated guest has no host audio device. SDL must not abort the editor
|
|
227
|
+
// while trying to open an unavailable ALSA default output.
|
|
228
|
+
if (app.id === 'kdenlive') env.SDL_AUDIODRIVER = 'dummy'
|
|
229
|
+
return env
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
module.exports = {
|
|
233
|
+
APPLICATIONS,
|
|
234
|
+
STAGES,
|
|
235
|
+
applicationsForStage,
|
|
236
|
+
activeStage,
|
|
237
|
+
activeApplications,
|
|
238
|
+
installedApplication,
|
|
239
|
+
applicationEnvironment,
|
|
240
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
#!/usr/local/bin/node
|
|
2
|
+
const { spawn } = require('node:child_process')
|
|
3
|
+
const { createHash } = require('node:crypto')
|
|
4
|
+
const { readFileSync } = require('node:fs')
|
|
5
|
+
const { join } = require('node:path')
|
|
6
|
+
const { installedApplication, applicationEnvironment } = require('./app-catalog.cjs')
|
|
7
|
+
const manifest = require('./new-tab/manifest.json')
|
|
8
|
+
|
|
9
|
+
const HOST_NAME = 'org.namzu.apps'
|
|
10
|
+
const extensionId = [
|
|
11
|
+
...createHash('sha256').update(Buffer.from(manifest.key, 'base64')).digest().subarray(0, 16),
|
|
12
|
+
]
|
|
13
|
+
.map((byte) => String.fromCharCode(97 + (byte >> 4), 97 + (byte & 15)))
|
|
14
|
+
.join('')
|
|
15
|
+
const EXTENSION_ORIGIN = `chrome-extension://${extensionId}/`
|
|
16
|
+
const MAX_MESSAGE = 4096
|
|
17
|
+
|
|
18
|
+
function launcherMessage(message) {
|
|
19
|
+
if (!message || typeof message !== 'object' || Array.isArray(message)) return undefined
|
|
20
|
+
const keys = Object.keys(message)
|
|
21
|
+
if (message.type === 'list' && keys.length === 1) return { type: 'list' }
|
|
22
|
+
if (
|
|
23
|
+
message.type === 'launch' &&
|
|
24
|
+
keys.length === 2 &&
|
|
25
|
+
typeof message.appId === 'string' &&
|
|
26
|
+
/^[a-z]{1,20}$/.test(message.appId)
|
|
27
|
+
)
|
|
28
|
+
return { type: 'launch', appId: message.appId }
|
|
29
|
+
return undefined
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function launchApplication(app, spawnProcess = spawn) {
|
|
33
|
+
return new Promise((resolve) => {
|
|
34
|
+
let child
|
|
35
|
+
try {
|
|
36
|
+
child = spawnProcess(app.binary, [...app.args], {
|
|
37
|
+
cwd: '/home/namzu/workspace',
|
|
38
|
+
env: applicationEnvironment(app),
|
|
39
|
+
detached: true,
|
|
40
|
+
shell: false,
|
|
41
|
+
stdio: 'ignore',
|
|
42
|
+
})
|
|
43
|
+
} catch {
|
|
44
|
+
resolve({ ok: false, error: 'launch_failed' })
|
|
45
|
+
return
|
|
46
|
+
}
|
|
47
|
+
child.once('error', () => resolve({ ok: false, error: 'launch_failed' }))
|
|
48
|
+
child.once('spawn', () => {
|
|
49
|
+
child.unref()
|
|
50
|
+
resolve({ ok: true, appId: app.id })
|
|
51
|
+
})
|
|
52
|
+
})
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** One bounded Chrome native message per host process. No guest HTTP port or token. */
|
|
56
|
+
async function handleMessage(message, options = {}) {
|
|
57
|
+
const parsed = launcherMessage(message)
|
|
58
|
+
if (!parsed) return { ok: false, error: 'invalid_request' }
|
|
59
|
+
if (parsed.type === 'list') {
|
|
60
|
+
try {
|
|
61
|
+
const apps = JSON.parse(
|
|
62
|
+
(options.readApps ?? (() => readFileSync(join(__dirname, 'new-tab/apps.json'), 'utf8')))(),
|
|
63
|
+
)
|
|
64
|
+
if (!Array.isArray(apps)) throw new Error('Invalid catalogue')
|
|
65
|
+
return { ok: true, apps }
|
|
66
|
+
} catch {
|
|
67
|
+
return { ok: false, error: 'catalogue_unavailable' }
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
const app = installedApplication(parsed.appId, options.access)
|
|
71
|
+
if (!app) return { ok: false, error: 'application_unavailable' }
|
|
72
|
+
return await launchApplication(app, options.spawn)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function writeMessage(output, message) {
|
|
76
|
+
const body = Buffer.from(JSON.stringify(message), 'utf8')
|
|
77
|
+
const header = Buffer.alloc(4)
|
|
78
|
+
header.writeUInt32LE(body.length)
|
|
79
|
+
output.end(Buffer.concat([header, body]))
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function runNativeHost(origin, input = process.stdin, output = process.stdout) {
|
|
83
|
+
if (origin !== EXTENSION_ORIGIN) {
|
|
84
|
+
writeMessage(output, { ok: false, error: 'unauthorized_origin' })
|
|
85
|
+
return
|
|
86
|
+
}
|
|
87
|
+
let body = Buffer.alloc(0)
|
|
88
|
+
let size
|
|
89
|
+
let settled = false
|
|
90
|
+
const finish = (response) => {
|
|
91
|
+
if (settled) return
|
|
92
|
+
settled = true
|
|
93
|
+
input.removeListener('data', receive)
|
|
94
|
+
input.destroy()
|
|
95
|
+
writeMessage(output, response)
|
|
96
|
+
}
|
|
97
|
+
const receive = (chunk) => {
|
|
98
|
+
if (settled) return
|
|
99
|
+
if (body.length + chunk.length > MAX_MESSAGE + 4) {
|
|
100
|
+
finish({ ok: false, error: 'invalid_request' })
|
|
101
|
+
return
|
|
102
|
+
}
|
|
103
|
+
body = Buffer.concat([body, chunk])
|
|
104
|
+
if (body.length < 4) return
|
|
105
|
+
if (size === undefined) size = body.readUInt32LE(0)
|
|
106
|
+
if (size < 1 || size > MAX_MESSAGE || body.length > size + 4) {
|
|
107
|
+
finish({ ok: false, error: 'invalid_request' })
|
|
108
|
+
return
|
|
109
|
+
}
|
|
110
|
+
if (body.length !== size + 4) return
|
|
111
|
+
let message
|
|
112
|
+
try {
|
|
113
|
+
message = JSON.parse(body.toString('utf8', 4))
|
|
114
|
+
} catch {
|
|
115
|
+
finish({ ok: false, error: 'invalid_request' })
|
|
116
|
+
return
|
|
117
|
+
}
|
|
118
|
+
settled = true
|
|
119
|
+
input.removeListener('data', receive)
|
|
120
|
+
input.destroy()
|
|
121
|
+
void handleMessage(message).then(
|
|
122
|
+
(response) => writeMessage(output, response),
|
|
123
|
+
() => writeMessage(output, { ok: false, error: 'launch_failed' }),
|
|
124
|
+
)
|
|
125
|
+
}
|
|
126
|
+
input.on('data', receive)
|
|
127
|
+
input.on('end', () => {
|
|
128
|
+
if (!settled) finish({ ok: false, error: 'invalid_request' })
|
|
129
|
+
})
|
|
130
|
+
input.on('error', () => {
|
|
131
|
+
if (!settled) finish({ ok: false, error: 'invalid_request' })
|
|
132
|
+
})
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
module.exports = {
|
|
136
|
+
EXTENSION_ORIGIN,
|
|
137
|
+
HOST_NAME,
|
|
138
|
+
MAX_MESSAGE,
|
|
139
|
+
launcherMessage,
|
|
140
|
+
launchApplication,
|
|
141
|
+
handleMessage,
|
|
142
|
+
runNativeHost,
|
|
143
|
+
}
|
|
144
|
+
if (require.main === module) runNativeHost(process.argv[2])
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
const { spawn } = require('node:child_process')
|
|
2
|
+
const { randomBytes } = require('node:crypto')
|
|
3
|
+
const { writeFileSync } = require('node:fs')
|
|
4
|
+
const { activeApplications } = require('./app-catalog.cjs')
|
|
5
|
+
const { EXTENSION_ORIGIN } = require('./app-native-host.cjs')
|
|
6
|
+
|
|
7
|
+
const HOME_URL = `${EXTENSION_ORIGIN}home.html`
|
|
8
|
+
const NEW_TAB_URL = 'chrome://newtab/'
|
|
9
|
+
const MARKER_BASE = 'http://127.0.0.1:9222/namzu-home-bootstrap?id='
|
|
10
|
+
const READY_FILE = '/tmp/namzu-browser-home-ready'
|
|
11
|
+
const PRESENTED_HOME = `({url:location.href,ready:document.readyState,apps:document.getElementById('apps')?.children.length||0,clock:!!document.getElementById('clock')})`
|
|
12
|
+
|
|
13
|
+
class BrowserHomeError extends Error {
|
|
14
|
+
constructor(code) {
|
|
15
|
+
super(code)
|
|
16
|
+
this.code = code
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function pause(signal) {
|
|
21
|
+
return new Promise((resolve, reject) => {
|
|
22
|
+
if (signal.aborted) return reject(new BrowserHomeError('home-startup-aborted'))
|
|
23
|
+
const finish = (error) => {
|
|
24
|
+
clearTimeout(timer)
|
|
25
|
+
signal.removeEventListener('abort', abort)
|
|
26
|
+
if (error) reject(error)
|
|
27
|
+
else resolve()
|
|
28
|
+
}
|
|
29
|
+
const abort = () => finish(new BrowserHomeError('home-startup-aborted'))
|
|
30
|
+
const timer = setTimeout(() => finish(), 100)
|
|
31
|
+
signal.addEventListener('abort', abort, { once: true })
|
|
32
|
+
})
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function listTargets(signal) {
|
|
36
|
+
const response = await fetch('http://127.0.0.1:9222/json/list', { signal })
|
|
37
|
+
if (!response.ok) throw new BrowserHomeError('browser-not-ready')
|
|
38
|
+
const text = await response.text()
|
|
39
|
+
if (text.length > 1024 * 1024) throw new BrowserHomeError('browser-target-list-too-large')
|
|
40
|
+
const targets = JSON.parse(text)
|
|
41
|
+
if (!Array.isArray(targets) || targets.length > 1000)
|
|
42
|
+
throw new BrowserHomeError('browser-target-list-invalid')
|
|
43
|
+
return targets
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function request(target, method, params, signal) {
|
|
47
|
+
let url
|
|
48
|
+
try {
|
|
49
|
+
url = new URL(target.webSocketDebuggerUrl)
|
|
50
|
+
} catch {
|
|
51
|
+
return Promise.reject(new BrowserHomeError('browser-target-invalid'))
|
|
52
|
+
}
|
|
53
|
+
if (
|
|
54
|
+
url.protocol !== 'ws:' ||
|
|
55
|
+
url.hostname !== '127.0.0.1' ||
|
|
56
|
+
url.port !== '9222' ||
|
|
57
|
+
url.username ||
|
|
58
|
+
url.password ||
|
|
59
|
+
url.pathname !== `/devtools/page/${target.id}` ||
|
|
60
|
+
url.search ||
|
|
61
|
+
url.hash
|
|
62
|
+
)
|
|
63
|
+
return Promise.reject(new BrowserHomeError('browser-target-invalid'))
|
|
64
|
+
return new Promise((resolve, reject) => {
|
|
65
|
+
if (signal.aborted) return reject(new BrowserHomeError('home-startup-aborted'))
|
|
66
|
+
const socket = new WebSocket(url)
|
|
67
|
+
let settled = false
|
|
68
|
+
const finish = (error, result) => {
|
|
69
|
+
if (settled) return
|
|
70
|
+
settled = true
|
|
71
|
+
signal.removeEventListener('abort', abort)
|
|
72
|
+
socket.removeEventListener('open', open)
|
|
73
|
+
socket.removeEventListener('message', message)
|
|
74
|
+
socket.removeEventListener('error', lost)
|
|
75
|
+
socket.removeEventListener('close', lost)
|
|
76
|
+
// Undici reports closing an opening socket as an error event.
|
|
77
|
+
socket.addEventListener('error', () => {}, { once: true })
|
|
78
|
+
socket.close()
|
|
79
|
+
if (error) reject(error)
|
|
80
|
+
else resolve(result)
|
|
81
|
+
}
|
|
82
|
+
const abort = () => finish(new BrowserHomeError('home-startup-aborted'))
|
|
83
|
+
const lost = () => finish(new BrowserHomeError('browser-target-disconnected'))
|
|
84
|
+
const open = () => {
|
|
85
|
+
try {
|
|
86
|
+
socket.send(JSON.stringify({ id: 1, method, params }))
|
|
87
|
+
} catch {
|
|
88
|
+
finish(new BrowserHomeError('browser-command-refused'))
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
const message = (event) => {
|
|
92
|
+
try {
|
|
93
|
+
const text = String(event.data)
|
|
94
|
+
if (text.length > 65536) throw new Error('Bounded response required')
|
|
95
|
+
const reply = JSON.parse(text)
|
|
96
|
+
if (reply.id !== 1) return
|
|
97
|
+
if (reply.error) finish(new BrowserHomeError('browser-command-refused'))
|
|
98
|
+
else finish(undefined, reply.result)
|
|
99
|
+
} catch {
|
|
100
|
+
finish(new BrowserHomeError('browser-response-invalid'))
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
signal.addEventListener('abort', abort, { once: true })
|
|
104
|
+
socket.addEventListener('open', open, { once: true })
|
|
105
|
+
socket.addEventListener('message', message)
|
|
106
|
+
socket.addEventListener('error', lost, { once: true })
|
|
107
|
+
socket.addEventListener('close', lost, { once: true })
|
|
108
|
+
})
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Bootstrap only the unique tab created by this launcher, never a restored tab. */
|
|
112
|
+
async function bootstrapBrowserHome(marker, options = {}) {
|
|
113
|
+
if (!/^http:\/\/127\.0\.0\.1:9222\/namzu-home-bootstrap\?id=[a-f0-9]{32}$/.test(marker))
|
|
114
|
+
throw new BrowserHomeError('browser-marker-invalid')
|
|
115
|
+
const signal = options.signal ?? AbortSignal.timeout(15000)
|
|
116
|
+
const list = options.listTargets ?? listTargets
|
|
117
|
+
const command = options.request ?? request
|
|
118
|
+
const delay = options.pause ?? pause
|
|
119
|
+
let id
|
|
120
|
+
let navigated = false
|
|
121
|
+
let nativeNewTab = false
|
|
122
|
+
while (!signal.aborted) {
|
|
123
|
+
let targets
|
|
124
|
+
try {
|
|
125
|
+
targets = await list(signal)
|
|
126
|
+
} catch {
|
|
127
|
+
await delay(signal)
|
|
128
|
+
continue
|
|
129
|
+
}
|
|
130
|
+
const target = targets.find((entry) =>
|
|
131
|
+
id ? entry.type === 'page' && entry.id === id : entry.type === 'page' && entry.url === marker,
|
|
132
|
+
)
|
|
133
|
+
if (!target) {
|
|
134
|
+
if (id) throw new BrowserHomeError('owned-home-tab-closed')
|
|
135
|
+
await delay(signal)
|
|
136
|
+
continue
|
|
137
|
+
}
|
|
138
|
+
id ??= target.id
|
|
139
|
+
if (![marker, NEW_TAB_URL, HOME_URL].includes(target.url))
|
|
140
|
+
throw new BrowserHomeError('owned-home-tab-navigated-away')
|
|
141
|
+
if (navigated) {
|
|
142
|
+
const result = await command(
|
|
143
|
+
target,
|
|
144
|
+
'Runtime.evaluate',
|
|
145
|
+
{ expression: PRESENTED_HOME, returnByValue: true },
|
|
146
|
+
signal,
|
|
147
|
+
)
|
|
148
|
+
const value = result?.result?.value
|
|
149
|
+
if (
|
|
150
|
+
value?.url === HOME_URL &&
|
|
151
|
+
value.ready === 'complete' &&
|
|
152
|
+
value.clock === true &&
|
|
153
|
+
value.apps === activeApplications().length
|
|
154
|
+
) {
|
|
155
|
+
if (nativeNewTab) return
|
|
156
|
+
// Activate this trusted extension, then use Chromium's native route
|
|
157
|
+
// for its New Tab virtual URL rather than leaving a literal address.
|
|
158
|
+
await command(target, 'Page.navigate', { url: NEW_TAB_URL }, signal)
|
|
159
|
+
nativeNewTab = true
|
|
160
|
+
await delay(signal)
|
|
161
|
+
continue
|
|
162
|
+
}
|
|
163
|
+
if (value?.url === HOME_URL) {
|
|
164
|
+
await delay(signal)
|
|
165
|
+
continue
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
await command(target, 'Page.navigate', { url: nativeNewTab ? NEW_TAB_URL : HOME_URL }, signal)
|
|
169
|
+
navigated = true
|
|
170
|
+
await delay(signal)
|
|
171
|
+
}
|
|
172
|
+
throw new BrowserHomeError('home-startup-aborted')
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function browserLaunchArguments(args, nonce = randomBytes(16).toString('hex')) {
|
|
176
|
+
if (!/^[a-f0-9]{32}$/.test(nonce)) throw new BrowserHomeError('browser-marker-invalid')
|
|
177
|
+
const homeRequested = args.at(-1) === NEW_TAB_URL
|
|
178
|
+
const marker = `${MARKER_BASE}${nonce}`
|
|
179
|
+
return { homeRequested, marker, args: homeRequested ? [...args.slice(0, -1), marker] : [...args] }
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async function runBrowser(args) {
|
|
183
|
+
const { homeRequested, marker, args: actualArgs } = browserLaunchArguments(args)
|
|
184
|
+
const controller = new AbortController()
|
|
185
|
+
const deadline = homeRequested ? setTimeout(() => controller.abort(), 15000) : undefined
|
|
186
|
+
const child = spawn('/usr/bin/chromium', actualArgs, { stdio: 'inherit' })
|
|
187
|
+
let ended = false
|
|
188
|
+
const closed = new Promise((resolve, reject) => {
|
|
189
|
+
child.once('error', () => {
|
|
190
|
+
ended = true
|
|
191
|
+
controller.abort()
|
|
192
|
+
reject(new BrowserHomeError('browser-start-failed'))
|
|
193
|
+
})
|
|
194
|
+
child.once('close', (code) => {
|
|
195
|
+
ended = true
|
|
196
|
+
if (code !== 0) controller.abort()
|
|
197
|
+
resolve(code ?? 1)
|
|
198
|
+
})
|
|
199
|
+
})
|
|
200
|
+
void closed.catch(() => {})
|
|
201
|
+
const stop = () => {
|
|
202
|
+
controller.abort()
|
|
203
|
+
if (!ended) child.kill('SIGTERM')
|
|
204
|
+
}
|
|
205
|
+
process.on('SIGTERM', stop)
|
|
206
|
+
process.on('SIGINT', stop)
|
|
207
|
+
try {
|
|
208
|
+
if (homeRequested) {
|
|
209
|
+
await bootstrapBrowserHome(marker, { signal: controller.signal })
|
|
210
|
+
clearTimeout(deadline)
|
|
211
|
+
if (process.env.NAMZU_BROWSER_HOME_READY === '1')
|
|
212
|
+
writeFileSync(READY_FILE, 'ready\n', { flag: 'wx', mode: 0o600 })
|
|
213
|
+
}
|
|
214
|
+
return await closed
|
|
215
|
+
} catch (error) {
|
|
216
|
+
if (!ended) child.kill('SIGTERM')
|
|
217
|
+
const retirement = setTimeout(() => {
|
|
218
|
+
if (!ended) child.kill('SIGKILL')
|
|
219
|
+
}, 2000)
|
|
220
|
+
await closed.catch(() => {})
|
|
221
|
+
clearTimeout(retirement)
|
|
222
|
+
throw error
|
|
223
|
+
} finally {
|
|
224
|
+
clearTimeout(deadline)
|
|
225
|
+
process.removeListener('SIGTERM', stop)
|
|
226
|
+
process.removeListener('SIGINT', stop)
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
module.exports = {
|
|
231
|
+
HOME_URL,
|
|
232
|
+
NEW_TAB_URL,
|
|
233
|
+
MARKER_BASE,
|
|
234
|
+
READY_FILE,
|
|
235
|
+
bootstrapBrowserHome,
|
|
236
|
+
browserLaunchArguments,
|
|
237
|
+
request,
|
|
238
|
+
}
|
|
239
|
+
if (require.main === module)
|
|
240
|
+
void runBrowser(process.argv.slice(2)).then(
|
|
241
|
+
(code) => {
|
|
242
|
+
process.exitCode = code
|
|
243
|
+
},
|
|
244
|
+
(error) => {
|
|
245
|
+
const reason = error instanceof BrowserHomeError ? error.code : 'home-startup-failed'
|
|
246
|
+
process.stderr.write(`Namzu browser home startup failed: ${reason}\n`)
|
|
247
|
+
process.exitCode = 1
|
|
248
|
+
},
|
|
249
|
+
)
|