workoffdesk 0.0.0-stage → 1.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 (128) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/LICENSE +21 -0
  3. package/README.md +199 -2
  4. package/SECURITY.md +50 -0
  5. package/dist/build.json +1 -0
  6. package/dist/server/agents/claude/accounts.js +105 -0
  7. package/dist/server/agents/claude/hooks/asks.js +135 -0
  8. package/dist/server/agents/claude/hooks/hooks.js +114 -0
  9. package/dist/server/agents/claude/hooks/status.js +34 -0
  10. package/dist/server/agents/claude/hooks/tracker.js +79 -0
  11. package/dist/server/agents/claude/offers/commands.js +219 -0
  12. package/dist/server/agents/claude/offers/files.js +51 -0
  13. package/dist/server/agents/claude/offers/initialize.js +59 -0
  14. package/dist/server/agents/claude/offers/models.js +20 -0
  15. package/dist/server/agents/claude/screen/choices.js +189 -0
  16. package/dist/server/agents/claude/screen/compacting.js +20 -0
  17. package/dist/server/agents/claude/screen/live.js +63 -0
  18. package/dist/server/agents/claude/screen/panel.js +140 -0
  19. package/dist/server/agents/claude/screen/prompt.js +23 -0
  20. package/dist/server/agents/claude/screen/screen.js +79 -0
  21. package/dist/server/agents/claude/terminals.js +110 -0
  22. package/dist/server/agents/claude/transcript/chat.js +254 -0
  23. package/dist/server/agents/claude/transcript/transcriptFile.js +194 -0
  24. package/dist/server/agents/claude/transcript/transcripts.js +210 -0
  25. package/dist/server/agents/claude/transcript/turn.js +136 -0
  26. package/dist/server/agents/claude/transcript/watch.js +60 -0
  27. package/dist/server/agents/claude/usage.js +36 -0
  28. package/dist/server/agents/codex/agent.js +263 -0
  29. package/dist/server/agents/codex/appServer.js +38 -0
  30. package/dist/server/agents/codex/items.js +226 -0
  31. package/dist/server/agents/codex/notifications.js +192 -0
  32. package/dist/server/agents/codex/rpc.js +121 -0
  33. package/dist/server/agents/codex/screen.js +5 -0
  34. package/dist/server/agents/codex/threads.js +208 -0
  35. package/dist/server/agents/types.js +1 -0
  36. package/dist/server/cli.js +49 -0
  37. package/dist/server/commands/account.js +61 -0
  38. package/dist/server/commands/attach.js +27 -0
  39. package/dist/server/commands/common.js +114 -0
  40. package/dist/server/commands/doctor.js +43 -0
  41. package/dist/server/commands/install.js +169 -0
  42. package/dist/server/commands/open.js +31 -0
  43. package/dist/server/commands/qr.js +33 -0
  44. package/dist/server/commands/serve.js +45 -0
  45. package/dist/server/commands/service.js +87 -0
  46. package/dist/server/commands/setup.js +36 -0
  47. package/dist/server/commands/setupWeb.js +87 -0
  48. package/dist/server/commands/status.js +32 -0
  49. package/dist/server/commands/wizard/probe.js +19 -0
  50. package/dist/server/commands/wizard/server.js +162 -0
  51. package/dist/server/http/app.js +144 -0
  52. package/dist/server/http/auth/guard.js +78 -0
  53. package/dist/server/http/auth/lockout.js +41 -0
  54. package/dist/server/http/auth/passkey.js +151 -0
  55. package/dist/server/http/auth/password.js +23 -0
  56. package/dist/server/http/auth/sessions.js +46 -0
  57. package/dist/server/http/routes/appUpdate.js +97 -0
  58. package/dist/server/http/routes/auth.js +79 -0
  59. package/dist/server/http/routes/chat.js +216 -0
  60. package/dist/server/http/routes/claude.js +69 -0
  61. package/dist/server/http/routes/codex.js +58 -0
  62. package/dist/server/http/routes/context.js +1 -0
  63. package/dist/server/http/routes/health.js +56 -0
  64. package/dist/server/http/routes/journal.js +35 -0
  65. package/dist/server/http/routes/mac.js +39 -0
  66. package/dist/server/http/routes/media.js +83 -0
  67. package/dist/server/http/routes/push.js +78 -0
  68. package/dist/server/http/routes/sessions.js +104 -0
  69. package/dist/server/http/routes/web.js +28 -0
  70. package/dist/server/http/serve.js +129 -0
  71. package/dist/server/http/sessionSocket.js +198 -0
  72. package/dist/server/ios-update/appUpdate.js +95 -0
  73. package/dist/server/ios-update/bundlePatch.js +56 -0
  74. package/dist/server/ios-update/bundlePatch.worker.js +11 -0
  75. package/dist/server/journal/db.js +243 -0
  76. package/dist/server/journal/derive.js +50 -0
  77. package/dist/server/journal/index.js +256 -0
  78. package/dist/server/journal/ingest.js +151 -0
  79. package/dist/server/notify/apns.js +126 -0
  80. package/dist/server/notify/push.js +109 -0
  81. package/dist/server/ops/awake.js +10 -0
  82. package/dist/server/ops/build.js +40 -0
  83. package/dist/server/ops/doctor.js +110 -0
  84. package/dist/server/ops/lastOp.js +34 -0
  85. package/dist/server/ops/problems.js +54 -0
  86. package/dist/server/ops/release.js +206 -0
  87. package/dist/server/ops/report.js +40 -0
  88. package/dist/server/ops/service.js +39 -0
  89. package/dist/server/ops/shutdown.js +12 -0
  90. package/dist/server/sessions/control.js +97 -0
  91. package/dist/server/sessions/events.js +77 -0
  92. package/dist/server/sessions/machine/accounts.js +55 -0
  93. package/dist/server/sessions/machine/input.js +208 -0
  94. package/dist/server/sessions/machine/lifecycle.js +357 -0
  95. package/dist/server/sessions/machine/moves.js +71 -0
  96. package/dist/server/sessions/machine/state.js +71 -0
  97. package/dist/server/sessions/machine/status.js +257 -0
  98. package/dist/server/sessions/machine/types.js +2 -0
  99. package/dist/server/sessions/machine.js +300 -0
  100. package/dist/server/sessions/outbox.js +194 -0
  101. package/dist/server/sessions/panes.js +60 -0
  102. package/dist/server/sessions/sessionName.js +7 -0
  103. package/dist/server/sessions/terminalEnv.js +40 -0
  104. package/dist/server/system/config.js +71 -0
  105. package/dist/server/system/dirs.js +54 -0
  106. package/dist/server/system/errors.js +37 -0
  107. package/dist/server/system/log.js +40 -0
  108. package/dist/server/system/messages.js +378 -0
  109. package/dist/server/system/node.js +6 -0
  110. package/dist/server/system/paths.js +51 -0
  111. package/dist/server/system/store.js +43 -0
  112. package/dist/server/system/tmux.js +198 -0
  113. package/dist/server/system/version.js +16 -0
  114. package/dist/server/uploads/fileUpload.js +171 -0
  115. package/dist/server/uploads/media.js +139 -0
  116. package/dist/web/assets/Setup-DinzDcF1.js +1 -0
  117. package/dist/web/assets/main-C8GiLoJy.css +1 -0
  118. package/dist/web/assets/main-w7i8PvU7.js +132 -0
  119. package/dist/web/icon-180.png +0 -0
  120. package/dist/web/icon-192.png +0 -0
  121. package/dist/web/icon-512.png +0 -0
  122. package/dist/web/icon.svg +6 -0
  123. package/dist/web/index.html +25 -0
  124. package/dist/web/manifest.webmanifest +15 -0
  125. package/dist/web/sw.js +8 -0
  126. package/dist/web/theme.js +11 -0
  127. package/package.json +77 -4
  128. package/scripts/smoke.mjs +130 -0
