@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.
Files changed (158) hide show
  1. package/CHANGELOG.md +284 -0
  2. package/dist/backends/http-worker-client.d.ts +3 -1
  3. package/dist/backends/http-worker-client.d.ts.map +1 -1
  4. package/dist/backends/http-worker-client.js +15 -4
  5. package/dist/backends/http-worker-client.js.map +1 -1
  6. package/dist/index.d.ts +2 -0
  7. package/dist/index.d.ts.map +1 -1
  8. package/dist/index.js +1 -0
  9. package/dist/index.js.map +1 -1
  10. package/dist/local-virtual-computer/client.d.ts +21 -0
  11. package/dist/local-virtual-computer/client.d.ts.map +1 -0
  12. package/dist/local-virtual-computer/client.js +503 -0
  13. package/dist/local-virtual-computer/client.js.map +1 -0
  14. package/dist/local-virtual-computer/detached-process.d.ts +15 -0
  15. package/dist/local-virtual-computer/detached-process.d.ts.map +1 -0
  16. package/dist/local-virtual-computer/detached-process.js +111 -0
  17. package/dist/local-virtual-computer/detached-process.js.map +1 -0
  18. package/dist/local-virtual-computer/detached-worker.d.ts +11 -0
  19. package/dist/local-virtual-computer/detached-worker.d.ts.map +1 -0
  20. package/dist/local-virtual-computer/detached-worker.js +97 -0
  21. package/dist/local-virtual-computer/detached-worker.js.map +1 -0
  22. package/dist/local-virtual-computer/dialect.d.ts +26 -0
  23. package/dist/local-virtual-computer/dialect.d.ts.map +1 -0
  24. package/dist/local-virtual-computer/dialect.js +61 -0
  25. package/dist/local-virtual-computer/dialect.js.map +1 -0
  26. package/dist/local-virtual-computer/engine.d.ts +63 -0
  27. package/dist/local-virtual-computer/engine.d.ts.map +1 -0
  28. package/dist/local-virtual-computer/engine.js +125 -0
  29. package/dist/local-virtual-computer/engine.js.map +1 -0
  30. package/dist/local-virtual-computer/guest-stdio.d.ts +15 -0
  31. package/dist/local-virtual-computer/guest-stdio.d.ts.map +1 -0
  32. package/dist/local-virtual-computer/guest-stdio.js +330 -0
  33. package/dist/local-virtual-computer/guest-stdio.js.map +1 -0
  34. package/dist/local-virtual-computer/index.d.ts +36 -0
  35. package/dist/local-virtual-computer/index.d.ts.map +1 -0
  36. package/dist/local-virtual-computer/index.js +341 -0
  37. package/dist/local-virtual-computer/index.js.map +1 -0
  38. package/dist/local-virtual-computer/podman-machine.d.ts +17 -0
  39. package/dist/local-virtual-computer/podman-machine.d.ts.map +1 -0
  40. package/dist/local-virtual-computer/podman-machine.js +181 -0
  41. package/dist/local-virtual-computer/podman-machine.js.map +1 -0
  42. package/dist/local-virtual-computer/setup/contract.d.ts +360 -0
  43. package/dist/local-virtual-computer/setup/contract.d.ts.map +1 -0
  44. package/dist/local-virtual-computer/setup/contract.js +3 -0
  45. package/dist/local-virtual-computer/setup/contract.js.map +1 -0
  46. package/dist/local-virtual-computer/setup/elevated.d.ts +78 -0
  47. package/dist/local-virtual-computer/setup/elevated.d.ts.map +1 -0
  48. package/dist/local-virtual-computer/setup/elevated.js +548 -0
  49. package/dist/local-virtual-computer/setup/elevated.js.map +1 -0
  50. package/dist/local-virtual-computer/setup/host.d.ts +12 -0
  51. package/dist/local-virtual-computer/setup/host.d.ts.map +1 -0
  52. package/dist/local-virtual-computer/setup/host.js +93 -0
  53. package/dist/local-virtual-computer/setup/host.js.map +1 -0
  54. package/dist/local-virtual-computer/setup/idle.d.ts +18 -0
  55. package/dist/local-virtual-computer/setup/idle.d.ts.map +1 -0
  56. package/dist/local-virtual-computer/setup/idle.js +38 -0
  57. package/dist/local-virtual-computer/setup/idle.js.map +1 -0
  58. package/dist/local-virtual-computer/setup/image.d.ts +7 -0
  59. package/dist/local-virtual-computer/setup/image.d.ts.map +1 -0
  60. package/dist/local-virtual-computer/setup/image.js +124 -0
  61. package/dist/local-virtual-computer/setup/image.js.map +1 -0
  62. package/dist/local-virtual-computer/setup/index.d.ts +17 -0
  63. package/dist/local-virtual-computer/setup/index.d.ts.map +1 -0
  64. package/dist/local-virtual-computer/setup/index.js +17 -0
  65. package/dist/local-virtual-computer/setup/index.js.map +1 -0
  66. package/dist/local-virtual-computer/setup/limits.d.ts +30 -0
  67. package/dist/local-virtual-computer/setup/limits.d.ts.map +1 -0
  68. package/dist/local-virtual-computer/setup/limits.js +58 -0
  69. package/dist/local-virtual-computer/setup/limits.js.map +1 -0
  70. package/dist/local-virtual-computer/setup/plan.d.ts +8 -0
  71. package/dist/local-virtual-computer/setup/plan.d.ts.map +1 -0
  72. package/dist/local-virtual-computer/setup/plan.js +131 -0
  73. package/dist/local-virtual-computer/setup/plan.js.map +1 -0
  74. package/dist/local-virtual-computer/setup/readiness.d.ts +11 -0
  75. package/dist/local-virtual-computer/setup/readiness.d.ts.map +1 -0
  76. package/dist/local-virtual-computer/setup/readiness.js +320 -0
  77. package/dist/local-virtual-computer/setup/readiness.js.map +1 -0
  78. package/dist/local-virtual-computer/setup/registry.d.ts +18 -0
  79. package/dist/local-virtual-computer/setup/registry.d.ts.map +1 -0
  80. package/dist/local-virtual-computer/setup/registry.js +50 -0
  81. package/dist/local-virtual-computer/setup/registry.js.map +1 -0
  82. package/dist/local-virtual-computer/setup/restart.d.ts +8 -0
  83. package/dist/local-virtual-computer/setup/restart.d.ts.map +1 -0
  84. package/dist/local-virtual-computer/setup/restart.js +60 -0
  85. package/dist/local-virtual-computer/setup/restart.js.map +1 -0
  86. package/dist/local-virtual-computer/setup/resume.d.ts +14 -0
  87. package/dist/local-virtual-computer/setup/resume.d.ts.map +1 -0
  88. package/dist/local-virtual-computer/setup/resume.js +58 -0
  89. package/dist/local-virtual-computer/setup/resume.js.map +1 -0
  90. package/dist/local-virtual-computer/setup/run.d.ts +8 -0
  91. package/dist/local-virtual-computer/setup/run.d.ts.map +1 -0
  92. package/dist/local-virtual-computer/setup/run.js +200 -0
  93. package/dist/local-virtual-computer/setup/run.js.map +1 -0
  94. package/dist/local-virtual-computer/setup/windows-folder.d.ts +11 -0
  95. package/dist/local-virtual-computer/setup/windows-folder.d.ts.map +1 -0
  96. package/dist/local-virtual-computer/setup/windows-folder.js +14 -0
  97. package/dist/local-virtual-computer/setup/windows-folder.js.map +1 -0
  98. package/dist/local-virtual-computer/setup/wsl-package.d.ts +19 -0
  99. package/dist/local-virtual-computer/setup/wsl-package.d.ts.map +1 -0
  100. package/dist/local-virtual-computer/setup/wsl-package.js +242 -0
  101. package/dist/local-virtual-computer/setup/wsl-package.js.map +1 -0
  102. package/dist/local-virtual-computer/setup/wslc-shared.d.ts +27 -0
  103. package/dist/local-virtual-computer/setup/wslc-shared.d.ts.map +1 -0
  104. package/dist/local-virtual-computer/setup/wslc-shared.js +137 -0
  105. package/dist/local-virtual-computer/setup/wslc-shared.js.map +1 -0
  106. package/dist/local-virtual-computer/ssh-forward.d.ts +19 -0
  107. package/dist/local-virtual-computer/ssh-forward.d.ts.map +1 -0
  108. package/dist/local-virtual-computer/ssh-forward.js +179 -0
  109. package/dist/local-virtual-computer/ssh-forward.js.map +1 -0
  110. package/dist/local-virtual-computer/wslc-engine.d.ts +27 -0
  111. package/dist/local-virtual-computer/wslc-engine.d.ts.map +1 -0
  112. package/dist/local-virtual-computer/wslc-engine.js +98 -0
  113. package/dist/local-virtual-computer/wslc-engine.js.map +1 -0
  114. package/local-computer/Dockerfile +99 -0
  115. package/local-computer/app-catalog.cjs +240 -0
  116. package/local-computer/app-native-host.cjs +144 -0
  117. package/local-computer/browser-home.cjs +249 -0
  118. package/local-computer/build-image.mjs +310 -0
  119. package/local-computer/desktop-worker.cjs +477 -0
  120. package/local-computer/entrypoint.sh +161 -0
  121. package/local-computer/home.html +28 -0
  122. package/local-computer/new-tab/home.css +255 -0
  123. package/local-computer/new-tab/home.js +65 -0
  124. package/local-computer/new-tab/manifest.json +12 -0
  125. package/local-computer/new-tab/wordmark.svg +33 -0
  126. package/local-computer/openbox-rc.xml +41 -0
  127. package/local-computer/prepare-home.cjs +121 -0
  128. package/local-computer/tint2rc +44 -0
  129. package/package.json +13 -4
  130. package/src/backends/http-worker-client.ts +31 -3
  131. package/src/index.ts +13 -0
  132. package/src/local-virtual-computer/client.ts +573 -0
  133. package/src/local-virtual-computer/detached-process.ts +133 -0
  134. package/src/local-virtual-computer/detached-worker.ts +124 -0
  135. package/src/local-virtual-computer/dialect.ts +83 -0
  136. package/src/local-virtual-computer/engine.ts +197 -0
  137. package/src/local-virtual-computer/guest-stdio.ts +332 -0
  138. package/src/local-virtual-computer/index.ts +447 -0
  139. package/src/local-virtual-computer/podman-machine.ts +264 -0
  140. package/src/local-virtual-computer/setup/contract.ts +395 -0
  141. package/src/local-virtual-computer/setup/elevated.ts +655 -0
  142. package/src/local-virtual-computer/setup/host.ts +100 -0
  143. package/src/local-virtual-computer/setup/idle.ts +39 -0
  144. package/src/local-virtual-computer/setup/image.ts +159 -0
  145. package/src/local-virtual-computer/setup/index.ts +16 -0
  146. package/src/local-virtual-computer/setup/limits.ts +68 -0
  147. package/src/local-virtual-computer/setup/plan.ts +144 -0
  148. package/src/local-virtual-computer/setup/readiness.ts +386 -0
  149. package/src/local-virtual-computer/setup/registry.ts +50 -0
  150. package/src/local-virtual-computer/setup/restart.ts +64 -0
  151. package/src/local-virtual-computer/setup/resume.ts +65 -0
  152. package/src/local-virtual-computer/setup/run.ts +225 -0
  153. package/src/local-virtual-computer/setup/windows-folder.ts +13 -0
  154. package/src/local-virtual-computer/setup/wsl-package.ts +242 -0
  155. package/src/local-virtual-computer/setup/wslc-shared.ts +147 -0
  156. package/src/local-virtual-computer/ssh-forward.ts +195 -0
  157. package/src/local-virtual-computer/wslc-engine.ts +124 -0
  158. 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
+ )