agentbox-app 0.1.13 → 0.1.16
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +24 -9
- package/bin/agentbox.mjs +23 -1
- package/briefs/message-rules.md +36 -8
- package/briefs/worker.md +46 -23
- package/main/account-discovery.mjs +122 -30
- package/main/account-tooling.mjs +29 -1
- package/main/account-usage.mjs +46 -0
- package/main/agent-sessions.mjs +282 -268
- package/main/agents.mjs +179 -466
- package/main/analytics.mjs +12 -2
- package/main/answer-modes.mjs +103 -37
- package/main/approval-prompt-core.mjs +197 -0
- package/main/approval-prompt-server.mjs +4 -138
- package/main/approvals.mjs +101 -297
- package/main/artifact-path.mjs +47 -18
- package/main/claude-input.mjs +48 -4
- package/main/claude-plan.mjs +48 -0
- package/main/claude-session-open.mjs +28 -0
- package/main/claude-usage-process.mjs +40 -0
- package/main/claude-usage.mjs +18 -40
- package/main/cloud/account.mjs +179 -0
- package/main/cloud/aws.mjs +127 -0
- package/main/cloud/browser-return.mjs +58 -0
- package/main/cloud/clients.mjs +15 -0
- package/main/cloud/codex-cloud.mjs +53 -0
- package/main/cloud/config.mjs +107 -0
- package/main/cloud/docs.mjs +219 -0
- package/main/cloud/exec.mjs +27 -0
- package/main/cloud/host.mjs +165 -0
- package/main/cloud/kit.mjs +80 -0
- package/main/cloud/logins.mjs +543 -0
- package/main/cloud/managed.mjs +115 -0
- package/main/cloud/marker.mjs +104 -0
- package/main/cloud/org.mjs +53 -0
- package/main/cloud/reuse.mjs +208 -0
- package/main/cloud/run-cli.mjs +177 -0
- package/main/cloud/run.mjs +1934 -0
- package/main/cloud/usage.mjs +76 -0
- package/main/cloud-permission.mjs +51 -0
- package/main/codex-account.mjs +78 -0
- package/main/codex-app-server.mjs +276 -409
- package/main/codex-approvals.mjs +148 -433
- package/main/codex-models.mjs +20 -2
- package/main/codex-session.mjs +212 -507
- package/main/codex-threads-worker.mjs +12 -0
- package/main/codex-threads.mjs +71 -3
- package/main/codex-usage-file.mjs +5 -3
- package/main/codex-usage.mjs +56 -7
- package/main/codex-watch.mjs +17 -3
- package/main/codex.mjs +95 -363
- package/main/config.mjs +11 -1
- package/main/corner-tag.mjs +340 -19
- package/main/dev-window.mjs +1 -1
- package/main/engine-bin.mjs +232 -282
- package/main/engine-setup.mjs +21 -9
- package/main/feedback.mjs +3 -25
- package/main/folder-pictures.mjs +2 -2
- package/main/freeze-recorder-thread.mjs +86 -0
- package/main/freeze-recorder.mjs +146 -0
- package/main/fresh-user.mjs +20 -9
- package/main/git-change-offthread.mjs +17 -3
- package/main/git-change-thread.mjs +5 -2
- package/main/harnesses/claude.mjs +32 -11
- package/main/harnesses/codex.mjs +9 -2
- package/main/importable-threads.mjs +64 -6
- package/main/ipc.mjs +362 -111
- package/main/leftovers.mjs +117 -25
- package/main/liquid-glass.mjs +26 -0
- package/main/live-replies.mjs +10 -4
- package/main/main-went-red.mjs +330 -0
- package/main/main.mjs +199 -84
- package/main/memory-gate-history.mjs +36 -10
- package/main/memory-gate-server.mjs +62 -0
- package/main/memory-gate.mjs +14 -1
- package/main/menu.mjs +4 -2
- package/main/message-priority.mjs +2 -2
- package/main/mic.mjs +38 -0
- package/main/native/liquid-glass/build/liquid-glass.node +0 -0
- package/main/new-chats-watch.mjs +110 -0
- package/main/new-user-window.mjs +25 -0
- package/main/notify.mjs +24 -8
- package/main/phone-alerts.mjs +117 -0
- package/main/phone-inbox.mjs +625 -0
- package/main/phone-link.mjs +29 -9
- package/main/phone-uploads.mjs +476 -0
- package/main/picture-shape.mjs +114 -0
- package/main/quit-sequence.mjs +143 -0
- package/main/refresh-agent-models.mjs +15 -2
- package/main/relay-client.mjs +69 -1
- package/main/repeats.mjs +10 -4
- package/main/row-label.mjs +12 -41
- package/main/run-change.mjs +13 -0
- package/main/run-ledger.mjs +135 -0
- package/main/save-attachments.mjs +41 -0
- package/main/serve.mjs +45 -3
- package/main/settings.mjs +220 -44
- package/main/shell-path.mjs +185 -37
- package/main/ship-conflict-brief.mjs +119 -0
- package/main/ship-queue.mjs +115 -19
- package/main/source-updater.mjs +18 -10
- package/main/stage-attachment.mjs +68 -0
- package/main/stall-watch.mjs +50 -0
- package/main/store/home.mjs +87 -232
- package/main/store/paths.mjs +11 -0
- package/main/store/teammates-files.mjs +99 -0
- package/main/store/work-items.mjs +231 -13
- package/main/store.mjs +226 -52
- package/main/supervisor.mjs +3103 -341
- package/main/task-folders.mjs +430 -30
- package/main/task-terminals.mjs +110 -34
- package/main/team/index.mjs +114 -10
- package/main/team/memory-cloud.mjs +183 -1
- package/main/team/room-hours.mjs +48 -0
- package/main/team/room-setup.mjs +31 -0
- package/main/team/rooms-memory.mjs +454 -0
- package/main/team/rooms-shape.mjs +42 -0
- package/main/team/rooms-supabase.mjs +324 -0
- package/main/team/rooms.mjs +575 -0
- package/main/team/session.mjs +96 -26
- package/main/team/supabase-backend.mjs +180 -0
- package/main/team/sync.mjs +109 -1
- package/main/teammates.mjs +580 -0
- package/main/terminal-host.mjs +143 -0
- package/main/terminal-hosts.mjs +261 -0
- package/main/transcript-search.mjs +80 -0
- package/main/webview-guard.mjs +45 -0
- package/main/worktree-include.mjs +90 -219
- package/mcp/core/documents.mjs +3 -1
- package/mcp/core/repeats.mjs +88 -0
- package/mcp/core/teammates.mjs +222 -0
- package/mcp/core/work.mjs +76 -4
- package/mcp/stdio.mjs +24 -11
- package/mcp/tools.mjs +116 -8
- package/package.json +21 -5
- package/renderer/dist/assets/{abnfDiagram-YUKMZFIV-DPPuMWt-.js → abnfDiagram-YUKMZFIV-CoWB6YUK.js} +1 -1
- package/renderer/dist/assets/{arc-HmFfdseH.js → arc-Ch0NrCul.js} +1 -1
- package/renderer/dist/assets/{architectureDiagram-47P4ROYG-DxJ3zHrH.js → architectureDiagram-47P4ROYG-CXz9M5tQ.js} +1 -1
- package/renderer/dist/assets/{blockDiagram-E7TT5TSB-CbTXzOqQ.js → blockDiagram-E7TT5TSB-DchGUuHH.js} +1 -1
- package/renderer/dist/assets/{c4Diagram-CRA5TL53-X5ItFyLn.js → c4Diagram-CRA5TL53-Cip-zNyr.js} +1 -1
- package/renderer/dist/assets/channel-BQuQSL--.js +1 -0
- package/renderer/dist/assets/{chunk-2Q5K7J3B-DC12Ho_L.js → chunk-2Q5K7J3B-BoXid_2r.js} +1 -1
- package/renderer/dist/assets/{chunk-5VM5RSS4-ClVtfPd0.js → chunk-5VM5RSS4-Cpez1rRz.js} +1 -1
- package/renderer/dist/assets/{chunk-7TKQ45FW-DgcOm0HT.js → chunk-7TKQ45FW-DKA84YkE.js} +1 -1
- package/renderer/dist/assets/{chunk-AW2ZBBNX-C01s4yoF.js → chunk-AW2ZBBNX-Dg-SumPG.js} +1 -1
- package/renderer/dist/assets/{chunk-FVRAUYC3-Bj0zxhR4.js → chunk-FVRAUYC3-06TCvPm1.js} +2 -2
- package/renderer/dist/assets/{chunk-HTAEGDNF-DWDeEhLU.js → chunk-HTAEGDNF-CMEBy1Td.js} +1 -1
- package/renderer/dist/assets/{chunk-JWPE2WC7-Dsqwv3eW.js → chunk-JWPE2WC7-BJ2ObmV-.js} +1 -1
- package/renderer/dist/assets/{chunk-KQW6MTUR-CE86ANL2.js → chunk-KQW6MTUR-CGWOYXUE.js} +1 -1
- package/renderer/dist/assets/{chunk-XXDRQBXY-CoNTASfs.js → chunk-XXDRQBXY-CiZp07XC.js} +1 -1
- package/renderer/dist/assets/{classDiagram-v2-K4WV4PDN-2wprLqtw.js → classDiagram-v2-K4WV4PDN-DJNIX719.js} +1 -1
- package/renderer/dist/assets/cornerTag-CPgvu-q3.js +1 -0
- package/renderer/dist/assets/cornerTag-oMfEjzCO.css +1 -0
- package/renderer/dist/assets/{cose-bilkent-JH36ORCC-enqMiYbn.js → cose-bilkent-JH36ORCC-CbkyZOUH.js} +1 -1
- package/renderer/dist/assets/{cynefin-EF2NZ3EQ-DHngLxvQ.js → cynefin-EF2NZ3EQ-78iOroZO.js} +1 -1
- package/renderer/dist/assets/{cynefinDiagram-3GCD6N5R-C0rZZIl3.js → cynefinDiagram-3GCD6N5R-GcCopMs7.js} +1 -1
- package/renderer/dist/assets/{dagre-W4DXFKR2-DQnaEWO7.js → dagre-W4DXFKR2-DHLlXLkz.js} +1 -1
- package/renderer/dist/assets/{diagram-2UJZ2QOL-CS8AlGPx.js → diagram-2UJZ2QOL-DuZSUHcH.js} +1 -1
- package/renderer/dist/assets/{diagram-OVF4WLC6-yiDMHIkY.js → diagram-OVF4WLC6-Ckxu3cqH.js} +1 -1
- package/renderer/dist/assets/{diagram-PFPMY2P6-XbQKzbBA.js → diagram-PFPMY2P6-CsTw9Ycn.js} +1 -1
- package/renderer/dist/assets/{diagram-UMYRVEAY-dhVvlfl_.js → diagram-UMYRVEAY-CTXd8qVg.js} +1 -1
- package/renderer/dist/assets/{diagram-YEKJPTXX-DZHe8SCf.js → diagram-YEKJPTXX-CJs1T_9m.js} +1 -1
- package/renderer/dist/assets/{diagram-ZIFT7M5P-B5h6aXVR.js → diagram-ZIFT7M5P-CvjI23mf.js} +1 -1
- package/renderer/dist/assets/{ebnfDiagram-VR2GEFS7-PR9ScDco.js → ebnfDiagram-VR2GEFS7-d_IeAzOJ.js} +1 -1
- package/renderer/dist/assets/{elk-IJKZMXRS-Cg-yQXPd.js → elk-IJKZMXRS-zo9KFP3N.js} +1 -1
- package/renderer/dist/assets/{erDiagram-O2IAPWRE-omOrJMxu.js → erDiagram-O2IAPWRE-DqfYf5Dt.js} +1 -1
- package/renderer/dist/assets/{flowDiagram-OXPTDLAJ-aOX2hzvT.js → flowDiagram-OXPTDLAJ-DAWsMLmo.js} +1 -1
- package/renderer/dist/assets/{ganttDiagram-R7TSEDQI-B1H3pWyI.js → ganttDiagram-R7TSEDQI-4ml_bhwR.js} +1 -1
- package/renderer/dist/assets/{gitGraphDiagram-XJZIOB7I-Cvw304HC.js → gitGraphDiagram-XJZIOB7I-B_WcqSId.js} +1 -1
- package/renderer/dist/assets/{infoDiagram-5W2HQ5XZ-_ZXcNViH.js → infoDiagram-5W2HQ5XZ-CI-jYqzc.js} +1 -1
- package/renderer/dist/assets/{ishikawaDiagram-K3B6WC7H-C7Mx_qQk.js → ishikawaDiagram-K3B6WC7H-B56VWRlV.js} +1 -1
- package/renderer/dist/assets/{journeyDiagram-COXZFDF6-fr62QoAt.js → journeyDiagram-COXZFDF6-D1-p09Lr.js} +1 -1
- package/renderer/dist/assets/{kanban-definition-P3RFRI5V-J8zsabCe.js → kanban-definition-P3RFRI5V-Cv3w7JoN.js} +1 -1
- package/renderer/dist/assets/{linear-BmNdEtIi.js → linear-C35V2Spi.js} +1 -1
- package/renderer/dist/assets/main-BcoBiGwv.css +32 -0
- package/renderer/dist/assets/main-CLnIOulD.js +1191 -0
- package/renderer/dist/assets/{mermaid.core-CnYzvFkz.js → mermaid.core-BzGT5Vo9.js} +4 -4
- package/renderer/dist/assets/{mindmap-definition-LZFPQGKD-Bfcxc7PM.js → mindmap-definition-LZFPQGKD-BZUfuymC.js} +1 -1
- package/renderer/dist/assets/{pegDiagram-BGZESJAR-CwUJOq6X.js → pegDiagram-BGZESJAR-DFh9XNGe.js} +1 -1
- package/renderer/dist/assets/{pieDiagram-CAPJLFHJ-BrwMmqfC.js → pieDiagram-CAPJLFHJ-C65tPVsy.js} +1 -1
- package/renderer/dist/assets/{product-name-CTKLYNWY.js → product-name-CSNW6d7s.js} +1 -1
- package/renderer/dist/assets/{quadrantDiagram-GDMTTRAM-BN4Stoj-.js → quadrantDiagram-GDMTTRAM-Cushj_nk.js} +1 -1
- package/renderer/dist/assets/{railroadDiagram-SM67HX2B-CPZ5bbeR.js → railroadDiagram-SM67HX2B-DZGebGx5.js} +1 -1
- package/renderer/dist/assets/{requirementDiagram-X7JNWC4A-CgRnimVe.js → requirementDiagram-X7JNWC4A-DpAB0MNo.js} +1 -1
- package/renderer/dist/assets/{sankeyDiagram-UM26HJRW-Bt2rej__.js → sankeyDiagram-UM26HJRW-D-29rHra.js} +1 -1
- package/renderer/dist/assets/{sequenceDiagram-ZO4K6R2Y-Dd5nKe9G.js → sequenceDiagram-ZO4K6R2Y-FfOQAZol.js} +1 -1
- package/renderer/dist/assets/{sizeCapture-INFHLROL-usyNyXyh.js → sizeCapture-INFHLROL-C_n6fDBY.js} +1 -1
- package/renderer/dist/assets/{stateDiagram-v2-TBUQTH76-CRzUBb0u.js → stateDiagram-v2-TBUQTH76-BZjenzJG.js} +1 -1
- package/renderer/dist/assets/{swimlanes-N4OXWK64-C1GJvMS0.js → swimlanes-N4OXWK64-DxaMU9vX.js} +1 -1
- package/renderer/dist/assets/swimlanesDiagram-K3J5GTZL-Cb2kM6rr.js +8 -0
- package/renderer/dist/assets/{timeline-definition-YOQAKGHF-caeyPlcc.js → timeline-definition-YOQAKGHF-DTg5HJKz.js} +1 -1
- package/renderer/dist/assets/{usecaseDiagram-VIAY4XPW-BN_SV6PC.js → usecaseDiagram-VIAY4XPW-Bc3MZDsl.js} +1 -1
- package/renderer/dist/assets/{vennDiagram-BLWOH2XV-BLGuaUX-.js → vennDiagram-BLWOH2XV-GIVGk9Id.js} +1 -1
- package/renderer/dist/assets/{wardleyDiagram-YMQ3BMBF-Da0cJeNc.js → wardleyDiagram-YMQ3BMBF-NmJnbQOg.js} +1 -1
- package/renderer/dist/assets/{xychartDiagram-TAQBALBS-cGfUffMk.js → xychartDiagram-TAQBALBS-p-kfGfnW.js} +1 -1
- package/renderer/dist/corner-tag.html +3 -3
- package/renderer/dist/index.html +3 -3
- package/scripts/after-install.mjs +30 -8
- package/scripts/cloud-shim.pl +57 -0
- package/shared/account-names.d.mts +6 -0
- package/shared/account-names.mjs +15 -0
- package/shared/agent-hours.mjs +136 -0
- package/shared/agent-import.mjs +60 -34
- package/shared/agents.mjs +126 -522
- package/shared/analytics-events.mjs +12 -0
- package/shared/approval-card.mjs +36 -0
- package/shared/bridge-map.mjs +27 -1
- package/shared/claude-code-shape.mjs +6 -0
- package/shared/claude-usage.d.mts +1 -0
- package/shared/claude-usage.mjs +42 -1
- package/shared/cloud-hand-back.d.mts +7 -0
- package/shared/cloud-hand-back.mjs +20 -0
- package/shared/cloud-kit.d.mts +219 -0
- package/shared/cloud-kit.mjs +341 -0
- package/shared/cloud-login-name.mjs +4 -0
- package/shared/cloud-start.mjs +17 -0
- package/shared/cloud-versions.mjs +23 -0
- package/shared/codex-import.mjs +2 -2
- package/shared/codex-modes.d.mts +22 -9
- package/shared/codex-modes.mjs +67 -11
- package/shared/codex-usage.mjs +108 -2
- package/shared/corner-tag.d.mts +23 -1
- package/shared/corner-tag.mjs +284 -29
- package/shared/dead-run-trace.mjs +19 -2
- package/shared/engines.mjs +70 -309
- package/shared/feedback.d.mts +7 -0
- package/shared/feedback.mjs +25 -0
- package/shared/first-run-practice.mjs +17 -24
- package/shared/harness-definitions.mjs +8 -0
- package/shared/instruction-defaults.json +2 -2
- package/shared/key-caps.d.mts +14 -0
- package/shared/key-caps.mjs +68 -0
- package/shared/message-edits.d.mts +7 -0
- package/shared/message-edits.mjs +35 -0
- package/shared/move-thread.d.mts +24 -0
- package/shared/move-thread.mjs +112 -0
- package/shared/named-threads.mjs +72 -0
- package/shared/not-shipped.d.mts +22 -0
- package/shared/not-shipped.mjs +89 -0
- package/shared/notify-rules.d.mts +6 -0
- package/shared/notify-rules.mjs +36 -8
- package/shared/phone-files.mjs +28 -0
- package/shared/phone-settings.mjs +118 -0
- package/shared/phone-snapshot.d.mts +16 -0
- package/shared/phone-snapshot.mjs +348 -0
- package/shared/picture-formats.d.mts +1 -0
- package/shared/picture-formats.mjs +13 -0
- package/shared/rank.d.mts +2 -1
- package/shared/rank.mjs +32 -19
- package/shared/repeats.d.mts +8 -0
- package/shared/repeats.mjs +33 -2
- package/shared/room-cards.mjs +167 -0
- package/shared/room-links.d.mts +4 -0
- package/shared/room-links.mjs +21 -0
- package/shared/runs-on.d.mts +3 -3
- package/shared/runs-on.mjs +8 -5
- package/shared/shipped-note.d.mts +4 -0
- package/shared/shipped-note.mjs +6 -0
- package/shared/shortcuts.mjs +14 -6
- package/shared/spawn-trouble.d.mts +4 -0
- package/shared/spawn-trouble.mjs +443 -40
- package/shared/team-rules.mjs +9 -2
- package/shared/teammates.d.mts +19 -0
- package/shared/teammates.mjs +756 -0
- package/shared/terminal-focus.d.mts +1 -0
- package/shared/terminal-focus.mjs +13 -0
- package/shared/thread-cards.mjs +4 -1
- package/shared/usage.d.mts +9 -0
- package/shared/usage.mjs +49 -3
- package/shared/websites.d.mts +15 -0
- package/shared/websites.mjs +61 -0
- package/shared/work-items.mjs +127 -30
- package/shared/work-lines.mjs +92 -244
- package/renderer/dist/assets/channel-BlT2g9Sz.js +0 -1
- package/renderer/dist/assets/cornerTag-BLrxiDE5.css +0 -1
- package/renderer/dist/assets/cornerTag-BOT6u3Tn.js +0 -1
- package/renderer/dist/assets/main-ChNdnUvt.js +0 -1132
- package/renderer/dist/assets/main-nj0QR5kg.css +0 -32
- package/renderer/dist/assets/swimlanesDiagram-K3J5GTZL-Cy4Av2_p.js +0 -8
package/main/phone-link.mjs
CHANGED
|
@@ -32,7 +32,7 @@ import { execFile } from 'node:child_process';
|
|
|
32
32
|
import { fileURLToPath } from 'node:url';
|
|
33
33
|
import QRCode from 'qrcode';
|
|
34
34
|
import webpush from 'web-push';
|
|
35
|
-
import {
|
|
35
|
+
import { phoneBanner, shouldSpeak } from '../shared/notify-rules.mjs';
|
|
36
36
|
import { createHandler, sameKey } from './serve.mjs';
|
|
37
37
|
import { createRelayClient } from './relay-client.mjs';
|
|
38
38
|
import { Name } from '../shared/product-name.mjs';
|
|
@@ -148,13 +148,17 @@ export function deviceName(ua = '') {
|
|
|
148
148
|
* dist?: string, port?: number, onChange?: () => void,
|
|
149
149
|
* interfaces?: () => object, log?: { warn?: Function } }} options
|
|
150
150
|
*/
|
|
151
|
-
export function createPhoneLink({ file, channels, listeners, dist, files = null, port = PHONE_PORT, onChange = () => {}, interfaces = () => os.networkInterfaces(), log = console, tailscale = tailscaleCli(), warm = (url) => fetch(url).catch(() => {}), send = (sub, body, opts) => webpush.sendNotification(sub, body, opts), atDesk = () => false, coalesceMs = COALESCE_MS, relayUrl = '', createRelay = (opts) => createRelayClient(opts) }) {
|
|
151
|
+
export function createPhoneLink({ file, channels, listeners, dist, files = null, port = PHONE_PORT, onChange = () => {}, interfaces = () => os.networkInterfaces(), log = console, tailscale = tailscaleCli(), warm = (url) => fetch(url, { signal: AbortSignal.timeout(10_000) }).catch(() => {}), send = (sub, body, opts) => webpush.sendNotification(sub, body, opts), atDesk = () => false, coalesceMs = COALESCE_MS, relayUrl = '', createRelay = (opts) => createRelayClient(opts) }) {
|
|
152
152
|
let saved = read(file);
|
|
153
|
+
// THE PORT IT IS ON, WHICH IS NOT ALWAYS THE PORT IT WAS ASKED FOR. `port` is
|
|
154
|
+
// `PHONE_PORT` in the app, so a phone paired once comes back to the same
|
|
155
|
+
// address; 0 asks for whatever is free, and then only the socket knows.
|
|
156
|
+
const livePort = () => server?.address()?.port ?? port;
|
|
153
157
|
// ANYWHERE, THROUGH THE RELAY (main/relay-client.mjs). With a relay to use it
|
|
154
158
|
// is where a person starts, because it needs nothing on the phone; a route
|
|
155
159
|
// someone picked stays picked. Before the relay every file said "wifi"
|
|
156
160
|
// whether or not anybody picked it, so only a pick marked as one counts.
|
|
157
|
-
const relay = relayUrl ? createRelay({ relayUrl, key: () => saved.key, port: () =>
|
|
161
|
+
const relay = relayUrl ? createRelay({ relayUrl, key: () => saved.key, port: () => livePort(), log, onChange: () => onChange() }) : null;
|
|
158
162
|
const via = () => {
|
|
159
163
|
const picked = saved.viaChosen || saved.via === 'tailscale' ? saved.via : null;
|
|
160
164
|
if (picked === 'anywhere' && !relay) return 'wifi';
|
|
@@ -331,7 +335,7 @@ export function createPhoneLink({ file, channels, listeners, dist, files = null,
|
|
|
331
335
|
try {
|
|
332
336
|
const host = await tailscale.name();
|
|
333
337
|
if (!host) throw new Error('This tailnet has HTTPS certificates turned off. Turn them on in the Tailscale admin console, under DNS.');
|
|
334
|
-
await tailscale.serve(
|
|
338
|
+
await tailscale.serve(livePort());
|
|
335
339
|
secure = { ...secure, host, error: null };
|
|
336
340
|
// The first request makes Tailscale fetch the certificate, which took
|
|
337
341
|
// 40 seconds when measured. Make it now, not on the phone.
|
|
@@ -384,7 +388,8 @@ export function createPhoneLink({ file, channels, listeners, dist, files = null,
|
|
|
384
388
|
if (action === 'subscribe') {
|
|
385
389
|
const endpoint = String(subscription?.endpoint ?? '');
|
|
386
390
|
if (!/^https:\/\//.test(endpoint) || !subscription?.keys?.p256dh || !subscription?.keys?.auth) throw new Error('That is not a push subscription.');
|
|
387
|
-
|
|
391
|
+
// ONE PHONE HEARS THEM: the one that turned them on last (newestAlerts).
|
|
392
|
+
saved.alerts = { [d.id]: { sub: { endpoint, keys: { p256dh: String(subscription.keys.p256dh), auth: String(subscription.keys.auth) } }, name: d.name, since: Date.now() } };
|
|
388
393
|
save();
|
|
389
394
|
onChange();
|
|
390
395
|
return { on: true };
|
|
@@ -441,7 +446,7 @@ export function createPhoneLink({ file, channels, listeners, dist, files = null,
|
|
|
441
446
|
if (atDesk()) return seenAll();
|
|
442
447
|
const items = [...unseen.values()];
|
|
443
448
|
if (!shouldSpeak({ spoke, spokeAsk, waiting: items })) return;
|
|
444
|
-
const say =
|
|
449
|
+
const say = phoneBanner(items);
|
|
445
450
|
if (!say) return;
|
|
446
451
|
spoke = true;
|
|
447
452
|
if (items.some((i) => i.kind === 'approval')) spokeAsk = true;
|
|
@@ -471,7 +476,7 @@ export function createPhoneLink({ file, channels, listeners, dist, files = null,
|
|
|
471
476
|
const url = !pick ? null
|
|
472
477
|
: anywhere ? `${relayUrl.replace(/\/+$/, '')}/#k=${encodeURIComponent(saved.key)}`
|
|
473
478
|
: safe ? `https://${secure.host}:${SECURE_PORT}/?token=${encodeURIComponent(saved.key)}`
|
|
474
|
-
: `http://${pick.host}:${
|
|
479
|
+
: `http://${pick.host}:${livePort()}/?token=${encodeURIComponent(saved.key)}`;
|
|
475
480
|
const relayState = anywhere ? relay.state() : null;
|
|
476
481
|
const now = Date.now();
|
|
477
482
|
return {
|
|
@@ -480,7 +485,7 @@ export function createPhoneLink({ file, channels, listeners, dist, files = null,
|
|
|
480
485
|
/** 127.0.0.1 over Tailscale and the relay, 0.0.0.0 over Wi-Fi. */
|
|
481
486
|
host: server?.listening ? host : null,
|
|
482
487
|
error: error ?? (pick?.via === 'tailscale' ? secure.error : null) ?? relayState?.error ?? null,
|
|
483
|
-
port,
|
|
488
|
+
port: livePort(),
|
|
484
489
|
secure: !!safe,
|
|
485
490
|
settingUp: !!secure.busy,
|
|
486
491
|
/** Through the relay: whether this Mac is connected to it now. */
|
|
@@ -537,10 +542,25 @@ function read(file) {
|
|
|
537
542
|
// A route somebody picked, not one written down because it was the only one.
|
|
538
543
|
viaChosen: saved.viaChosen === true,
|
|
539
544
|
vapid: saved.vapid?.publicKey && saved.vapid?.privateKey ? saved.vapid : null,
|
|
540
|
-
alerts: saved.alerts
|
|
545
|
+
alerts: newestAlerts(saved.alerts),
|
|
541
546
|
};
|
|
542
547
|
}
|
|
543
548
|
|
|
549
|
+
// ALERTS GO TO ONE PHONE, the one that turned them on last (2026-10-13).
|
|
550
|
+
// Adding the app to the home screen again, over Wi-Fi, over Tailscale or
|
|
551
|
+
// through the relay, makes a separate web app to iOS with its own id here, and
|
|
552
|
+
// nothing a page can read says two of them are the same phone. One Mac held
|
|
553
|
+
// three of them, so every alert arrived three times in three groups to clear.
|
|
554
|
+
// The newest is the icon in use: an older one is told alerts are off and can
|
|
555
|
+
// take them back with one tap.
|
|
556
|
+
function newestAlerts(alerts) {
|
|
557
|
+
if (!alerts || typeof alerts !== 'object') return {};
|
|
558
|
+
const entries = Object.entries(alerts).filter(([, a]) => a?.sub?.endpoint);
|
|
559
|
+
if (entries.length <= 1) return Object.fromEntries(entries);
|
|
560
|
+
const [id, a] = entries.reduce((best, e) => ((e[1].since ?? 0) > (best[1].since ?? 0) ? e : best));
|
|
561
|
+
return { [id]: a };
|
|
562
|
+
}
|
|
563
|
+
|
|
544
564
|
function readJson(req) {
|
|
545
565
|
return new Promise((resolve, reject) => {
|
|
546
566
|
let body = '';
|
|
@@ -0,0 +1,476 @@
|
|
|
1
|
+
// THE PICTURES, FILMS, SOUNDS AND PAGES A THREAD NAMES, FOR HER PHONE WITH THE
|
|
2
|
+
// MAC SHUT.
|
|
3
|
+
//
|
|
4
|
+
// A thread's copy online is its words (shared/phone-snapshot.mjs), and a path
|
|
5
|
+
// in those words is a file on this Mac that the hosted phone site cannot open.
|
|
6
|
+
// So for each thread carried, this finds the files its words name, inside that
|
|
7
|
+
// project's own folders only, uploads each once to her own private storage
|
|
8
|
+
// (named by what is in it, so the same file twice is one upload), and lists
|
|
9
|
+
// them in the thread's copy: the path as the screens ask for it, and the name
|
|
10
|
+
// online. A page brings the files it draws with it.
|
|
11
|
+
//
|
|
12
|
+
// What goes up is bounded three ways (per file, per thread, all together), and
|
|
13
|
+
// newest threads go first; a file over the per-file bound is listed as too big,
|
|
14
|
+
// so the phone can say so instead of drawing nothing. A file no thread names
|
|
15
|
+
// any more is deleted after a few days.
|
|
16
|
+
import fs from 'node:fs';
|
|
17
|
+
import path from 'node:path';
|
|
18
|
+
import crypto from 'node:crypto';
|
|
19
|
+
import { hideSecrets } from '../shared/phone-snapshot.mjs';
|
|
20
|
+
import { readPictureShapeSync } from './picture-shape.mjs';
|
|
21
|
+
|
|
22
|
+
// A tenth of the Pro plan's 100 GB, which the organization's projects share;
|
|
23
|
+
// past it the oldest threads' files make way (`trimBelow`).
|
|
24
|
+
export const UPLOAD_LIMITS = { fileBytes: 25 * 1024 * 1024, threadBytes: 100 * 1024 * 1024, totalBytes: 10 * 1024 ** 3, files: 60, days: 7 };
|
|
25
|
+
const KEEP_UNNAMED_MS = 3 * 24 * 3600_000;
|
|
26
|
+
const LOOK_AGAIN_MS = 5 * 60_000;
|
|
27
|
+
// After a refused upload (no bucket yet, offline, signed out of storage) the
|
|
28
|
+
// uploader rests, rather than reading every queued file again to be refused again.
|
|
29
|
+
const REST_AFTER_REFUSAL_MS = 30 * 60_000;
|
|
30
|
+
|
|
31
|
+
const TYPES = {
|
|
32
|
+
'.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.webp': 'image/webp', '.avif': 'image/avif', '.svg': 'image/svg+xml',
|
|
33
|
+
'.mp3': 'audio/mpeg', '.wav': 'audio/wav', '.m4a': 'audio/mp4', '.aac': 'audio/aac', '.ogg': 'audio/ogg',
|
|
34
|
+
'.mp4': 'video/mp4', '.mov': 'video/quicktime', '.m4v': 'video/mp4', '.webm': 'video/webm',
|
|
35
|
+
'.html': 'text/html', '.htm': 'text/html', '.pdf': 'application/pdf', '.md': 'text/markdown', '.markdown': 'text/markdown',
|
|
36
|
+
};
|
|
37
|
+
// Documents, named anywhere in the words as the kinds above are.
|
|
38
|
+
const OFFICE = 'application/vnd.openxmlformats-officedocument';
|
|
39
|
+
const DOCUMENTS = {
|
|
40
|
+
'.txt': 'text/plain; charset=utf-8', '.log': 'text/plain; charset=utf-8', '.json': 'application/json', '.csv': 'text/csv; charset=utf-8', '.tsv': 'text/tab-separated-values; charset=utf-8',
|
|
41
|
+
'.yaml': 'text/yaml; charset=utf-8', '.yml': 'text/yaml; charset=utf-8', '.xml': 'text/xml', '.rtf': 'application/rtf', '.heic': 'image/heic',
|
|
42
|
+
'.doc': 'application/msword', '.docx': `${OFFICE}.wordprocessingml.document`, '.xls': 'application/vnd.ms-excel', '.xlsx': `${OFFICE}.spreadsheetml.sheet`,
|
|
43
|
+
'.ppt': 'application/vnd.ms-powerpoint', '.pptx': `${OFFICE}.presentationml.presentation`, '.pages': 'application/vnd.apple.pages', '.numbers': 'application/vnd.apple.numbers', '.zip': 'application/zip',
|
|
44
|
+
};
|
|
45
|
+
// What a page may bring with it, besides the kinds above.
|
|
46
|
+
const PAGE_PARTS = { '.css': 'text/css', '.woff2': 'font/woff2', '.woff': 'font/woff', '.ttf': 'font/ttf', '.otf': 'font/otf' };
|
|
47
|
+
const PICTURE = /\.(png|jpe?g|gif|webp|avif)$/i;
|
|
48
|
+
// Keys, certificates and databases never go, whatever way they are named.
|
|
49
|
+
const NEVER = /\.(pem|key|p12|pfx|kdbx|keystore|jks|der|crt|cer|asc|gpg|sqlite3?|db)$/i;
|
|
50
|
+
const EXT = [...Object.keys(TYPES), ...Object.keys(DOCUMENTS)].map((e) => e.slice(1)).join('|');
|
|
51
|
+
// A path as a worker writes one: bare, after ~/ or /, or in quotes with spaces.
|
|
52
|
+
const NAMED = new RegExp(String.raw`(?:["“'‘]((?:~\/|\/)?[^"”'’\r\n]+\.(?:${EXT}))["”'’])|((?:~\/|\/)?[\w@%+=,.\/-]*[\w-]\.(?:${EXT}))(?![\w])`, 'gi');
|
|
53
|
+
// Any kind at all where the screens draw it as a file: a markdown link's
|
|
54
|
+
// target (a reply's attachments) or a path in backticks.
|
|
55
|
+
const LINKED = /\]\(<?([^)\s>]+\.[A-Za-z0-9]{1,10})>?\)|`([^`\s]+\.[A-Za-z0-9]{1,10})`/g;
|
|
56
|
+
const FOLDER_LINE = /(?:^|\n)[ \t]*((?:~\/|\/)[\w./-]*[\w-])\/?[ \t]*(?=\n|$)/g;
|
|
57
|
+
const SKIP_SEGMENTS = new Set(['node_modules', '.git']);
|
|
58
|
+
// A file whose name says it holds a secret never goes, whatever its kind.
|
|
59
|
+
const SECRET_NAME = /(secret|credential|passw|token|private[-_.]?key|(^|[/.])env($|[/.]))/i;
|
|
60
|
+
// Words, not pictures: what goes up is the text with its keys hidden, as a
|
|
61
|
+
// thread's words are. A file of another kind whose bytes are text goes so too.
|
|
62
|
+
const TEXT = /\.(html?|md|markdown|css|svg|txt|log|json|csv|tsv|ya?ml|xml)$/i;
|
|
63
|
+
// Not pictures, films or sounds, which are never read as text.
|
|
64
|
+
const MEDIA = /\.(png|jpe?g|gif|webp|avif|heic|mp3|wav|m4a|aac|ogg|mp4|mov|m4v|webm|pdf|zip|docx?|xlsx?|pptx?|pages|numbers|woff2?|ttf|otf)$/i;
|
|
65
|
+
// A spreadsheet's column whose heading names a secret is emptied: a password
|
|
66
|
+
// in a column has no name beside it for the text rules to hide it by.
|
|
67
|
+
const SECRET_COLUMN = /pass|secret|token|key|credential|auth/i;
|
|
68
|
+
// A page or stylesheet bigger than this is listed but not read for what it draws.
|
|
69
|
+
const PAGE_READ_MAX = 2 * 1024 * 1024;
|
|
70
|
+
// And all the pages one thread names, read for what they draw, at most this much.
|
|
71
|
+
const PAGES_READ_MAX = 6 * 1024 * 1024;
|
|
72
|
+
|
|
73
|
+
/** At most `max` bytes of a file, through one open handle; null for a bigger one. */
|
|
74
|
+
function readAtMost(file, max) {
|
|
75
|
+
const fd = fs.openSync(file, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
|
|
76
|
+
try {
|
|
77
|
+
const buf = Buffer.alloc(max + 1);
|
|
78
|
+
let n = 0;
|
|
79
|
+
for (let got; n <= max && (got = fs.readSync(fd, buf, n, max + 1 - n, null)) > 0;) n += got;
|
|
80
|
+
return n > max ? null : buf.subarray(0, n);
|
|
81
|
+
} finally {
|
|
82
|
+
fs.closeSync(fd);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const knownType = (file) => TYPES[path.extname(file).toLowerCase()] ?? PAGE_PARTS[path.extname(file).toLowerCase()] ?? null;
|
|
87
|
+
/** What a file goes up as, or null for one that never goes. */
|
|
88
|
+
export const typeOf = (file) => {
|
|
89
|
+
if (NEVER.test(file) || SECRET_NAME.test(path.basename(file))) return null;
|
|
90
|
+
return knownType(file) ?? DOCUMENTS[path.extname(file).toLowerCase()] ?? 'application/octet-stream';
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
const cellsOf = (line, sep) => {
|
|
94
|
+
const out = [];
|
|
95
|
+
let cell = '';
|
|
96
|
+
let quoted = false;
|
|
97
|
+
for (const ch of line) {
|
|
98
|
+
if (ch === '"') quoted = !quoted;
|
|
99
|
+
if (ch === sep && !quoted) { out.push(cell); cell = ''; } else cell += ch;
|
|
100
|
+
}
|
|
101
|
+
out.push(cell);
|
|
102
|
+
return out;
|
|
103
|
+
};
|
|
104
|
+
/** A spreadsheet's text with every column whose heading names a secret emptied. */
|
|
105
|
+
export function hideCsvSecrets(text) {
|
|
106
|
+
const lines = String(text).split('\n');
|
|
107
|
+
const head = (lines[0] ?? '').replace(/\r$/, '');
|
|
108
|
+
const sep = head.includes('\t') && !head.includes(',') ? '\t' : ',';
|
|
109
|
+
const hide = cellsOf(head, sep).map((h, i) => (SECRET_COLUMN.test(h) ? i : -1)).filter((i) => i >= 0);
|
|
110
|
+
if (!hide.length) return String(text);
|
|
111
|
+
return [lines[0], ...lines.slice(1).map((line) => {
|
|
112
|
+
if (!line) return line;
|
|
113
|
+
const cr = line.endsWith('\r');
|
|
114
|
+
const cells = cellsOf(cr ? line.slice(0, -1) : line, sep);
|
|
115
|
+
for (const i of hide) if (i < cells.length) cells[i] = '[hidden]';
|
|
116
|
+
return cells.join(sep) + (cr ? '\r' : '');
|
|
117
|
+
})].join('\n');
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Bytes that read as text: no NUL near the start, and whole UTF-8.
|
|
121
|
+
function readsAsText(buf) {
|
|
122
|
+
if (buf.subarray(0, 8000).includes(0)) return false;
|
|
123
|
+
try { new TextDecoder('utf-8', { fatal: true }).decode(buf); return true; } catch { return false; }
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Newest first, so a long thread's latest messages are the ones its bound keeps.
|
|
127
|
+
function textsOf(snapshot) {
|
|
128
|
+
const out = [];
|
|
129
|
+
const add = (v) => { if (typeof v === 'string' && v) out.push(v); };
|
|
130
|
+
for (const v of Object.values(snapshot?.item ?? {})) add(v);
|
|
131
|
+
for (const l of [...(snapshot?.history ?? [])].reverse()) for (const v of Object.values(l?.patch ?? {})) add(v);
|
|
132
|
+
return out;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const real = (p) => { try { return fs.realpathSync(p); } catch { return null; } };
|
|
136
|
+
const within = (root, file) => !!root && (file === root || file.startsWith(root + path.sep));
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Where a file is, as the phone knows it: relative to the project's documents
|
|
140
|
+
* folder, or `@repo/` and relative to its code. Null outside both.
|
|
141
|
+
*/
|
|
142
|
+
export function keyOf(file, { docs, repo }) {
|
|
143
|
+
if (docs && within(docs, file)) return path.relative(docs, file).split(path.sep).join('/');
|
|
144
|
+
if (repo && within(repo, file)) return `@repo/${path.relative(repo, file).split(path.sep).join('/')}`;
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* The files a thread's copy names, as [{ key, file }], inside the project's
|
|
150
|
+
* own folders only. A bare name is looked for where the screens look for it
|
|
151
|
+
* (renderer/src/message-folders.ts pictureRoots), in that order.
|
|
152
|
+
*/
|
|
153
|
+
export function filesNamed(snapshot, { dir, repoPath = null, home }) {
|
|
154
|
+
const docs = dir && real(dir);
|
|
155
|
+
if (!docs) return [];
|
|
156
|
+
const repo = repoPath ? real(repoPath) : null;
|
|
157
|
+
const id = snapshot?.item?.id;
|
|
158
|
+
const found = new Map();
|
|
159
|
+
const take = (abs) => {
|
|
160
|
+
const file = real(abs);
|
|
161
|
+
if (!file || file.split(path.sep).some((seg) => SKIP_SEGMENTS.has(seg)) || SECRET_NAME.test(path.basename(file))) return false;
|
|
162
|
+
const key = keyOf(file, { docs, repo });
|
|
163
|
+
if (!key) return false;
|
|
164
|
+
let st;
|
|
165
|
+
try { st = fs.statSync(file); } catch { return false; }
|
|
166
|
+
if (!st.isFile() || !typeOf(file)) return false;
|
|
167
|
+
if (!found.has(key)) found.set(key, file);
|
|
168
|
+
return true;
|
|
169
|
+
};
|
|
170
|
+
const full = (p) => path.resolve(p.startsWith('~/') ? path.join(home, p.slice(2)) : p);
|
|
171
|
+
const texts = textsOf(snapshot);
|
|
172
|
+
const named = [];
|
|
173
|
+
for (const text of texts) {
|
|
174
|
+
for (const m of text.matchAll(NAMED)) named.push((m[1] ?? m[2]).trim());
|
|
175
|
+
for (const m of text.matchAll(LINKED)) { try { named.push(decodeURI(m[1] ?? m[2]).trim()); } catch { named.push((m[1] ?? m[2]).trim()); } }
|
|
176
|
+
}
|
|
177
|
+
// The folders the words name come first, as they do on the screens.
|
|
178
|
+
const folders = [];
|
|
179
|
+
for (const n of named) if (n.includes('/')) folders.push(path.dirname(n.startsWith('/') || n.startsWith('~/') ? full(n) : path.join(docs, n)));
|
|
180
|
+
for (const text of texts) for (const m of text.matchAll(FOLDER_LINE)) folders.push(full(m[1]));
|
|
181
|
+
const roots = [...new Set([
|
|
182
|
+
...folders, id && path.join(docs, 'designs', id), repo && id && path.join(repo, '.claude', 'worktrees', id),
|
|
183
|
+
docs, path.join(docs, 'designs'), path.join(docs, 'attachments'), repo, repo && path.join(repo, 'designs'),
|
|
184
|
+
].filter(Boolean))];
|
|
185
|
+
for (const n of named) {
|
|
186
|
+
if (/^[a-z]+:\/\//i.test(n)) continue;
|
|
187
|
+
if (n.startsWith('/') || n.startsWith('~/')) { take(full(n)); continue; }
|
|
188
|
+
const rel = n.replace(/^\.\//, '');
|
|
189
|
+
if (rel.split('/').includes('..')) continue;
|
|
190
|
+
for (const root of roots) if (fs.existsSync(path.join(root, rel)) && take(path.join(root, rel))) break;
|
|
191
|
+
}
|
|
192
|
+
return [...found].map(([key, file]) => ({ key, file }));
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** The folders a thread's copy names on a line of their own, as [{ key, pictures: [{ key, file }] }], newest pictures first. */
|
|
196
|
+
export function foldersNamed(snapshot, { dir, repoPath = null, home, limit = 6 }) {
|
|
197
|
+
const docs = dir && real(dir);
|
|
198
|
+
if (!docs) return [];
|
|
199
|
+
const repo = repoPath ? real(repoPath) : null;
|
|
200
|
+
const out = [];
|
|
201
|
+
for (const text of textsOf(snapshot)) {
|
|
202
|
+
for (const m of text.matchAll(FOLDER_LINE)) {
|
|
203
|
+
const named = m[1];
|
|
204
|
+
if (/\.[a-z0-9]{1,8}$/i.test(named)) continue;
|
|
205
|
+
const folder = real(path.resolve(named.startsWith('~/') ? path.join(home, named.slice(2)) : named));
|
|
206
|
+
const key = folder && keyOf(folder, { docs, repo });
|
|
207
|
+
if (!key || out.some((f) => f.key === `${key}/`)) continue;
|
|
208
|
+
let entries = [];
|
|
209
|
+
try { entries = fs.readdirSync(folder, { withFileTypes: true }).filter((e) => e.isFile() && PICTURE.test(e.name)); } catch { continue; }
|
|
210
|
+
const pictures = entries
|
|
211
|
+
.map((e) => ({ key: `${key}/${e.name}`, file: path.join(folder, e.name), m: fs.statSync(path.join(folder, e.name)).mtimeMs }))
|
|
212
|
+
.sort((a, b) => b.m - a.m || a.key.localeCompare(b.key))
|
|
213
|
+
.slice(0, limit)
|
|
214
|
+
.map(({ key: k, file }) => ({ key: k, file }));
|
|
215
|
+
out.push({ key: `${key}/`, pictures });
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
return out;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// What a page draws from beside it: src, href, poster, srcset and url() that
|
|
222
|
+
// name a file next to it rather than a site. A stylesheet's own url()s too.
|
|
223
|
+
const REFS = /\b(?:src|href|poster|data-src)\s*=\s*["']([^"'#?]+)[^"']*["']|\bsrcset\s*=\s*["']([^"']+)["']|url\(\s*["']?([^"')#?]+)[^"')]*["']?\s*\)/gi;
|
|
224
|
+
export function partsOf(text) {
|
|
225
|
+
const out = new Set();
|
|
226
|
+
for (const m of String(text).matchAll(REFS)) {
|
|
227
|
+
const list = m[2] ? m[2].split(',').map((s) => s.trim().split(/\s+/)[0]) : [m[1] ?? m[3]];
|
|
228
|
+
for (const ref of list) {
|
|
229
|
+
if (!ref || /^(?:[a-z][a-z0-9+.-]*:|\/\/|\/|#)/i.test(ref)) continue;
|
|
230
|
+
out.add(decodeURIComponent(ref));
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
return [...out];
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** A page's own files, as [{ key, file }], one stylesheet deep, inside the project's folders only. */
|
|
237
|
+
export function pageParts(page, { docs, repo, budget = { bytes: PAGES_READ_MAX } }) {
|
|
238
|
+
const out = new Map();
|
|
239
|
+
const visit = (file, depth) => {
|
|
240
|
+
let text;
|
|
241
|
+
try {
|
|
242
|
+
const raw = readAtMost(file, Math.min(PAGE_READ_MAX, budget.bytes));
|
|
243
|
+
if (!raw) return;
|
|
244
|
+
budget.bytes -= raw.length;
|
|
245
|
+
text = raw.toString('utf8');
|
|
246
|
+
} catch { return; }
|
|
247
|
+
for (const ref of partsOf(text)) {
|
|
248
|
+
const part = real(path.join(path.dirname(file), ref));
|
|
249
|
+
const key = part && keyOf(part, { docs, repo });
|
|
250
|
+
if (!key || out.has(key) || !knownType(part) || SECRET_NAME.test(path.basename(part)) || part.split(path.sep).some((seg) => SKIP_SEGMENTS.has(seg))) continue;
|
|
251
|
+
try { if (!fs.statSync(part).isFile()) continue; } catch { continue; }
|
|
252
|
+
out.set(key, part);
|
|
253
|
+
if (depth < 1 && /\.css$/i.test(part)) visit(part, depth + 1);
|
|
254
|
+
}
|
|
255
|
+
};
|
|
256
|
+
visit(page.file, 0);
|
|
257
|
+
return [...out].map(([key, file]) => ({ key, file }));
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* The uploader. `manifest(...)` says what a thread's copy lists now (only what
|
|
262
|
+
* is already online, so the phone never asks for a file that is not there yet)
|
|
263
|
+
* and queues the rest; `kick()` uploads what is queued, one file at a time, in
|
|
264
|
+
* the background; `sweep()` deletes what nothing has named for days.
|
|
265
|
+
*/
|
|
266
|
+
export function createPhoneUploads({ backend, owner, device, alive = () => true, state, save, home, now = Date.now, limits = UPLOAD_LIMITS, log = () => {} }) {
|
|
267
|
+
state.uploaded ??= {}; // name -> { bytes, named: last moment a thread named it, rank: newest thread naming it }
|
|
268
|
+
// What each file on this Mac is called online, learnt by the upload (off
|
|
269
|
+
// the window's thread, a piece at a time) and good while it is unchanged.
|
|
270
|
+
const names = new Map(); // file -> { size, mtimeMs, name }
|
|
271
|
+
const queue = new Map(); // file -> { bytes, rank, roots, threads }
|
|
272
|
+
const memo = new Map(); // thread -> { base, at, sig, out }
|
|
273
|
+
let running = null;
|
|
274
|
+
let restUntil = 0;
|
|
275
|
+
let saidFull = false;
|
|
276
|
+
let madeRoom = 0;
|
|
277
|
+
// A file on record from before ranks were kept reads as oldest, so nothing is
|
|
278
|
+
// taken until every thread has looked again and said how new its files are.
|
|
279
|
+
const startedAt = now();
|
|
280
|
+
|
|
281
|
+
const known = (file, st) => {
|
|
282
|
+
const was = names.get(file);
|
|
283
|
+
return was && was.size === st.size && was.mtimeMs === st.mtimeMs ? was.name : null;
|
|
284
|
+
};
|
|
285
|
+
// A picture's width and height, so the phone draws its box at that shape
|
|
286
|
+
// before the picture arrives; read again only when the file changes.
|
|
287
|
+
const shapes = new Map(); // file -> { size, mtimeMs, shape }
|
|
288
|
+
const shapeOf = (file, st) => {
|
|
289
|
+
if (!PICTURE.test(file)) return null;
|
|
290
|
+
const was = shapes.get(file);
|
|
291
|
+
if (was && was.size === st.size && was.mtimeMs === st.mtimeMs) return was.shape;
|
|
292
|
+
let shape = null;
|
|
293
|
+
try { shape = readPictureShapeSync(file); } catch { /* drawn 4:3 until it arrives, as before */ }
|
|
294
|
+
shapes.set(file, { size: st.size, mtimeMs: st.mtimeMs, shape });
|
|
295
|
+
return shape;
|
|
296
|
+
};
|
|
297
|
+
const total = () => Object.values(state.uploaded).reduce((n, u) => n + (u.bytes || 0), 0);
|
|
298
|
+
const olderThan = (rank) => Object.entries(state.uploaded)
|
|
299
|
+
.filter(([, u]) => (u.rank ?? 0) < rank)
|
|
300
|
+
.sort((a, b) => (a[1].rank ?? 0) - (b[1].rank ?? 0) || (a[1].named ?? 0) - (b[1].named ?? 0));
|
|
301
|
+
const roomFor = (bytes, rank) => {
|
|
302
|
+
const over = total() + bytes - limits.totalBytes;
|
|
303
|
+
if (over <= 0) return true;
|
|
304
|
+
if (now() - startedAt < LOOK_AGAIN_MS) return false;
|
|
305
|
+
return olderThan(rank).reduce((n, [, u]) => n + (u.bytes || 0), 0) >= over;
|
|
306
|
+
};
|
|
307
|
+
// THE NEWEST WORK GOES FIRST WHEN THE SPACE IS FULL: the oldest threads'
|
|
308
|
+
// files leave to make room, and never those of a thread as new as this one.
|
|
309
|
+
// Only after the new file is up, so a refused upload takes nothing away; the
|
|
310
|
+
// space runs over by that one file until then.
|
|
311
|
+
async function trimBelow(rank) {
|
|
312
|
+
const over = total() - limits.totalBytes;
|
|
313
|
+
if (over <= 0) return;
|
|
314
|
+
const drop = [];
|
|
315
|
+
let freed = 0;
|
|
316
|
+
for (const [n, u] of olderThan(rank)) {
|
|
317
|
+
if (freed >= over) break;
|
|
318
|
+
drop.push(n);
|
|
319
|
+
freed += u.bytes || 0;
|
|
320
|
+
}
|
|
321
|
+
if (!drop.length) return;
|
|
322
|
+
await backend.dropPhoneFiles({ owner, device, names: drop });
|
|
323
|
+
for (const n of drop) delete state.uploaded[n];
|
|
324
|
+
save();
|
|
325
|
+
for (const m of memo.values()) m.at = -Infinity;
|
|
326
|
+
madeRoom += drop.length;
|
|
327
|
+
}
|
|
328
|
+
const full = () => {
|
|
329
|
+
if (!saidFull) log('phone: the space for files is full');
|
|
330
|
+
saidFull = true;
|
|
331
|
+
};
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* What thread `sk`'s copy (`made()`, built only when needed) lists:
|
|
335
|
+
* { files: { key: { n, s } | { s, big: true } }, folders?: { 'key/': [keys] } },
|
|
336
|
+
* or null when it names nothing that can go. Newer threads upload first.
|
|
337
|
+
* Its words are read again when they change (`base`, the copy's own key) and
|
|
338
|
+
* its files looked at again every five minutes, since a page can change under the
|
|
339
|
+
* same words.
|
|
340
|
+
*/
|
|
341
|
+
function manifest(sk, made, { dir, repoPath = null, updatedAt = 0, base = '' }) {
|
|
342
|
+
const was = memo.get(sk);
|
|
343
|
+
if (was && was.base === base && now() - was.at < LOOK_AGAIN_MS) return was.out;
|
|
344
|
+
const rank = Number(updatedAt) || 0;
|
|
345
|
+
let out = null;
|
|
346
|
+
if (now() - rank <= limits.days * 24 * 3600_000) {
|
|
347
|
+
const snapshot = typeof made === 'function' ? made() : made;
|
|
348
|
+
const named = filesNamed(snapshot, { dir, repoPath, home });
|
|
349
|
+
const folders = foldersNamed(snapshot, { dir, repoPath, home });
|
|
350
|
+
const where = { docs: real(dir), repo: repoPath && real(repoPath) };
|
|
351
|
+
// Each page with its own files right after it.
|
|
352
|
+
const all = new Map();
|
|
353
|
+
const budget = { bytes: PAGES_READ_MAX };
|
|
354
|
+
for (const f of named) {
|
|
355
|
+
if (all.size >= limits.files) break;
|
|
356
|
+
if (!all.has(f.key)) all.set(f.key, f.file);
|
|
357
|
+
if (/\.html?$/i.test(f.file) && budget.bytes > 0) for (const p of pageParts(f, { ...where, budget })) if (!all.has(p.key)) all.set(p.key, p.file);
|
|
358
|
+
}
|
|
359
|
+
for (const f of folders) for (const p of f.pictures) if (!all.has(p.key)) all.set(p.key, p.file);
|
|
360
|
+
const files = {};
|
|
361
|
+
let bytes = 0;
|
|
362
|
+
for (const [key, file] of [...all].slice(0, limits.files)) {
|
|
363
|
+
let st;
|
|
364
|
+
try { st = fs.statSync(file); } catch { continue; }
|
|
365
|
+
if (st.size > limits.fileBytes || bytes + st.size > limits.threadBytes) { files[key] = { s: st.size, big: true }; continue; }
|
|
366
|
+
bytes += st.size;
|
|
367
|
+
const name = known(file, st);
|
|
368
|
+
if (name && state.uploaded[name]) {
|
|
369
|
+
const shape = shapeOf(file, st);
|
|
370
|
+
files[key] = shape ? { n: name, s: st.size, w: shape.w, h: shape.h } : { n: name, s: st.size };
|
|
371
|
+
state.uploaded[name].named = now();
|
|
372
|
+
state.uploaded[name].rank = Math.max(state.uploaded[name].rank ?? 0, rank);
|
|
373
|
+
} else {
|
|
374
|
+
const job = queue.get(file) ?? { bytes: st.size, rank, roots: [where.docs, where.repo].filter(Boolean), threads: new Set() };
|
|
375
|
+
job.rank = Math.max(job.rank, rank);
|
|
376
|
+
job.threads.add(sk);
|
|
377
|
+
queue.set(file, job);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
if (Object.keys(files).length) {
|
|
381
|
+
out = { files };
|
|
382
|
+
const listed = Object.fromEntries(folders.map((f) => [f.key, f.pictures.map((p) => p.key).filter((k) => files[k]?.n)]).filter(([, keys]) => keys.length));
|
|
383
|
+
if (Object.keys(listed).length) out.folders = listed;
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
// The same object while nothing in it changed, so the publisher can tell.
|
|
387
|
+
const sig = JSON.stringify(out);
|
|
388
|
+
if (was?.sig === sig) out = was.out;
|
|
389
|
+
// Each thread's next look at its own moment, so they do not all fall in one pass.
|
|
390
|
+
const spread = was ? 0 : (parseInt(crypto.createHash('sha1').update(sk).digest('hex').slice(0, 8), 16) % LOOK_AGAIN_MS);
|
|
391
|
+
memo.set(sk, { base, at: now() - spread, sig, out });
|
|
392
|
+
return out;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// The file as it is now, read only if it is still the one that was checked:
|
|
396
|
+
// no link anywhere on its way, inside the project, a plain file within the bound.
|
|
397
|
+
async function readChecked(file, roots) {
|
|
398
|
+
if (real(file) !== file || !roots.some((r) => within(r, file))) return null;
|
|
399
|
+
const fh = await fs.promises.open(file, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
|
|
400
|
+
try {
|
|
401
|
+
const st = await fh.stat();
|
|
402
|
+
const there = fs.statSync(file);
|
|
403
|
+
if (!st.isFile() || st.ino !== there.ino || st.dev !== there.dev || real(file) !== file || st.size > limits.fileBytes) return null;
|
|
404
|
+
const buf = Buffer.alloc(st.size + 1);
|
|
405
|
+
let n = 0;
|
|
406
|
+
for (let got; n <= st.size && (got = (await fh.read(buf, n, st.size + 1 - n, n)).bytesRead) > 0;) n += got;
|
|
407
|
+
// Grew while it was read: the next pass looks at it again.
|
|
408
|
+
if (n !== st.size) return null;
|
|
409
|
+
const raw = buf.subarray(0, n);
|
|
410
|
+
if (!TEXT.test(file) && (MEDIA.test(file) || !readsAsText(raw))) return { st, data: raw };
|
|
411
|
+
const words = /\.(csv|tsv)$/i.test(file) ? hideCsvSecrets(raw.toString('utf8')) : raw.toString('utf8');
|
|
412
|
+
return { st, data: Buffer.from(hideSecrets(words, { home })) };
|
|
413
|
+
} finally {
|
|
414
|
+
await fh.close();
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
async function pump() {
|
|
419
|
+
while (queue.size) {
|
|
420
|
+
if (!alive()) return;
|
|
421
|
+
const [file, job] = [...queue].sort((a, b) => b[1].rank - a[1].rank)[0];
|
|
422
|
+
queue.delete(file);
|
|
423
|
+
try {
|
|
424
|
+
const was = names.get(file);
|
|
425
|
+
if (!(was && state.uploaded[was.name]) && !roomFor(job.bytes, job.rank)) { full(); continue; }
|
|
426
|
+
// A file gone or changed since it was named is skipped; only a refused upload rests the uploader.
|
|
427
|
+
const read = await readChecked(file, job.roots).catch(() => null);
|
|
428
|
+
if (!read) continue;
|
|
429
|
+
const name = `${crypto.createHash('sha256').update(read.data).digest('hex')}${path.extname(file).toLowerCase()}`;
|
|
430
|
+
names.set(file, { size: read.st.size, mtimeMs: read.st.mtimeMs, name });
|
|
431
|
+
if (!state.uploaded[name]) {
|
|
432
|
+
if (!roomFor(read.data.length, job.rank)) { full(); continue; }
|
|
433
|
+
if (!alive()) return;
|
|
434
|
+
await backend.putPhoneFile({ owner, device, name, data: read.data, type: typeOf(file) });
|
|
435
|
+
state.uploaded[name] = { bytes: read.data.length, named: now(), rank: job.rank };
|
|
436
|
+
saidFull = false;
|
|
437
|
+
save();
|
|
438
|
+
await trimBelow(job.rank);
|
|
439
|
+
} else state.uploaded[name].rank = Math.max(state.uploaded[name].rank ?? 0, job.rank);
|
|
440
|
+
// The threads that name it look again at what they list.
|
|
441
|
+
for (const sk of job.threads) { const m = memo.get(sk); if (m) m.at = -Infinity; }
|
|
442
|
+
} catch (err) {
|
|
443
|
+
log(`phone: could not send a file, trying again in half an hour (${err?.message ?? err})`);
|
|
444
|
+
restUntil = now() + REST_AFTER_REFUSAL_MS;
|
|
445
|
+
return;
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
return {
|
|
451
|
+
manifest,
|
|
452
|
+
/** Upload what is queued, in the background; the run already going if there is one. */
|
|
453
|
+
kick() {
|
|
454
|
+
if (!running && queue.size && now() >= restUntil) {
|
|
455
|
+
running = pump().finally(() => {
|
|
456
|
+
running = null;
|
|
457
|
+
if (madeRoom) log(`phone: took ${madeRoom} older file${madeRoom === 1 ? '' : 's'} off your phone to make room for newer ones`);
|
|
458
|
+
madeRoom = 0;
|
|
459
|
+
});
|
|
460
|
+
}
|
|
461
|
+
return running ?? Promise.resolve();
|
|
462
|
+
},
|
|
463
|
+
pending: () => queue.size,
|
|
464
|
+
pendingBytes: () => [...queue.values()].reduce((n, j) => n + j.bytes, 0),
|
|
465
|
+
/** Delete what no thread has named for days. */
|
|
466
|
+
async sweep() {
|
|
467
|
+
const old = Object.entries(state.uploaded).filter(([, u]) => now() - (u.named || 0) > KEEP_UNNAMED_MS).map(([n]) => n);
|
|
468
|
+
if (!old.length || !alive()) return 0;
|
|
469
|
+
await backend.dropPhoneFiles({ owner, device, names: old });
|
|
470
|
+
for (const n of old) delete state.uploaded[n];
|
|
471
|
+
save();
|
|
472
|
+
return old.length;
|
|
473
|
+
},
|
|
474
|
+
forget(sk) { memo.delete(sk); },
|
|
475
|
+
};
|
|
476
|
+
}
|