@@ -0,0 +1,104 @@
1
+ import { homedir } from 'node:os';
2
+ import { heldInBackground } from '../../agents/claude/transcript/transcripts.js';
3
+ import { listDirs } from '../../system/dirs.js';
4
+ import { Conflict } from '../../system/errors.js';
5
+ import { serveSession } from '../sessionSocket.js';
6
+ // The sessions of a machine: listed, started, renamed, ended, and the sockets that carry them live.
7
+ export const register = (app, ctx) => {
8
+ const { deps, hub, admit, machineOr404 } = ctx;
9
+ const { machine } = deps;
10
+ app.get('/api/m/:machine/sessions', async (req, reply) => {
11
+ const m = machineOr404(req, reply);
12
+ return m ? m.listSessions() : reply;
13
+ });
14
+ app.post('/api/m/:machine/sessions', async (req, reply) => {
15
+ const m = machineOr404(req, reply);
16
+ if (!m)
17
+ return reply;
18
+ const parsed = parseCreateSpec(req.body);
19
+ if (!parsed)
20
+ return reply.code(400).send({ error: 'invalid session spec' });
21
+ const { spec, takeOver } = parsed;
22
+ if (spec.kind === 'claude' && spec.resumeId && deps.claudeJobsRoot && (await heldInBackground(deps.claudeJobsRoot))(spec.resumeId)) {
23
+ throw new Conflict('this conversation is in a Claude background session and cannot be resumed here', 'background-session');
24
+ }
25
+ if (spec.kind !== 'shell' && spec.resumeId && takeOver === 'whenIdle') {
26
+ const made = await m.takeOverWhenIdle({ ...spec, resumeId: spec.resumeId });
27
+ return made ? reply.code(201).send(made) : reply.code(202).send({ waiting: true });
28
+ }
29
+ return reply.code(201).send(await m.createSession(spec.kind === 'shell' ? spec : { ...spec, takeOver: takeOver === 'now' }));
30
+ });
31
+ // Goes on with the conversation of a session whose Claude quit, in that same session.
32
+ app.post('/api/m/:machine/sessions/:id/continue', async (req, reply) => {
33
+ const m = machineOr404(req, reply);
34
+ if (!m)
35
+ return reply;
36
+ const sid = m.sessionMeta(req.params.id)?.claudeSessionId;
37
+ if (sid && deps.claudeJobsRoot && (await heldInBackground(deps.claudeJobsRoot))(sid)) {
38
+ throw new Conflict('this conversation is in a Claude background session and cannot be resumed here', 'background-session');
39
+ }
40
+ return m.continueSession(req.params.id);
41
+ });
42
+ app.get('/api/m/:machine/agents', async (req, reply) => {
43
+ const m = machineOr404(req, reply);
44
+ return m ? m.agents : reply;
45
+ });
46
+ // The folders a path typed in the new-session picker points into, only inside home.
47
+ app.get('/api/m/:machine/dirs', async (req, reply) => {
48
+ const m = machineOr404(req, reply);
49
+ return m ? listDirs(req.query.path ?? '', homedir()) : reply;
50
+ });
51
+ app.delete('/api/m/:machine/sessions/:id', async (req, reply) => {
52
+ const m = machineOr404(req, reply);
53
+ if (!m)
54
+ return reply;
55
+ await m.killSession(req.params.id);
56
+ return reply.code(204).send();
57
+ });
58
+ // A name of the session's own, for every device; an empty one goes back to the directory's name.
59
+ app.patch('/api/m/:machine/sessions/:id', async (req, reply) => {
60
+ const m = machineOr404(req, reply);
61
+ if (!m)
62
+ return reply;
63
+ const title = req.body?.title;
64
+ if (typeof title !== 'string' || title.length > 4096)
65
+ return reply.code(400).send({ error: 'title required' });
66
+ const s = await m.renameSession(req.params.id, title);
67
+ if (!s)
68
+ return reply.code(404).send({ error: 'no such session' });
69
+ return s;
70
+ });
71
+ app.get('/api/events', { websocket: true }, (socket, req) => {
72
+ if (!admit(socket, req))
73
+ return;
74
+ void hub.add(socket);
75
+ });
76
+ // What /api/events sends, for a client whose socket does not get through.
77
+ app.get('/api/events/now', async (_req, reply) => (await hub.snapshot()) ?? reply.code(503).send({ error: 'unavailable' }));
78
+ app.get('/api/m/:machine/sessions/:id/ws', { websocket: true }, (socket, req) => {
79
+ if (!admit(socket, req))
80
+ return;
81
+ if (req.params.machine !== machine.id)
82
+ return socket.close(4404);
83
+ void serveSession(socket, req.params.id, machine, deps.pusher && ((id, conn, v) => deps.pusher.setVisible(id, conn, v)), {
84
+ ingest: (id) => machine.ingest(id),
85
+ sync: (id, epoch, v) => machine.journal.sync(id, epoch, v),
86
+ onRows: (id, cb) => machine.journal.onRows(id, cb),
87
+ });
88
+ });
89
+ };
90
+ // takeOver: for a Claude or Codex conversation open in a terminal on the Mac, quit it there now, or once it is idle.
91
+ function parseCreateSpec(body) {
92
+ if (!body || typeof body !== 'object')
93
+ return null;
94
+ const b = body;
95
+ const str = (v) => (typeof v === 'string' && v.length > 0 && v.length < 4096 ? v : undefined);
96
+ if (b.takeOver !== undefined && ((b.kind !== 'claude' && b.kind !== 'codex') || (b.takeOver !== 'now' && b.takeOver !== 'whenIdle')))
97
+ return null;
98
+ if (b.kind === 'shell')
99
+ return { spec: { kind: 'shell', cwd: str(b.cwd), title: str(b.title) } };
100
+ if ((b.kind === 'claude' || b.kind === 'codex') && str(b.cwd)) {
101
+ return { spec: { kind: b.kind, cwd: str(b.cwd), resumeId: str(b.resumeId), title: str(b.title) }, takeOver: b.takeOver };
102
+ }
103
+ return null;
104
+ }
@@ -0,0 +1,28 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { join, sep } from 'node:path';
3
+ import fastifyStatic from '@fastify/static';
4
+ // The phone app itself: its files, and index.html for any page it routes to. Registered last, so it
5
+ // answers only what no other route does.
6
+ export const register = async (app, ctx) => {
7
+ const webDir = ctx.deps.webDir;
8
+ const hasWeb = !!webDir && existsSync(join(webDir, 'index.html'));
9
+ await app.register(async (scope) => {
10
+ scope.addHook('onRoute', (route) => {
11
+ route.config = { ...route.config, public: true };
12
+ });
13
+ // The build names what it puts in assets/ after its content, so a name never stands for another file.
14
+ const built = join(webDir ?? '', 'assets') + sep;
15
+ const setHeaders = (reply, path) => {
16
+ if (path.startsWith(built))
17
+ reply.header('cache-control', 'public, max-age=31536000, immutable');
18
+ };
19
+ if (hasWeb)
20
+ await scope.register(fastifyStatic, { root: webDir, index: false, wildcard: false, setHeaders });
21
+ scope.setNotFoundHandler((req, reply) => {
22
+ if (req.url.startsWith('/api/') || !hasWeb || req.method !== 'GET') {
23
+ return reply.code(404).send({ error: 'not found' });
24
+ }
25
+ return reply.type('text/html; charset=utf-8').header('cache-control', 'no-cache').sendFile('index.html', { cacheControl: false });
26
+ });
27
+ });
28
+ };
@@ -0,0 +1,129 @@
1
+ import { accessSync, constants, mkdirSync } from 'node:fs';
2
+ import { homedir, userInfo } from 'node:os';
3
+ import { delimiter, join } from 'node:path';
4
+ import webpush from 'web-push';
5
+ import { buildApp } from './app.js';
6
+ import { registerPasskeyRoutes } from './auth/passkey.js';
7
+ import { startControlServer } from '../sessions/control.js';
8
+ import { startHookServer, userStatusLine, writeClaudeSettings } from '../agents/claude/hooks/hooks.js';
9
+ import { usageFrom } from '../agents/claude/usage.js';
10
+ import { ClaudeAccounts } from '../agents/claude/accounts.js';
11
+ import { createHookHandler, createPermissionHandler } from '../agents/claude/hooks/tracker.js';
12
+ import { createLogger } from '../system/log.js';
13
+ import { LocalMachine } from '../sessions/machine.js';
14
+ import { socketPath, tmuxSocketFor } from '../system/paths.js';
15
+ import { Apns, loadApnsKey } from '../notify/apns.js';
16
+ import { Pusher } from '../notify/push.js';
17
+ import { Store } from '../system/store.js';
18
+ import { Tmux, writeTmuxConf } from '../system/tmux.js';
19
+ export class NotSetUp extends Error {
20
+ }
21
+ const isExecutable = (f) => {
22
+ try {
23
+ accessSync(f, constants.X_OK);
24
+ return true;
25
+ }
26
+ catch {
27
+ return false;
28
+ }
29
+ };
30
+ // npx and pnpm put node_modules/.bin first on PATH, where a stale npm-installed
31
+ // claude can shadow the real one; the official installer puts it in ~/.local/bin.
32
+ export function resolveClaude(env = process.env, userHome = homedir()) {
33
+ const dirs = [join(userHome, '.local/bin'), ...(env.PATH ?? '').split(delimiter).filter((d) => d && !d.includes('node_modules'))];
34
+ return dirs.map((d) => join(d, 'claude')).find(isExecutable) ?? 'claude';
35
+ }
36
+ // Codex's CLI where its installer or npm puts it; null when it is not installed.
37
+ export function resolveCodex(env = process.env, userHome = homedir()) {
38
+ const dirs = [join(userHome, '.local/bin'), ...(env.PATH ?? '').split(delimiter).filter((d) => d && !d.includes('node_modules'))];
39
+ return dirs.map((d) => join(d, 'codex')).find(isExecutable) ?? null;
40
+ }
41
+ export async function startServer(o) {
42
+ const store = new Store(join(o.home, 'state.json'));
43
+ if (!store.get().user)
44
+ throw new NotSetUp();
45
+ mkdirSync(join(o.home, 'logs'), { recursive: true });
46
+ if (!store.get().vapid) {
47
+ const keys = webpush.generateVAPIDKeys();
48
+ store.update((s) => {
49
+ s.vapid = { publicKey: keys.publicKey, privateKey: keys.privateKey };
50
+ });
51
+ }
52
+ const conf = join(o.home, 'tmux.conf');
53
+ writeTmuxConf(conf);
54
+ const tmux = new Tmux({ socket: o.tmuxSocket ?? tmuxSocketFor(o.home), conf });
55
+ const claudeSettingsPath = join(o.home, 'claude-settings.json');
56
+ const hookSock = socketPath(o.home, 'hooks.sock');
57
+ writeClaudeSettings(claudeSettingsPath, hookSock, userStatusLine(join(o.claudeDir ?? join(homedir(), '.claude'), 'settings.json')));
58
+ const claudeBin = o.claudeBin ?? resolveClaude();
59
+ const codexBin = o.codexBin === undefined ? resolveCodex() : o.codexBin;
60
+ const logger = o.logger ?? createLogger({ file: join(o.home, 'logs/workoffdesk.log') });
61
+ const machine = new LocalMachine({
62
+ tmux,
63
+ store,
64
+ claudeSettingsPath,
65
+ claudeBin,
66
+ claudeArgs: o.config.claudeArgs,
67
+ claudeProjectsRoot: o.claudeProjectsRoot ?? join(homedir(), '.claude/projects'),
68
+ claudeAccounts: o.claudeAccounts ?? new ClaudeAccounts({ home: homedir(), projectsRoot: o.claudeProjectsRoot ?? join(homedir(), '.claude/projects'), own: process.env.CLAUDE_CONFIG_DIR || undefined }),
69
+ shell: process.env.SHELL || userInfo().shell || '/bin/zsh',
70
+ journal: { file: join(o.home, 'journal.db'), log: (msg, fields) => logger.info(fields, msg) },
71
+ codex: codexBin ? { bin: codexBin, sock: socketPath(o.home, 'codex.sock'), home: process.env.CODEX_HOME || join(homedir(), '.codex') } : undefined,
72
+ });
73
+ // Push services want a contact URL; Apple rejects anything but https: or mailto:.
74
+ const subject = o.config.origin.startsWith('https://') ? o.config.origin : 'mailto:workoffdesk@localhost';
75
+ const apnsKey = loadApnsKey(o.home);
76
+ const pusher = new Pusher(store, { subject, send: o.pushSend, apns: apnsKey ? new Apns(apnsKey, o.apnsPost) : undefined });
77
+ machine.setNotifier(pusher);
78
+ const hooks = await startHookServer(hookSock, createHookHandler(machine, pusher), (id, payload) => machine.setUsage(id, usageFrom(payload)), createPermissionHandler(machine, pusher));
79
+ const control = await startControlServer(socketPath(o.home, 'control.sock'), {
80
+ open: async ({ kind, env, ...r }) => (await machine.createSession(kind === 'codex' ? { kind, ...r } : { kind: 'claude', env, ...r })).id,
81
+ accounts: {
82
+ view: () => machine.claudeAccountsView(),
83
+ pick: async (id) => {
84
+ const view = await machine.pickClaudeAccount(id);
85
+ logger.info({ account: id, from: 'cli' }, 'claude account picked');
86
+ return view;
87
+ },
88
+ move: async () => {
89
+ const view = await machine.moveToAccountInUse();
90
+ logger.info({ moving: view.moving, from: 'cli' }, 'open sessions moving to the claude account in use');
91
+ return view;
92
+ },
93
+ },
94
+ });
95
+ const stopWatch = machine.watch(o.watchMs ?? 2000);
96
+ const { app } = await buildApp({
97
+ logger,
98
+ store,
99
+ machine,
100
+ origin: o.config.origin,
101
+ rpId: o.config.rpId,
102
+ auditPath: join(o.home, 'logs/audit.log'),
103
+ webDir: o.webDir,
104
+ pusher,
105
+ claudeProjectsRoot: o.claudeProjectsRoot ?? join(homedir(), '.claude/projects'),
106
+ claudeJobsRoot: o.claudeJobsRoot ?? join(homedir(), '.claude/jobs'),
107
+ claudeDir: o.claudeDir ?? join(homedir(), '.claude'),
108
+ claudeBin,
109
+ pinnedDirs: o.config.pinnedDirs,
110
+ media: { uploadsDir: join(o.home, 'uploads') },
111
+ appUpdate: o.appUpdate && { ...o.appUpdate, bundlesDir: join(o.home, 'app-bundles') },
112
+ ops: o.ops,
113
+ extend: (a, ctx) => registerPasskeyRoutes(a, ctx, { store, origin: o.config.origin, rpId: o.config.rpId }),
114
+ });
115
+ return {
116
+ app,
117
+ tmux,
118
+ // The app first, so the phones hear 4012 before anything else stops.
119
+ close: async () => {
120
+ await app.close();
121
+ stopWatch();
122
+ hooks.close();
123
+ // Prompts held for the phone go back to the Mac's dialog alone.
124
+ hooks.closeAllConnections();
125
+ control.close();
126
+ machine.closeJournal();
127
+ },
128
+ };
129
+ }
@@ -0,0 +1,198 @@
1
+ // How often the screen is read while the page is looked at; what changed on it is sent at once.
2
+ const SCREEN_MS = 250;
3
+ // Said at least this often while the page is looked at, so the page can tell a quiet line from a dead one.
4
+ const ALIVE_MS = 4000;
5
+ // How often the whole screen is read while it is watched as text; it is long, so less often than the chat's
6
+ // part, and sooner after a key.
7
+ const SCREEN_TEXT_MS = 1000;
8
+ const KEY_SHOWS_MS = 300;
9
+ let connSeq = 0;
10
+ // A phone's line to one session: what is typed goes to the program, the page says whether it is being
11
+ // looked at, and the line closes when the session ends. While the page is looked at, it tells the page what
12
+ // the screen holds for the chat each time that changes ({type:'screen'}), so the page need not keep asking.
13
+ // Asked ({type:'screen'}), it sends what the screen holds even if it sent it already; while the screen is watched as text ({type:'screenText', on}), it sends the text, with
14
+ // its history, each time that changes. A page that says where its copy of the journal is
15
+ // ({type:'journal', epoch, v}) is sent what it is missing, then each change ({type:'journal', from, v, rows}).
16
+ export async function serveSession(socket, id, machine, onVisibility, journal) {
17
+ const connId = String(++connSeq);
18
+ // Listeners go on before the first await so nothing the client sends early is lost.
19
+ const early = [];
20
+ let handle = null;
21
+ let closed = false;
22
+ let cleanup = () => { };
23
+ socket.on('message', (data, isBinary) => (handle ? handle(data, isBinary) : early.push([data, isBinary])));
24
+ socket.on('close', () => {
25
+ closed = true;
26
+ onVisibility?.(id, connId, false);
27
+ cleanup();
28
+ });
29
+ const ended = () => {
30
+ if (socket.readyState === socket.OPEN)
31
+ socket.send(JSON.stringify({ type: 'exit' }));
32
+ };
33
+ if (!(await machine.exists(id))) {
34
+ ended();
35
+ socket.close(4404, 'no such session');
36
+ return;
37
+ }
38
+ if (closed)
39
+ return;
40
+ await machine.fitForChat(id).catch(() => { });
41
+ if (closed)
42
+ return;
43
+ let lastSent = 0;
44
+ const send = (msg) => {
45
+ if (socket.readyState !== socket.OPEN)
46
+ return;
47
+ socket.send(JSON.stringify(msg));
48
+ lastSent = Date.now();
49
+ };
50
+ const ping = setInterval(() => socket.ping(), 25_000);
51
+ const unwatch = machine.onChange(async () => {
52
+ if (closed || (await machine.exists(id).catch(() => true)))
53
+ return;
54
+ ended();
55
+ socket.close(1000);
56
+ });
57
+ // Where the page's copy of the journal is, once it has said and been caught up; a page behind a change
58
+ // is sent nothing until it says where it is again.
59
+ let journalAt = null;
60
+ const unwatchJournal = journal?.onRows(id, (from, to, rows) => {
61
+ if (journalAt === null || journalAt < from || journalAt >= to)
62
+ return;
63
+ const at = journalAt;
64
+ send({ type: 'journal', from: at, v: to, rows: rows.filter((r) => r.v > at) });
65
+ journalAt = to;
66
+ }) ?? (() => { });
67
+ const catchUp = async (epoch, v) => {
68
+ if (!journal)
69
+ return;
70
+ journalAt = null;
71
+ await journal.ingest(id).catch(() => { });
72
+ if (closed)
73
+ return;
74
+ let from = v;
75
+ let sync = journal.sync(id, epoch, v);
76
+ send({ type: 'journal', from, ...sync });
77
+ while (sync.more) {
78
+ from = sync.v;
79
+ sync = journal.sync(id, sync.epoch, from);
80
+ send({ type: 'journal', from, ...sync });
81
+ }
82
+ journalAt = sync.v;
83
+ };
84
+ // One reading at a time, only while the page is looked at.
85
+ let visible = false;
86
+ let reading = false;
87
+ let lastScreen = '';
88
+ let screenTimer;
89
+ const readScreen = async () => {
90
+ if (closed || !visible || reading)
91
+ return;
92
+ reading = true;
93
+ try {
94
+ const screen = await machine.onScreen(id);
95
+ const now = JSON.stringify(screen);
96
+ if (now !== lastScreen) {
97
+ lastScreen = now;
98
+ send({ type: 'screen', screen });
99
+ }
100
+ else if (Date.now() - lastSent >= ALIVE_MS)
101
+ send({ type: 'alive' });
102
+ }
103
+ catch {
104
+ // the next reading tries again
105
+ }
106
+ finally {
107
+ reading = false;
108
+ }
109
+ if (!closed && visible)
110
+ screenTimer = setTimeout(readScreen, SCREEN_MS);
111
+ };
112
+ // The same for the screen as text, while it is watched.
113
+ let textOn = false;
114
+ let readingText = false;
115
+ let textAgain = false;
116
+ let lastText = null;
117
+ let textTimer;
118
+ const readText = async () => {
119
+ clearTimeout(textTimer);
120
+ if (closed || !textOn)
121
+ return;
122
+ if (readingText) {
123
+ textAgain = true;
124
+ return;
125
+ }
126
+ readingText = true;
127
+ textAgain = false;
128
+ try {
129
+ const text = await machine.screenText(id);
130
+ if (text !== lastText && textOn) {
131
+ lastText = text;
132
+ send({ type: 'screenText', text });
133
+ }
134
+ }
135
+ catch {
136
+ // the next reading tries again
137
+ }
138
+ finally {
139
+ readingText = false;
140
+ }
141
+ if (!closed && textOn)
142
+ textTimer = setTimeout(readText, textAgain ? 0 : SCREEN_TEXT_MS);
143
+ };
144
+ const textSoon = () => {
145
+ if (!textOn)
146
+ return;
147
+ clearTimeout(textTimer);
148
+ textTimer = setTimeout(readText, KEY_SHOWS_MS);
149
+ };
150
+ cleanup = () => {
151
+ clearInterval(ping);
152
+ clearTimeout(screenTimer);
153
+ clearTimeout(textTimer);
154
+ unwatch();
155
+ unwatchJournal();
156
+ };
157
+ let input = Promise.resolve();
158
+ handle = (data, isBinary) => {
159
+ if (isBinary) {
160
+ const text = data.toString('utf8');
161
+ input = input.then(() => (closed ? undefined : machine.type(id, text).then(textSoon, () => { })));
162
+ return;
163
+ }
164
+ let msg;
165
+ try {
166
+ msg = JSON.parse(data.toString('utf8'));
167
+ }
168
+ catch {
169
+ return;
170
+ }
171
+ if (msg?.type === 'journal' && Number.isSafeInteger(msg.v) && msg.v >= 0) {
172
+ void catchUp(typeof msg.epoch === 'string' ? msg.epoch : undefined, msg.v);
173
+ return;
174
+ }
175
+ if (msg?.type === 'screen') {
176
+ lastScreen = '';
177
+ void readScreen();
178
+ return;
179
+ }
180
+ if (msg?.type === 'screenText') {
181
+ textOn = !!msg.on;
182
+ lastText = null;
183
+ void readText();
184
+ return;
185
+ }
186
+ if (msg?.type === 'visible') {
187
+ onVisibility?.(id, connId, !!msg.visible);
188
+ visible = !!msg.visible;
189
+ clearTimeout(screenTimer);
190
+ // What the page had may be stale after it was away: the next reading is sent whatever it holds.
191
+ lastScreen = '';
192
+ if (visible)
193
+ void readScreen();
194
+ }
195
+ };
196
+ for (const [d, b] of early.splice(0))
197
+ handle(d, b);
198
+ }
@@ -0,0 +1,95 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { copyFileSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
3
+ import { basename, extname, join } from 'node:path';
4
+ // The iPhone app's JavaScript for the native runtime this build was made with, as the server serves it
5
+ // over the expo-updates protocol. The build writes it once (writeAppUpdate); update.json goes last, so a
6
+ // folder with one is complete.
7
+ export const UPDATE_FILE = 'update.json';
8
+ // The form expo-updates checks a download against: base64url SHA-256, no padding.
9
+ export const hashOf = (bytes) => createHash('sha256').update(bytes).digest('base64url');
10
+ const CONTENT_TYPES = {
11
+ png: 'image/png',
12
+ jpg: 'image/jpeg',
13
+ jpeg: 'image/jpeg',
14
+ gif: 'image/gif',
15
+ webp: 'image/webp',
16
+ svg: 'image/svg+xml',
17
+ ttf: 'font/ttf',
18
+ otf: 'font/otf',
19
+ json: 'application/json',
20
+ mp3: 'audio/mpeg',
21
+ wav: 'audio/wav',
22
+ mp4: 'video/mp4',
23
+ };
24
+ // From what `expo export --platform ios` left in exportDir to a served update in outDir. Assets keep the
25
+ // key Metro gave them (their content hash), which is also their key in the binary's own update, so the
26
+ // phone copies rather than downloads the ones it has; the bundle is keyed by its SHA-256.
27
+ export function writeAppUpdate(exportDir, outDir, meta) {
28
+ rmSync(outDir, { recursive: true, force: true });
29
+ const ios = JSON.parse(readFileSync(join(exportDir, 'metadata.json'), 'utf8')).fileMetadata?.ios;
30
+ if (!ios?.bundle)
31
+ throw new Error(`no iOS bundle in ${join(exportDir, 'metadata.json')}`);
32
+ mkdirSync(outDir, { recursive: true });
33
+ const bundle = readFileSync(join(exportDir, ios.bundle));
34
+ const bundleKey = createHash('sha256').update(bundle).digest('hex');
35
+ const launchAsset = { file: `${bundleKey}${extname(ios.bundle)}`, key: bundleKey, hash: hashOf(bundle) };
36
+ writeFileSync(join(outDir, launchAsset.file), bundle);
37
+ const assets = (ios.assets ?? []).map(({ path, ext }) => {
38
+ const key = basename(path);
39
+ const file = `${key}.${ext}`;
40
+ copyFileSync(join(exportDir, path), join(outDir, file));
41
+ return { file, key, hash: hashOf(readFileSync(join(outDir, file))), fileExtension: `.${ext}`, contentType: CONTENT_TYPES[ext.toLowerCase()] ?? 'application/octet-stream' };
42
+ });
43
+ const update = { ...meta, launchAsset, assets };
44
+ writeFileSync(join(outDir, UPDATE_FILE), JSON.stringify(update, null, 2));
45
+ }
46
+ const isText = (v) => typeof v === 'string' && v !== '';
47
+ const isFile = (v) => {
48
+ const f = v;
49
+ return !!f && isText(f.file) && isText(f.key) && isText(f.hash);
50
+ };
51
+ // The update a build carries, with every file read and checked against its hash, so nothing is offered
52
+ // that a phone could not download whole. Null when the build has none; throws when it has a broken one.
53
+ export function loadAppUpdate(dir) {
54
+ let raw;
55
+ try {
56
+ raw = readFileSync(join(dir, UPDATE_FILE), 'utf8');
57
+ }
58
+ catch (e) {
59
+ if (e.code === 'ENOENT')
60
+ return null;
61
+ throw e;
62
+ }
63
+ const build = JSON.parse(raw);
64
+ const assets = Array.isArray(build.assets) ? build.assets : [];
65
+ if (!isText(build.runtimeVersion) || !isText(build.builtAt) || typeof build.expoClient !== 'object' || !build.expoClient || !isFile(build.launchAsset) || !Array.isArray(build.assets) || !assets.every((a) => isFile(a) && isText(a.fileExtension) && isText(a.contentType))) {
66
+ throw new Error(`${UPDATE_FILE} is not an update`);
67
+ }
68
+ const files = new Map();
69
+ for (const { file, hash, contentType } of [{ ...build.launchAsset, contentType: 'application/javascript' }, ...build.assets]) {
70
+ const bytes = readFileSync(join(dir, basename(file)));
71
+ if (hashOf(bytes) !== hash)
72
+ throw new Error(`${file} does not match its hash`);
73
+ files.set(file, { bytes, contentType });
74
+ }
75
+ return { build, files };
76
+ }
77
+ // expo-updates takes an update id as immutable: the same id must always mean the same manifest. So it is
78
+ // derived from all that changes one, and nothing else, which keeps it across restarts.
79
+ export function updateId(runtimeVersion, launchHash, createdAt) {
80
+ const h = createHash('sha256').update(`${runtimeVersion}\n${launchHash}\n${createdAt}`).digest('hex');
81
+ const variant = ((parseInt(h[16], 16) & 0x3) | 0x8).toString(16);
82
+ return `${h.slice(0, 8)}-${h.slice(8, 12)}-8${h.slice(13, 16)}-${variant}${h.slice(17, 20)}-${h.slice(20, 32)}`;
83
+ }
84
+ export function manifestOf(build, createdAt, filesBase) {
85
+ const url = (file) => `${filesBase}/${encodeURIComponent(file)}`;
86
+ return {
87
+ id: updateId(build.runtimeVersion, build.launchAsset.hash, createdAt),
88
+ createdAt,
89
+ runtimeVersion: build.runtimeVersion,
90
+ launchAsset: { key: build.launchAsset.key, hash: build.launchAsset.hash, url: url(build.launchAsset.file), contentType: 'application/javascript' },
91
+ assets: build.assets.map((a) => ({ key: a.key, hash: a.hash, url: url(a.file), fileExtension: a.fileExtension, contentType: a.contentType })),
92
+ metadata: {},
93
+ extra: { expoClient: build.expoClient },
94
+ };
95
+ }
@@ -0,0 +1,56 @@
1
+ import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
2
+ import { extname, join } from 'node:path';
3
+ import { Worker } from 'node:worker_threads';
4
+ // The bundles this server has offered the iPhone app, by update id. The app names the update it runs on
5
+ // every download, so the next bundle can go to it as a bsdiff patch against that one: a few KB to a few
6
+ // hundred, where the whole bundle is 1.9 MB over a line that drops long downloads.
7
+ const KEEP = 20;
8
+ const UPDATE_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
9
+ const fileOf = (dir, id) => join(dir, `${id}.bundle`);
10
+ // Next to this module, run as it is: compiled, or as TypeScript when the tests import the source.
11
+ const WORKER = new URL(`./bundlePatch.worker${extname(new URL(import.meta.url).pathname)}`, import.meta.url);
12
+ export function keepBundle(dir, id, bytes) {
13
+ mkdirSync(dir, { recursive: true });
14
+ const file = fileOf(dir, id);
15
+ if (existsSync(file))
16
+ return;
17
+ writeFileSync(`${file}.tmp`, bytes);
18
+ renameSync(`${file}.tmp`, file);
19
+ const kept = readdirSync(dir)
20
+ .filter((n) => n.endsWith('.bundle'))
21
+ .map((n) => ({ n, at: statSync(join(dir, n)).mtimeMs }))
22
+ .sort((a, b) => b.at - a.at);
23
+ for (const { n } of kept.slice(KEEP))
24
+ rmSync(join(dir, n), { force: true });
25
+ }
26
+ function bsdiff(base, target) {
27
+ return new Promise((resolve, reject) => {
28
+ const worker = new Worker(WORKER, { workerData: { base, target } });
29
+ worker.once('message', (patch) => resolve(Buffer.from(patch.buffer, patch.byteOffset, patch.byteLength)));
30
+ worker.once('error', reject);
31
+ worker.once('exit', (code) => reject(new Error(`bsdiff worker exited with ${code}`)));
32
+ });
33
+ }
34
+ // The patch from the bundle `baseId` names to `target`, made once per base; null when this server never
35
+ // kept that bundle, in which case the app downloads the whole one.
36
+ export function bundlePatcher(dir, target) {
37
+ const made = new Map();
38
+ return (baseId) => {
39
+ const id = baseId.toLowerCase();
40
+ if (!UPDATE_ID.test(id))
41
+ return Promise.resolve(null);
42
+ let patch = made.get(id);
43
+ if (!patch) {
44
+ let base;
45
+ try {
46
+ base = readFileSync(fileOf(dir, id));
47
+ }
48
+ catch {
49
+ return Promise.resolve(null);
50
+ }
51
+ patch = bsdiff(base, target);
52
+ made.set(id, patch);
53
+ }
54
+ return patch;
55
+ };
56
+ }
@@ -0,0 +1,11 @@
1
+ import { parentPort, workerData } from 'node:worker_threads';
2
+ import { loadBsdiff } from 'bsdiff-wasm';
3
+ // bsdiff takes about a second of CPU on a 4.5 MB bundle, so it runs here rather than on the server's thread.
4
+ const { base, target } = workerData;
5
+ const bsdiff = await loadBsdiff();
6
+ bsdiff.FS.writeFile('/base', base);
7
+ bsdiff.FS.writeFile('/target', target);
8
+ const code = bsdiff.callMain(['/base', '/target', '/patch']);
9
+ if (code !== 0)
10
+ throw new Error(`bsdiff exited with ${code}`);
11
+ parentPort.postMessage(bsdiff.FS.readFile('/patch'));