aicodeman 1.40.0 → 1.41.1
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 +81 -13
- package/dist/attachment-registry.d.ts +2 -0
- package/dist/attachment-registry.d.ts.map +1 -1
- package/dist/attachment-registry.js +14 -3
- package/dist/attachment-registry.js.map +1 -1
- package/dist/cli-agent.d.ts +227 -0
- package/dist/cli-agent.d.ts.map +1 -0
- package/dist/cli-agent.js +771 -0
- package/dist/cli-agent.js.map +1 -0
- package/dist/cli.d.ts +25 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +103 -41
- package/dist/cli.js.map +1 -1
- package/dist/codeman-credentials.d.ts +27 -0
- package/dist/codeman-credentials.d.ts.map +1 -0
- package/dist/codeman-credentials.js +66 -0
- package/dist/codeman-credentials.js.map +1 -0
- package/dist/config/buffer-limits.d.ts +9 -0
- package/dist/config/buffer-limits.d.ts.map +1 -1
- package/dist/config/buffer-limits.js +9 -0
- package/dist/config/buffer-limits.js.map +1 -1
- package/dist/config/cli-registry/schema.d.ts +10 -0
- package/dist/config/cli-registry/schema.d.ts.map +1 -1
- package/dist/config/cli-registry/schema.js +15 -0
- package/dist/config/cli-registry/schema.js.map +1 -1
- package/dist/config/cli-registry/stock.d.ts.map +1 -1
- package/dist/config/cli-registry/stock.js +47 -0
- package/dist/config/cli-registry/stock.js.map +1 -1
- package/dist/config/cli-registry/types.d.ts +47 -1
- package/dist/config/cli-registry/types.d.ts.map +1 -1
- package/dist/folder-listing.d.ts +94 -0
- package/dist/folder-listing.d.ts.map +1 -0
- package/dist/folder-listing.js +241 -0
- package/dist/folder-listing.js.map +1 -0
- package/dist/git-clone.d.ts.map +1 -1
- package/dist/git-clone.js +4 -0
- package/dist/git-clone.js.map +1 -1
- package/dist/hooks-config.d.ts +36 -16
- package/dist/hooks-config.d.ts.map +1 -1
- package/dist/hooks-config.js +45 -20
- package/dist/hooks-config.js.map +1 -1
- package/dist/html-view-capabilities.d.ts +54 -0
- package/dist/html-view-capabilities.d.ts.map +1 -0
- package/dist/html-view-capabilities.js +103 -0
- package/dist/html-view-capabilities.js.map +1 -0
- package/dist/image-watcher.d.ts.map +1 -1
- package/dist/image-watcher.js +6 -2
- package/dist/image-watcher.js.map +1 -1
- package/dist/mcp-server.d.ts +114 -0
- package/dist/mcp-server.d.ts.map +1 -0
- package/dist/mcp-server.js +917 -0
- package/dist/mcp-server.js.map +1 -0
- package/dist/mcp-sync-targets.d.ts +34 -0
- package/dist/mcp-sync-targets.d.ts.map +1 -0
- package/dist/mcp-sync-targets.js +55 -0
- package/dist/mcp-sync-targets.js.map +1 -0
- package/dist/mcp-sync.d.ts +7 -1
- package/dist/mcp-sync.d.ts.map +1 -1
- package/dist/mcp-sync.js +113 -6
- package/dist/mcp-sync.js.map +1 -1
- package/dist/mux-interface.d.ts +36 -1
- package/dist/mux-interface.d.ts.map +1 -1
- package/dist/remote-files.d.ts +37 -0
- package/dist/remote-files.d.ts.map +1 -1
- package/dist/remote-files.js +103 -0
- package/dist/remote-files.js.map +1 -1
- package/dist/remote-pane-history.d.ts +103 -0
- package/dist/remote-pane-history.d.ts.map +1 -0
- package/dist/remote-pane-history.js +258 -0
- package/dist/remote-pane-history.js.map +1 -0
- package/dist/remote-ssh-limiter.d.ts +16 -3
- package/dist/remote-ssh-limiter.d.ts.map +1 -1
- package/dist/remote-ssh-limiter.js +37 -6
- package/dist/remote-ssh-limiter.js.map +1 -1
- package/dist/session.d.ts +29 -1
- package/dist/session.d.ts.map +1 -1
- package/dist/session.js +63 -4
- package/dist/session.js.map +1 -1
- package/dist/subagent-watcher.d.ts.map +1 -1
- package/dist/subagent-watcher.js +29 -6
- package/dist/subagent-watcher.js.map +1 -1
- package/dist/tab-layout-legacy-order.d.ts.map +1 -1
- package/dist/tab-layout-legacy-order.js +72 -13
- package/dist/tab-layout-legacy-order.js.map +1 -1
- package/dist/tab-layout-service.d.ts +33 -2
- package/dist/tab-layout-service.d.ts.map +1 -1
- package/dist/tab-layout-service.js +102 -7
- package/dist/tab-layout-service.js.map +1 -1
- package/dist/tab-layout.d.ts +9 -0
- package/dist/tab-layout.d.ts.map +1 -1
- package/dist/tab-layout.js +15 -2
- package/dist/tab-layout.js.map +1 -1
- package/dist/templates/case-template.md +2 -0
- package/dist/tmux-manager.d.ts +76 -2
- package/dist/tmux-manager.d.ts.map +1 -1
- package/dist/tmux-manager.js +216 -40
- package/dist/tmux-manager.js.map +1 -1
- package/dist/tui/tui-client.d.ts +3 -17
- package/dist/tui/tui-client.d.ts.map +1 -1
- package/dist/tui/tui-client.js +3 -44
- package/dist/tui/tui-client.js.map +1 -1
- package/dist/types/session.d.ts +53 -0
- package/dist/types/session.d.ts.map +1 -1
- package/dist/types/session.js.map +1 -1
- package/dist/usage-telemetry.d.ts +44 -7
- package/dist/usage-telemetry.d.ts.map +1 -1
- package/dist/usage-telemetry.js +72 -7
- package/dist/usage-telemetry.js.map +1 -1
- package/dist/utils/index.d.ts +1 -0
- package/dist/utils/index.d.ts.map +1 -1
- package/dist/utils/index.js +1 -0
- package/dist/utils/index.js.map +1 -1
- package/dist/utils/process-exit-wait.d.ts +40 -0
- package/dist/utils/process-exit-wait.d.ts.map +1 -0
- package/dist/utils/process-exit-wait.js +68 -0
- package/dist/utils/process-exit-wait.js.map +1 -0
- package/dist/web/approval-inbox.d.ts +12 -0
- package/dist/web/approval-inbox.d.ts.map +1 -1
- package/dist/web/approval-inbox.js +1 -0
- package/dist/web/approval-inbox.js.map +1 -1
- package/dist/web/middleware/auth.d.ts.map +1 -1
- package/dist/web/middleware/auth.js +33 -0
- package/dist/web/middleware/auth.js.map +1 -1
- package/dist/web/paste-image-gc.d.ts +46 -6
- package/dist/web/paste-image-gc.d.ts.map +1 -1
- package/dist/web/paste-image-gc.js +106 -32
- package/dist/web/paste-image-gc.js.map +1 -1
- package/dist/web/ports/session-port.d.ts +8 -1
- package/dist/web/ports/session-port.d.ts.map +1 -1
- package/dist/web/public/admin-ui.js.gz +0 -0
- package/dist/web/public/api-client.c9b1cddc.js.gz +0 -0
- package/dist/web/public/app.24689381.js +43 -0
- package/dist/web/public/app.24689381.js.br +0 -0
- package/dist/web/public/app.24689381.js.gz +0 -0
- package/dist/web/public/approvals-ui.js +4 -0
- package/dist/web/public/approvals-ui.js.br +0 -0
- package/dist/web/public/approvals-ui.js.gz +0 -0
- package/dist/web/public/{constants.c0f8f9de.js → constants.17d77b65.js} +690 -35
- package/dist/web/public/constants.17d77b65.js.br +0 -0
- package/dist/web/public/constants.17d77b65.js.gz +0 -0
- package/dist/web/public/cron-ui.js.gz +0 -0
- package/dist/web/public/entrance-animations.js +490 -43
- package/dist/web/public/entrance-animations.js.br +0 -0
- package/dist/web/public/entrance-animations.js.gz +0 -0
- package/dist/web/public/gesture/gesture-codeman.js +112 -6
- package/dist/web/public/git-status-ui.js.gz +0 -0
- package/dist/web/public/home-sessions.js +2 -1
- package/dist/web/public/home-sessions.js.br +0 -0
- package/dist/web/public/home-sessions.js.gz +0 -0
- package/dist/web/public/host-wake-ui.js.gz +0 -0
- package/dist/web/public/i18n.9afd8e4d.js +2 -0
- package/dist/web/public/i18n.9afd8e4d.js.br +0 -0
- package/dist/web/public/i18n.9afd8e4d.js.gz +0 -0
- package/dist/web/public/image-input.9ed1d0d2.js +453 -0
- package/dist/web/public/image-input.9ed1d0d2.js.br +0 -0
- package/dist/web/public/image-input.9ed1d0d2.js.gz +0 -0
- package/dist/web/public/index.html +223 -46
- package/dist/web/public/index.html.br +0 -0
- package/dist/web/public/index.html.gz +0 -0
- package/dist/web/public/input-cjk.8bc46081.js.gz +0 -0
- package/dist/web/public/{keyboard-accessory.c1e7c7b7.js → keyboard-accessory.c458eea8.js} +115 -35
- package/dist/web/public/keyboard-accessory.c458eea8.js.br +0 -0
- package/dist/web/public/keyboard-accessory.c458eea8.js.gz +0 -0
- package/dist/web/public/mobile-handlers.0ef9c311.js.gz +0 -0
- package/dist/web/public/mobile-ime-preview.2dbaf262.js.gz +0 -0
- package/dist/web/public/mobile-overview.js +11 -2
- package/dist/web/public/mobile-overview.js.br +0 -0
- package/dist/web/public/mobile-overview.js.gz +0 -0
- package/dist/web/public/mobile.2dff1580.css +1 -0
- package/dist/web/public/mobile.2dff1580.css.br +0 -0
- package/dist/web/public/mobile.2dff1580.css.gz +0 -0
- package/dist/web/public/{notification-manager.50bb87d8.js → notification-manager.feb82c04.js} +19 -2
- package/dist/web/public/notification-manager.feb82c04.js.br +0 -0
- package/dist/web/public/notification-manager.feb82c04.js.gz +0 -0
- package/dist/web/public/orchestrator-panel.js.gz +0 -0
- package/dist/web/public/{panels-ui.4735dfc2.js → panels-ui.73ec0137.js} +87 -87
- package/dist/web/public/panels-ui.73ec0137.js.br +0 -0
- package/dist/web/public/panels-ui.73ec0137.js.gz +0 -0
- package/dist/web/public/ralph-panel.b350e2ef.js.gz +0 -0
- package/dist/web/public/ralph-wizard.b6c0dd19.js.gz +0 -0
- package/dist/web/public/readmymind-ui.js.gz +0 -0
- package/dist/web/public/reboot-restore-ui.js.gz +0 -0
- package/dist/web/public/respawn-ui.ff0dae4c.js.gz +0 -0
- package/dist/web/public/sanitize-html.e7147e76.js.gz +0 -0
- package/dist/web/public/session-lineage.js.gz +0 -0
- package/dist/web/public/{session-ui.f731602c.js → session-ui.dbc6819b.js} +33 -33
- package/dist/web/public/session-ui.dbc6819b.js.br +0 -0
- package/dist/web/public/session-ui.dbc6819b.js.gz +0 -0
- package/dist/web/public/settings-ui.81ac264e.js +97 -0
- package/dist/web/public/settings-ui.81ac264e.js.br +0 -0
- package/dist/web/public/settings-ui.81ac264e.js.gz +0 -0
- package/dist/web/public/spreadsheet-preview-worker.js +11 -0
- package/dist/web/public/spreadsheet-preview-worker.js.br +0 -0
- package/dist/web/public/spreadsheet-preview-worker.js.gz +0 -0
- package/dist/web/public/{spreadsheet-preview.af82fa2f.js → spreadsheet-preview.75c751de.js} +1 -1
- package/dist/web/public/spreadsheet-preview.75c751de.js.br +0 -0
- package/dist/web/public/spreadsheet-preview.75c751de.js.gz +0 -0
- package/dist/web/public/spreadsheet-xlsx-core.js +110 -0
- package/dist/web/public/spreadsheet-xlsx-core.js.br +0 -0
- package/dist/web/public/spreadsheet-xlsx-core.js.gz +0 -0
- package/dist/web/public/styles.900a28e4.css +1 -0
- package/dist/web/public/styles.900a28e4.css.br +0 -0
- package/dist/web/public/styles.900a28e4.css.gz +0 -0
- package/dist/web/public/{subagent-windows.e6ca799f.js → subagent-windows.07277a58.js} +7 -5
- package/dist/web/public/subagent-windows.07277a58.js.br +0 -0
- package/dist/web/public/subagent-windows.07277a58.js.gz +0 -0
- package/dist/web/public/sw.js +6 -2
- package/dist/web/public/sw.js.br +0 -0
- package/dist/web/public/sw.js.gz +0 -0
- package/dist/web/public/tab-layout-browser.2d6189f7.js +1 -0
- package/dist/web/public/tab-layout-browser.2d6189f7.js.br +0 -0
- package/dist/web/public/tab-layout-browser.2d6189f7.js.gz +0 -0
- package/dist/web/public/{tab-rail-resize.a5867793.js → tab-rail-resize.7157e0bd.js} +1 -1
- package/dist/web/public/tab-rail-resize.7157e0bd.js.br +0 -0
- package/dist/web/public/tab-rail-resize.7157e0bd.js.gz +0 -0
- package/dist/web/public/terminal-keycode229-recovery.af09f99a.js.gz +0 -0
- package/dist/web/public/terminal-split.js.gz +0 -0
- package/dist/web/public/terminal-tile.js +135 -38
- package/dist/web/public/terminal-tile.js.br +0 -0
- package/dist/web/public/terminal-tile.js.gz +0 -0
- package/dist/web/public/terminal-ui.858f5da5.js +2 -0
- package/dist/web/public/terminal-ui.858f5da5.js.br +0 -0
- package/dist/web/public/terminal-ui.858f5da5.js.gz +0 -0
- package/dist/web/public/tile-grid.js +769 -137
- package/dist/web/public/tile-grid.js.br +0 -0
- package/dist/web/public/tile-grid.js.gz +0 -0
- package/dist/web/public/ultracode-panel.js.gz +0 -0
- package/dist/web/public/ultracode-windows.js +9 -6
- package/dist/web/public/ultracode-windows.js.br +0 -0
- package/dist/web/public/ultracode-windows.js.gz +0 -0
- package/dist/web/public/vendor/dompurify.min.js.gz +0 -0
- package/dist/web/public/vendor/exceljs.min.js.gz +0 -0
- package/dist/web/public/vendor/fflate.min.js.gz +0 -0
- package/dist/web/public/vendor/marked.min.js.gz +0 -0
- package/dist/web/public/vendor/xterm-addon-fit.min.js.gz +0 -0
- package/dist/web/public/vendor/xterm-addon-serialize.min.js.gz +0 -0
- package/dist/web/public/vendor/xterm-addon-unicode11.min.js.gz +0 -0
- package/dist/web/public/vendor/xterm-addon-webgl.min.js.gz +0 -0
- package/dist/web/public/vendor/xterm-predictive-echo.ae5e3d5d.js.gz +0 -0
- package/dist/web/public/vendor/xterm-zerolag-input.0b69623c.js +4 -0
- package/dist/web/public/vendor/xterm-zerolag-input.0b69623c.js.br +0 -0
- package/dist/web/public/vendor/xterm-zerolag-input.0b69623c.js.gz +0 -0
- package/dist/web/public/vendor/xterm.css.gz +0 -0
- package/dist/web/public/vendor/xterm.min.js.gz +0 -0
- package/dist/web/public/voice-input.2342c37b.js.gz +0 -0
- package/dist/web/public/voice-pcm-worklet.js.gz +0 -0
- package/dist/web/public/webview-tabs.js +12 -0
- package/dist/web/public/webview-tabs.js.br +0 -0
- package/dist/web/public/webview-tabs.js.gz +0 -0
- package/dist/web/route-helpers.d.ts +8 -0
- package/dist/web/route-helpers.d.ts.map +1 -1
- package/dist/web/route-helpers.js +8 -0
- package/dist/web/route-helpers.js.map +1 -1
- package/dist/web/routes/admin-routes.d.ts.map +1 -1
- package/dist/web/routes/admin-routes.js +4 -1
- package/dist/web/routes/admin-routes.js.map +1 -1
- package/dist/web/routes/approval-routes.d.ts.map +1 -1
- package/dist/web/routes/approval-routes.js +6 -8
- package/dist/web/routes/approval-routes.js.map +1 -1
- package/dist/web/routes/case-routes.js +1 -1
- package/dist/web/routes/case-routes.js.map +1 -1
- package/dist/web/routes/cli-registry-routes.d.ts +23 -4
- package/dist/web/routes/cli-registry-routes.d.ts.map +1 -1
- package/dist/web/routes/cli-registry-routes.js +92 -7
- package/dist/web/routes/cli-registry-routes.js.map +1 -1
- package/dist/web/routes/file-routes.d.ts +46 -1
- package/dist/web/routes/file-routes.d.ts.map +1 -1
- package/dist/web/routes/file-routes.js +214 -27
- package/dist/web/routes/file-routes.js.map +1 -1
- package/dist/web/routes/hook-event-routes.d.ts.map +1 -1
- package/dist/web/routes/hook-event-routes.js +8 -1
- package/dist/web/routes/hook-event-routes.js.map +1 -1
- package/dist/web/routes/html-view-routes.d.ts +60 -0
- package/dist/web/routes/html-view-routes.d.ts.map +1 -0
- package/dist/web/routes/html-view-routes.js +288 -0
- package/dist/web/routes/html-view-routes.js.map +1 -0
- package/dist/web/routes/index.d.ts +1 -0
- package/dist/web/routes/index.d.ts.map +1 -1
- package/dist/web/routes/index.js +1 -0
- package/dist/web/routes/index.js.map +1 -1
- package/dist/web/routes/mcp-sync-routes.d.ts +7 -2
- package/dist/web/routes/mcp-sync-routes.d.ts.map +1 -1
- package/dist/web/routes/mcp-sync-routes.js +10 -3
- package/dist/web/routes/mcp-sync-routes.js.map +1 -1
- package/dist/web/routes/reboot-restore-routes.js +3 -3
- package/dist/web/routes/reboot-restore-routes.js.map +1 -1
- package/dist/web/routes/session-routes.d.ts +1 -0
- package/dist/web/routes/session-routes.d.ts.map +1 -1
- package/dist/web/routes/session-routes.js +345 -90
- package/dist/web/routes/session-routes.js.map +1 -1
- package/dist/web/routes/status-telemetry-routes.d.ts.map +1 -1
- package/dist/web/routes/status-telemetry-routes.js +16 -2
- package/dist/web/routes/status-telemetry-routes.js.map +1 -1
- package/dist/web/schemas.d.ts +32 -0
- package/dist/web/schemas.d.ts.map +1 -1
- package/dist/web/schemas.js +74 -0
- package/dist/web/schemas.js.map +1 -1
- package/dist/web/server.d.ts +7 -2
- package/dist/web/server.d.ts.map +1 -1
- package/dist/web/server.js +77 -24
- package/dist/web/server.js.map +1 -1
- package/dist/web/sse-events.d.ts +5 -2
- package/dist/web/sse-events.d.ts.map +1 -1
- package/dist/web/sse-events.js +5 -2
- package/dist/web/sse-events.js.map +1 -1
- package/package.json +5 -4
- package/skills/codeman/SKILL.md +30 -7
- package/README.zh-CN.md +0 -1147
- package/dist/web/public/app.abadc970.js +0 -43
- package/dist/web/public/app.abadc970.js.br +0 -0
- package/dist/web/public/app.abadc970.js.gz +0 -0
- package/dist/web/public/constants.c0f8f9de.js.br +0 -0
- package/dist/web/public/constants.c0f8f9de.js.gz +0 -0
- package/dist/web/public/i18n.a31747a9.js +0 -2
- package/dist/web/public/i18n.a31747a9.js.br +0 -0
- package/dist/web/public/i18n.a31747a9.js.gz +0 -0
- package/dist/web/public/image-input.898a538b.js +0 -281
- package/dist/web/public/image-input.898a538b.js.br +0 -0
- package/dist/web/public/image-input.898a538b.js.gz +0 -0
- package/dist/web/public/keyboard-accessory.c1e7c7b7.js.br +0 -0
- package/dist/web/public/keyboard-accessory.c1e7c7b7.js.gz +0 -0
- package/dist/web/public/mobile.111b187d.css +0 -1
- package/dist/web/public/mobile.111b187d.css.br +0 -0
- package/dist/web/public/mobile.111b187d.css.gz +0 -0
- package/dist/web/public/notification-manager.50bb87d8.js.br +0 -0
- package/dist/web/public/notification-manager.50bb87d8.js.gz +0 -0
- package/dist/web/public/panels-ui.4735dfc2.js.br +0 -0
- package/dist/web/public/panels-ui.4735dfc2.js.gz +0 -0
- package/dist/web/public/session-ui.f731602c.js.br +0 -0
- package/dist/web/public/session-ui.f731602c.js.gz +0 -0
- package/dist/web/public/settings-ui.a97ab16f.js +0 -97
- package/dist/web/public/settings-ui.a97ab16f.js.br +0 -0
- package/dist/web/public/settings-ui.a97ab16f.js.gz +0 -0
- package/dist/web/public/spreadsheet-preview.af82fa2f.js.br +0 -0
- package/dist/web/public/spreadsheet-preview.af82fa2f.js.gz +0 -0
- package/dist/web/public/styles.8ee12666.css +0 -1
- package/dist/web/public/styles.8ee12666.css.br +0 -0
- package/dist/web/public/styles.8ee12666.css.gz +0 -0
- package/dist/web/public/subagent-windows.e6ca799f.js.br +0 -0
- package/dist/web/public/subagent-windows.e6ca799f.js.gz +0 -0
- package/dist/web/public/tab-layout-browser.065fcba4.js +0 -1
- package/dist/web/public/tab-layout-browser.065fcba4.js.br +0 -0
- package/dist/web/public/tab-layout-browser.065fcba4.js.gz +0 -0
- package/dist/web/public/tab-rail-resize.a5867793.js.br +0 -0
- package/dist/web/public/tab-rail-resize.a5867793.js.gz +0 -0
- package/dist/web/public/terminal-ui.5059b30e.js +0 -2
- package/dist/web/public/terminal-ui.5059b30e.js.br +0 -0
- package/dist/web/public/terminal-ui.5059b30e.js.gz +0 -0
- package/dist/web/public/vendor/xterm-zerolag-input.bbb5f84c.js +0 -4
- package/dist/web/public/vendor/xterm-zerolag-input.bbb5f84c.js.br +0 -0
- package/dist/web/public/vendor/xterm-zerolag-input.bbb5f84c.js.gz +0 -0
|
@@ -0,0 +1,453 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Image Input Mixin - Clipboard paste and drag-and-drop image support
|
|
3
|
+
*
|
|
4
|
+
* For paste: intercepts Ctrl+V at the xterm keyboard level, creates a temporary
|
|
5
|
+
* hidden contenteditable div ("paste trap"), lets the browser's native paste fill
|
|
6
|
+
* it, then checks for image data. This works on HTTP (no secure context needed).
|
|
7
|
+
*
|
|
8
|
+
* For drag-and-drop: listens on the terminal container for file drops.
|
|
9
|
+
*
|
|
10
|
+
* @dependency app.js (uses global `app` for sendInput, activeSessionId, showToast)
|
|
11
|
+
* @dependency panels-ui.js (provides showToast)
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
Object.assign(CodemanApp.prototype, {
|
|
15
|
+
|
|
16
|
+
initImageInput() {
|
|
17
|
+
// Phone toolbar Attach (index.html): the tap lands on the file INPUT, which
|
|
18
|
+
// sits invisibly over the button, and the browser opens the picker itself.
|
|
19
|
+
//
|
|
20
|
+
// Nothing here may open the picker from script. While the keyboard is up,
|
|
21
|
+
// app.js's keyboard tap shim cancels every touch on a toolbar BUTTON and
|
|
22
|
+
// re-dispatches it as a scripted click, and iOS will not open a file picker
|
|
23
|
+
// from an untrusted click: measured on iOS 17, `isTrusted: false` two
|
|
24
|
+
// milliseconds after pointerdown, no picker, no error, and no real click after
|
|
25
|
+
// it either because the gesture was cancelled. A native tap on the input is not
|
|
26
|
+
// a button tap, so the shim leaves it alone and the click stays trusted. It also
|
|
27
|
+
// anchors the picker popover to the input's own frame (a `hidden` input has
|
|
28
|
+
// none, which is why the composer's picker floated loose).
|
|
29
|
+
//
|
|
30
|
+
// Cancelling mousedown's default keeps focus on the terminal's hidden textarea,
|
|
31
|
+
// so the fixed toolbar never reflows out from under the finger mid-tap.
|
|
32
|
+
//
|
|
33
|
+
// It does NOT keep the keyboard up, and nothing here can: iOS resigns first
|
|
34
|
+
// responder to present the picker, and on DEVICE it does not hand the keyboard
|
|
35
|
+
// back when the picker closes, even though focus never left the textarea.
|
|
36
|
+
// (An iOS 27 simulator does hand it back, which is a simulator artifact, so do
|
|
37
|
+
// not take a keyboard measurement from one.) A blur-then-focus on the close
|
|
38
|
+
// of the picker, cancel and change alike, restores DOM focus and still leaves
|
|
39
|
+
// the keyboard down, because iOS only raises it inside a user gesture. So the
|
|
40
|
+
// user taps the prompt when ready to type, which is the keyboard policy
|
|
41
|
+
// everywhere else in the app.
|
|
42
|
+
//
|
|
43
|
+
// Do not try to hide the keyboard's exit by freezing the page's keyboard
|
|
44
|
+
// state across the picker. That was tried (holdKeyboardState, reverted) to
|
|
45
|
+
// avoid the reflow and the two PTY resizes the hide/show pair costs: with the
|
|
46
|
+
// keyboard gone and the layout still keyboard-sized, the toolbar and accessory
|
|
47
|
+
// bar strand in the MIDDLE of the screen over a dead black band, which is far
|
|
48
|
+
// worse than the reflow. The page must follow the keyboard out.
|
|
49
|
+
const attachBtn = document.getElementById('attachBtnMobile');
|
|
50
|
+
const attachInput = document.getElementById('attachFileInput');
|
|
51
|
+
if (attachBtn && attachInput) {
|
|
52
|
+
attachInput.addEventListener('mousedown', (event) => event.preventDefault());
|
|
53
|
+
// The button is the ACCESSIBLE control (the input is aria-hidden and not
|
|
54
|
+
// tabbable), so a keyboard or VoiceOver activation arrives here instead of on
|
|
55
|
+
// the input. That click is trusted, so opening the picker from it is allowed.
|
|
56
|
+
attachBtn.addEventListener('click', () => attachInput.click());
|
|
57
|
+
attachInput.addEventListener('click', (event) => {
|
|
58
|
+
if (!this.activeSessionId) {
|
|
59
|
+
event.preventDefault(); // refuse before the picker opens, not after a pick
|
|
60
|
+
this.showToast('Open a session to attach a file', 'error');
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
// Captured before the picker opens: the session can be deleted while it is
|
|
64
|
+
// up, and the pick must not land on whatever replaced it.
|
|
65
|
+
this._attachSessionId = this.activeSessionId;
|
|
66
|
+
});
|
|
67
|
+
attachInput.addEventListener('change', () => {
|
|
68
|
+
const files = Array.from(attachInput.files || []);
|
|
69
|
+
// Reset so picking the same file again still fires change.
|
|
70
|
+
attachInput.value = '';
|
|
71
|
+
// Captured when the picker opened (see the click handler above).
|
|
72
|
+
const sessionId = this._attachSessionId;
|
|
73
|
+
this._attachSessionId = null;
|
|
74
|
+
if (files.length > 0) {
|
|
75
|
+
void this._attachFromToolbar(files, sessionId).catch((err) => console.warn('Attach failed:', err));
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Drag-and-drop handlers on terminal container
|
|
81
|
+
const container = document.getElementById('terminalContainer');
|
|
82
|
+
if (!container) return;
|
|
83
|
+
|
|
84
|
+
container.addEventListener('dragover', (e) => {
|
|
85
|
+
e.preventDefault();
|
|
86
|
+
if (e.dataTransfer && e.dataTransfer.types.includes('Files')) {
|
|
87
|
+
container.classList.add('drag-active');
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
container.addEventListener('dragleave', (e) => {
|
|
92
|
+
if (!container.contains(e.relatedTarget)) {
|
|
93
|
+
container.classList.remove('drag-active');
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
container.addEventListener('drop', (e) => {
|
|
98
|
+
e.preventDefault();
|
|
99
|
+
container.classList.remove('drag-active');
|
|
100
|
+
|
|
101
|
+
if (!this.activeSessionId) return;
|
|
102
|
+
if (!e.dataTransfer || !e.dataTransfer.files.length) return;
|
|
103
|
+
|
|
104
|
+
const mediaFiles = Array.from(e.dataTransfer.files).filter((f) => this._promptAttachKind(f));
|
|
105
|
+
if (mediaFiles.length === 0) {
|
|
106
|
+
this.showToast('Only image or video files are supported', 'error');
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
this._uploadAndInsertImages(mediaFiles);
|
|
110
|
+
});
|
|
111
|
+
},
|
|
112
|
+
|
|
113
|
+
/** Upload the toolbar Attach selection and put the paths on the prompt. The
|
|
114
|
+
* Compose dialog is deliberately not involved (see initImageInput).
|
|
115
|
+
*
|
|
116
|
+
* With local echo on (every phone by default) the paths go into the OVERLAY as
|
|
117
|
+
* pending text rather than to the PTY. That is what makes them part of "what the
|
|
118
|
+
* user typed": they paint at the prompt, anything typed after them appends in
|
|
119
|
+
* order, Enter flushes the whole line, and opening Compose adopts path and prose
|
|
120
|
+
* together (_takePendingLocalEcho reads exactly this buffer). Writing to the PTY
|
|
121
|
+
* instead would strand the path on the terminal while Compose took only the prose.
|
|
122
|
+
* Without the overlay (local echo off) there is nothing to buffer into, so the
|
|
123
|
+
* text goes straight out. The trailing space keeps the next word off the path. */
|
|
124
|
+
async _attachFromToolbar(files, sessionId) {
|
|
125
|
+
const media = files.filter((f) => this._promptAttachKind(f));
|
|
126
|
+
if (media.length === 0) {
|
|
127
|
+
this.showToast('Only image or video files are supported', 'error');
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
if (!sessionId) return; // no captured session: refuse rather than guess at one
|
|
131
|
+
const paths = await this._uploadAndInsertImages(media, { insert: false, sessionId });
|
|
132
|
+
if (paths.length === 0) return;
|
|
133
|
+
// Deleted while the upload ran: a write would go to a dead session, and the
|
|
134
|
+
// flushed maps below would gain an entry whose cleanup has already run.
|
|
135
|
+
if (this.sessions && !this.sessions.has(sessionId)) return;
|
|
136
|
+
const text = paths.join(' ');
|
|
137
|
+
|
|
138
|
+
// The prompt composer, if it opened for this session while the upload ran, is
|
|
139
|
+
// where the prompt now lives: it took the terminal's pending text with it, so
|
|
140
|
+
// appending behind it would strand the path outside the text being composed.
|
|
141
|
+
// _insertComposerText adds its own separator and fires the draft save.
|
|
142
|
+
const openComposer = KeyboardAccessoryBar._composerOverlay;
|
|
143
|
+
const composerTextarea =
|
|
144
|
+
openComposer?.isConnected && openComposer.dataset.sessionId === sessionId
|
|
145
|
+
? openComposer.querySelector('.prompt-composer-textarea')
|
|
146
|
+
: null;
|
|
147
|
+
if (composerTextarea) {
|
|
148
|
+
KeyboardAccessoryBar._insertComposerText(composerTextarea, text);
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const stillActive = this.activeSessionId === sessionId;
|
|
153
|
+
// The overlay holds the prompt only while local echo actually owns it. A
|
|
154
|
+
// session handed back to plain PTY echo by a composer nav key (echo
|
|
155
|
+
// passthrough) skips the whole overlay branch in onData, Enter included, so
|
|
156
|
+
// text buffered there would sit on screen and never be submitted. Nor while
|
|
157
|
+
// the tile grid owns the terminal area: the main terminal is parked and
|
|
158
|
+
// hidden then, and a tile's Enter never reaches its overlay, so the path
|
|
159
|
+
// goes to the PTY like any other background write.
|
|
160
|
+
const overlayOwnsPrompt =
|
|
161
|
+
stillActive &&
|
|
162
|
+
!this._tilesOwnTerminal?.() &&
|
|
163
|
+
this._localEchoEnabled &&
|
|
164
|
+
this._localEchoOverlay &&
|
|
165
|
+
!this._echoPassthroughSessions?.has(sessionId);
|
|
166
|
+
if (overlayOwnsPrompt) {
|
|
167
|
+
// Prompt text the overlay has not adopted yet (a tab completion, a restored
|
|
168
|
+
// tab) only lands in its flushed half once detection runs, and appendText
|
|
169
|
+
// runs it AFTER we would have measured. Detect first, or the separator is
|
|
170
|
+
// computed against an empty string and the path fuses onto a real word.
|
|
171
|
+
if (!this._localEchoOverlay.pendingText && !this._localEchoOverlay.getFlushed().count) {
|
|
172
|
+
this._localEchoOverlay.detectBufferText();
|
|
173
|
+
}
|
|
174
|
+
const flushed = this._localEchoOverlay.getFlushed().text || '';
|
|
175
|
+
const prompt = flushed + (this._localEchoOverlay.pendingText || '');
|
|
176
|
+
const gap = prompt && !/\s$/.test(prompt) ? ' ' : '';
|
|
177
|
+
this._localEchoOverlay.appendText(gap + text + ' ');
|
|
178
|
+
} else {
|
|
179
|
+
// No buffer to measure, so lead with a separator unconditionally rather than
|
|
180
|
+
// risk fusing onto whatever the CLI's own composer already holds.
|
|
181
|
+
const out = ` ${text} `;
|
|
182
|
+
this._sendInputAsync(sessionId, out);
|
|
183
|
+
// A background session's prompt is tracked in the flushed maps, which is what
|
|
184
|
+
// a later composer open adopts and erases. An untracked write leaves that
|
|
185
|
+
// count short, so the erase would eat the tail of this path instead. Not for
|
|
186
|
+
// a passthrough session: its cursor can sit mid-text, nothing tracks its
|
|
187
|
+
// composer, and Enter there never clears the maps, so a record would offset
|
|
188
|
+
// the overlay for the next prompt instead.
|
|
189
|
+
if (!stillActive && !this._echoPassthroughSessions?.has(sessionId)) {
|
|
190
|
+
if (!this._flushedOffsets) this._flushedOffsets = new Map();
|
|
191
|
+
if (!this._flushedTexts) this._flushedTexts = new Map();
|
|
192
|
+
this._flushedTexts.set(sessionId, (this._flushedTexts.get(sessionId) || '') + out);
|
|
193
|
+
this._flushedOffsets.set(sessionId, (this._flushedOffsets.get(sessionId) || 0) + out.length);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
},
|
|
197
|
+
|
|
198
|
+
// Called from customKeyEventHandler in terminal-ui.js on Ctrl+V keydown.
|
|
199
|
+
// Creates a hidden paste trap, lets the browser paste into it, then inspects
|
|
200
|
+
// the result for images. Works on plain HTTP (no Clipboard API needed).
|
|
201
|
+
// `target` names the terminal the Ctrl+V came from and its session; both
|
|
202
|
+
// default to the primary pane. A second terminal (the split pane) passes its
|
|
203
|
+
// own, so text pastes into THAT xterm and images upload to THAT session.
|
|
204
|
+
_handleImagePaste(target = {}) {
|
|
205
|
+
const self = this;
|
|
206
|
+
const terminal = target.terminal || this.terminal;
|
|
207
|
+
const sessionId = target.sessionId || this.activeSessionId;
|
|
208
|
+
|
|
209
|
+
// Create a hidden contenteditable div to receive the paste
|
|
210
|
+
const trap = document.createElement('div');
|
|
211
|
+
trap.contentEditable = 'true';
|
|
212
|
+
trap.style.cssText = 'position:fixed;left:-9999px;top:0;width:1px;height:1px;opacity:0;overflow:hidden';
|
|
213
|
+
document.body.appendChild(trap);
|
|
214
|
+
trap.focus();
|
|
215
|
+
|
|
216
|
+
// One Ctrl+V can deliver TWO paste events to this trap. The
|
|
217
|
+
// execCommand('paste') below fires one wherever the browser honours that
|
|
218
|
+
// command, and the key's own default action fires another, because xterm's
|
|
219
|
+
// custom key handler returns false without cancelling the keydown. Handling
|
|
220
|
+
// both sends the clipboard text to the PTY twice, which is the "Ctrl+V
|
|
221
|
+
// pastes twice, right-click Paste does not" report: the context-menu paste
|
|
222
|
+
// has no keydown, so it only ever produces one event. The trap therefore
|
|
223
|
+
// accepts the first paste and drops every later one.
|
|
224
|
+
var pasteConsumed = false;
|
|
225
|
+
|
|
226
|
+
// Listen for the paste event on our trap
|
|
227
|
+
trap.addEventListener('paste', function(e) {
|
|
228
|
+
e.stopPropagation();
|
|
229
|
+
e.preventDefault();
|
|
230
|
+
if (pasteConsumed) return;
|
|
231
|
+
pasteConsumed = true;
|
|
232
|
+
|
|
233
|
+
// Check for images or videos in clipboard items
|
|
234
|
+
var imageFiles = [];
|
|
235
|
+
var items = e.clipboardData && e.clipboardData.items;
|
|
236
|
+
if (items) {
|
|
237
|
+
for (var i = 0; i < items.length; i++) {
|
|
238
|
+
var blob = items[i].getAsFile();
|
|
239
|
+
if (blob && self._promptAttachKind(blob)) imageFiles.push(blob);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Clean up the trap
|
|
244
|
+
setTimeout(function() {
|
|
245
|
+
if (trap.parentNode) trap.parentNode.removeChild(trap);
|
|
246
|
+
// Refocus the terminal
|
|
247
|
+
if (terminal) terminal.focus();
|
|
248
|
+
}, 0);
|
|
249
|
+
|
|
250
|
+
if (imageFiles.length > 0) {
|
|
251
|
+
self._uploadAndInsertImages(imageFiles, { sessionId: sessionId });
|
|
252
|
+
} else {
|
|
253
|
+
// No image -- route text through xterm's paste() so bracketed-paste
|
|
254
|
+
// markers (CSI 200~ ... CSI 201~) survive when the inner application
|
|
255
|
+
// has enabled bracketed-paste mode (Claude Code does). Sending text
|
|
256
|
+
// via raw sendInput() strips those markers and makes pasted input
|
|
257
|
+
// indistinguishable from typed input, weakening the CLI's
|
|
258
|
+
// prompt-injection defenses.
|
|
259
|
+
var text = e.clipboardData ? e.clipboardData.getData('text/plain') : '';
|
|
260
|
+
if (text && terminal) terminal.paste(text);
|
|
261
|
+
}
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
// Trigger the browser's native paste via execCommand
|
|
265
|
+
// (this fires the paste event on our focused trap element)
|
|
266
|
+
document.execCommand('paste');
|
|
267
|
+
},
|
|
268
|
+
|
|
269
|
+
/** What a pick or a paste may upload: 'image', or 'video' for the camera
|
|
270
|
+
* roll's clips (the server streams those to disk under their own cap), else
|
|
271
|
+
* null. The one filter behind Attach, drop, the Compose and paste-dialog
|
|
272
|
+
* pickers and the clipboard handlers. The MIME type decides; some Android
|
|
273
|
+
* file managers hand over a File with none, so the name's extension is the
|
|
274
|
+
* fallback, mirroring the server's own allowlists. */
|
|
275
|
+
_promptAttachKind(file) {
|
|
276
|
+
if (file.type.startsWith('image/')) return 'image';
|
|
277
|
+
if (file.type.startsWith('video/')) return 'video';
|
|
278
|
+
const ext = (file.name || '').toLowerCase().match(/\.([a-z0-9]+)$/)?.[1] || '';
|
|
279
|
+
if (['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'heic', 'heif'].includes(ext)) return 'image';
|
|
280
|
+
if (['mp4', 'webm', 'mov', 'm4v', 'ogv'].includes(ext)) return 'video';
|
|
281
|
+
return null;
|
|
282
|
+
},
|
|
283
|
+
|
|
284
|
+
// Max images accepted in one batch (paste / drop / mobile picker). Each is
|
|
285
|
+
// uploaded as its own request, so 20 stays under the server's 30 uploads/min
|
|
286
|
+
// rate limit while covering "select a bunch of photos at once".
|
|
287
|
+
_maxBatchImages: 20,
|
|
288
|
+
// How many uploads to run concurrently. Small enough that decoding several
|
|
289
|
+
// large images through <canvas> at once won't OOM a phone, large enough that
|
|
290
|
+
// 20 photos don't crawl through serially.
|
|
291
|
+
_uploadConcurrency: 3,
|
|
292
|
+
|
|
293
|
+
/** Upload a batch and normally insert its paths into the active terminal.
|
|
294
|
+
* The prompt composer passes `{ insert: false }` so it can put those paths
|
|
295
|
+
* into its textarea instead. `options.sessionId` names the session to upload
|
|
296
|
+
* to (default: the active one). Returns successful paths in selection order. */
|
|
297
|
+
async _uploadAndInsertImages(fileList, options = {}) {
|
|
298
|
+
const sessionId = options.sessionId || this.activeSessionId;
|
|
299
|
+
if (!sessionId) return [];
|
|
300
|
+
if (options.sessionId && this.sessions && !this.sessions.has(sessionId)) return [];
|
|
301
|
+
|
|
302
|
+
let files = Array.from(fileList || []);
|
|
303
|
+
if (files.length === 0) return [];
|
|
304
|
+
|
|
305
|
+
// Cap the batch and tell the user what got dropped (no silent truncation).
|
|
306
|
+
let capped = false;
|
|
307
|
+
if (files.length > this._maxBatchImages) {
|
|
308
|
+
files = files.slice(0, this._maxBatchImages);
|
|
309
|
+
capped = true;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const total = files.length;
|
|
313
|
+
let done = 0;
|
|
314
|
+
let failed = 0;
|
|
315
|
+
let failReason = ''; // first server reason, shown in the toast so a failure is not just a count
|
|
316
|
+
const results = new Array(total); // preserve selection order for insertion
|
|
317
|
+
const progress = () =>
|
|
318
|
+
this.showToast(`Uploading ${Math.min(done + 1, total)}/${total} file${total > 1 ? 's' : ''}…`, 'info');
|
|
319
|
+
progress();
|
|
320
|
+
|
|
321
|
+
// Bounded-concurrency worker pool over the file list.
|
|
322
|
+
let next = 0;
|
|
323
|
+
const worker = async () => {
|
|
324
|
+
for (;;) {
|
|
325
|
+
const i = next++;
|
|
326
|
+
if (i >= total) return;
|
|
327
|
+
try {
|
|
328
|
+
// Re-encode to a standard JPEG/PNG (and downscale very large images)
|
|
329
|
+
// before upload. Galleries on some phones (notably Android/MIUI) hand
|
|
330
|
+
// back a WebP/HEIF whose filename and MIME claim "image/jpeg", which
|
|
331
|
+
// passes the server's extension allowlist but fails its magic-byte
|
|
332
|
+
// check. Decoding through the browser and re-encoding guarantees the
|
|
333
|
+
// bytes match the extension we send — and shrinks huge photos so they
|
|
334
|
+
// fit the upload limit and iOS's <canvas> area cap.
|
|
335
|
+
const normalized = await this._normalizeImageForUpload(files[i]);
|
|
336
|
+
results[i] = await this._uploadPasteImage(sessionId, normalized);
|
|
337
|
+
} catch (err) {
|
|
338
|
+
failed++;
|
|
339
|
+
if (!failReason && err && err.message) failReason = err.message;
|
|
340
|
+
console.warn('Image upload failed:', err);
|
|
341
|
+
results[i] = null;
|
|
342
|
+
} finally {
|
|
343
|
+
done++;
|
|
344
|
+
if (done < total) progress();
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
};
|
|
348
|
+
await Promise.all(Array.from({ length: Math.min(this._uploadConcurrency, total) }, () => worker()));
|
|
349
|
+
|
|
350
|
+
const paths = results.filter(Boolean);
|
|
351
|
+
if (paths.length > 0 && options.insert !== false) {
|
|
352
|
+
// Insert all paths in one shot, space-separated, in selection order, into
|
|
353
|
+
// the session the batch was uploaded TO. Not sendInput(): it re-reads
|
|
354
|
+
// activeSessionId, and after the awaits above that is whatever tab the
|
|
355
|
+
// user switched to mid-upload, so the paths landed in the wrong session.
|
|
356
|
+
// Same delivery sendInput() uses (durable queue, useMux for the POST path).
|
|
357
|
+
this._sendInputAsync(sessionId, paths.join(' '), { useMux: true });
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// Final status: successes, plus any failures / cap so nothing is silent.
|
|
361
|
+
const parts = [];
|
|
362
|
+
if (paths.length > 0) parts.push(`${paths.length} file${paths.length > 1 ? 's' : ''} ready`);
|
|
363
|
+
if (failed > 0) parts.push(failReason ? `${failed} failed: ${failReason}` : `${failed} failed`);
|
|
364
|
+
if (capped) parts.push(`max ${this._maxBatchImages} per batch`);
|
|
365
|
+
const tone = paths.length > 0 ? (failed > 0 || capped ? 'info' : 'success') : 'error';
|
|
366
|
+
this.showToast(parts.join(' · ') || 'No files uploaded', tone);
|
|
367
|
+
return paths;
|
|
368
|
+
},
|
|
369
|
+
|
|
370
|
+
async _uploadPasteImage(sessionId, file) {
|
|
371
|
+
const form = new FormData();
|
|
372
|
+
form.append('image', file);
|
|
373
|
+
|
|
374
|
+
// A video takes the server's disk-streamed path and its larger cap; the flag
|
|
375
|
+
// has to ride the URL because the size budget is fixed before the part is read.
|
|
376
|
+
const kind = this._promptAttachKind(file) === 'video' ? '?kind=video' : '';
|
|
377
|
+
const resp = await fetch('/api/sessions/' + sessionId + '/paste-image' + kind, {
|
|
378
|
+
method: 'POST',
|
|
379
|
+
body: form,
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
if (!resp.ok) {
|
|
383
|
+
const data = await resp.json().catch(() => ({}));
|
|
384
|
+
throw new Error(data.error || 'HTTP ' + resp.status);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
const data = await resp.json();
|
|
388
|
+
return data.data.path;
|
|
389
|
+
},
|
|
390
|
+
|
|
391
|
+
// Decode an image File through the browser and re-encode it to a format the
|
|
392
|
+
// server accepts, so the uploaded bytes always match their declared
|
|
393
|
+
// extension. PNG is re-encoded as PNG (preserves transparency); everything
|
|
394
|
+
// else (JPEG, WebP, HEIF, unknown) becomes JPEG. Animated GIFs are passed
|
|
395
|
+
// through untouched since a canvas would flatten them to one frame. On any
|
|
396
|
+
// decode/encode failure the original file is returned unchanged so the server
|
|
397
|
+
// still gets a chance (and logs a precise diagnostic).
|
|
398
|
+
async _normalizeImageForUpload(file) {
|
|
399
|
+
// Only an image goes through the canvas; a video is uploaded as picked. With no
|
|
400
|
+
// MIME (some Android file managers) the name's extension stands in, as it does in
|
|
401
|
+
// _promptAttachKind, so a .gif keeps its frames and a .png its transparency.
|
|
402
|
+
const name = (file.name || '').toLowerCase();
|
|
403
|
+
const isGif = file.type === 'image/gif' || (!file.type && name.endsWith('.gif'));
|
|
404
|
+
if (this._promptAttachKind(file) !== 'image' || isGif) return file;
|
|
405
|
+
|
|
406
|
+
const toPng = file.type === 'image/png' || (!file.type && name.endsWith('.png'));
|
|
407
|
+
const url = URL.createObjectURL(file);
|
|
408
|
+
try {
|
|
409
|
+
const img = new Image();
|
|
410
|
+
await new Promise((resolve, reject) => {
|
|
411
|
+
img.onload = () => resolve();
|
|
412
|
+
img.onerror = () => reject(new Error('decode failed'));
|
|
413
|
+
img.src = url;
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
const width = img.naturalWidth;
|
|
417
|
+
const height = img.naturalHeight;
|
|
418
|
+
if (!width || !height) return file;
|
|
419
|
+
|
|
420
|
+
// Downscale very large images. Two reasons: (1) iOS Safari refuses to
|
|
421
|
+
// render a <canvas> larger than ~16.7M px (it returns a blank/null
|
|
422
|
+
// blob), so a 48MP photo would otherwise fail to re-encode and fall back
|
|
423
|
+
// to the original — which then trips the server's magic-byte check for
|
|
424
|
+
// HEIF mislabeled as JPEG. (2) It keeps multi-photo uploads fast and well
|
|
425
|
+
// under the size limit. Cap the longest edge so area stays safely below
|
|
426
|
+
// the canvas limit while still uploading a large, high-quality image.
|
|
427
|
+
const MAX_EDGE = 4096;
|
|
428
|
+
const scale = Math.min(1, MAX_EDGE / Math.max(width, height));
|
|
429
|
+
const w = Math.max(1, Math.round(width * scale));
|
|
430
|
+
const h = Math.max(1, Math.round(height * scale));
|
|
431
|
+
|
|
432
|
+
const canvas = document.createElement('canvas');
|
|
433
|
+
canvas.width = w;
|
|
434
|
+
canvas.height = h;
|
|
435
|
+
const ctx = canvas.getContext('2d');
|
|
436
|
+
if (!ctx) return file;
|
|
437
|
+
ctx.drawImage(img, 0, 0, w, h);
|
|
438
|
+
|
|
439
|
+
const mime = toPng ? 'image/png' : 'image/jpeg';
|
|
440
|
+
const blob = await new Promise((resolve) => canvas.toBlob(resolve, mime, 0.92));
|
|
441
|
+
if (!blob) return file;
|
|
442
|
+
|
|
443
|
+
const baseName = (file.name || 'image').replace(/\.[^.]+$/, '') || 'image';
|
|
444
|
+
return new File([blob], baseName + (toPng ? '.png' : '.jpg'), { type: mime });
|
|
445
|
+
} catch (err) {
|
|
446
|
+
console.warn('Image re-encode failed, uploading original:', err);
|
|
447
|
+
return file;
|
|
448
|
+
} finally {
|
|
449
|
+
URL.revokeObjectURL(url);
|
|
450
|
+
}
|
|
451
|
+
},
|
|
452
|
+
|
|
453
|
+
});
|
|
Binary file
|
|
Binary file
|