diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fa1d3ca38..28eb8968c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,6 +26,8 @@ jobs: run: npm run audit:secrets - name: Run tests run: npm test + - name: Test Even G2 companion + run: npm run test:even - name: Type-check run: npm run typecheck - name: Build production bundles diff --git a/README.md b/README.md index 7f2879f83..7915c655b 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,10 @@ npm run dev | [Runtime plugin authoring](docs/plugins.md) | [Typed plugin SDK](docs/plugin-api.d.ts) | | [Changelog](CHANGELOG.md) | [MIT license](LICENSE) | +## Even G2 companion + +The opt-in **Settings → Controls → Even G2** integration adapts CanvasTTY's terminal and AI-agent workflows to Even G2 glasses. Pair with six digits on the same local network, approve access on the computer, then read responses on the glasses HUD, dictate through local Nemotron speech recognition, and create, rename or close shared sessions. The companion includes a More agents picker and supports the desktop's provider list. Its source lives in `integrations/even-g2` and builds with the desktop. See [setup, distribution status and acceptance limits](docs/even-g2.md); a compatible public installer and Even Hub approval are still pending. + ## Runtime plugins CanvasTTY includes a permissioned runtime for ready-to-run static GitHub packages: HOME widgets, canvas apps, and separate sandboxed windows. The host SDK now supports persistent user-selected music-library grants, seekable local audio streams, and bounded playlist import/export for full player plugins. See the [authoring and security guide](docs/plugins.md), [manifest schema](docs/canvastty-plugin.schema.json), and [TypeScript SDK declarations](docs/plugin-api.d.ts). @@ -78,6 +82,7 @@ Every browser command produces a redacted local activity record. Persistent JSON ```bash npm test +npm run test:even npm run typecheck npm run build ``` diff --git a/docs/even-g2-browser-voice-next.md b/docs/even-g2-browser-voice-next.md new file mode 100644 index 000000000..f74923e62 --- /dev/null +++ b/docs/even-g2-browser-voice-next.md @@ -0,0 +1,9 @@ +# Even G2 browser and speech scope + +The implemented companion reads and controls shared terminal sessions, creates supported agent sessions and submits push-to-talk dictation. Its Browser action opens the existing browser on the computer. + +Full browser navigation from the glasses is future work: readable-page extraction, element selection, scrolling, typed/voice interaction, explicit confirmations and return to the original terminal would need their own interaction and safety contracts. No webpage screenshot or link-selection HUD is advertised as implemented. + +Speech recognition currently runs locally on the Mac through the bundled transcribe.cpp/Nemotron path or a supported Handy installation. G2 PCM enters the recognition helper directly. External speech applications such as Wispr Flow are not integrated, and G2 audio is not exposed as a general OS microphone by this feature. + +Future browser or speech-provider integrations should preserve per-session grants, require real microphone acknowledgements and include physical glasses acceptance. See [the current setup and limitations](even-g2.md). diff --git a/docs/even-g2-onboarding-progress.md b/docs/even-g2-onboarding-progress.md new file mode 100644 index 000000000..41401b045 --- /dev/null +++ b/docs/even-g2-onboarding-progress.md @@ -0,0 +1,30 @@ +# Even G2 acceptance record + +## Current flow + +Install a compatible desktop build and Even App companion, enable Even G2 in CanvasTTY Controls, enter six digits and approve scoped device access on the computer. Local discovery uses eight reserved Bonjour names. No separately operated relay is required. The integration never changes the device's VPN or firewall settings. + +## Reported physical acceptance + +On 14 September 2026 the user confirmed connection, microphone input, terminal interaction and session creation on their Mac/Android/Even G2 setup. The subsequent More agents menu was also user-confirmed. These reports do not establish fresh-machine installation, detached-USB operation or background/reconnect behavior. + +## Version 0.5.6 packaging correction + +The 0.5.5 store review rejected a dynamic local URL template despite the intended hosts being listed in the manifest. Version 0.5.6 writes the eight origins as complete literals and validates emitted bundle URLs against the manifest before packing. The handoff records 622 desktop and 47 companion tests, type checks, builds, secret audit, and a negative control that rejects the former packaged frontend. + +The corrected package was submitted for Even Hub review on 15 September 2026; approval is pending. A fresh 0.5.6 phone installation and physical voice run were not performed. The submitted package is immutable for this Git publication task. + +## Integration with current upstream + +Pi and OMP remain available in the desktop launcher and are included in the companion's provider lists and More agents picker. Their phone buttons and glasses selection routes are covered by the provider-enumerating tests. Source/CI results for this updated branch must be distinguished from the earlier submitted binary and hardware acceptance. + +## Remaining acceptance + +- Fresh phone install and complete pairing/voice flow for the next distributed artifact. +- Clean Mac installation; Intel Mac and other desktop platform device coverage. +- USB-detached operation, phone lock/background, reconnect and shutdown. +- Mixed-agent interactive acceptance for the newly added upstream providers. +- Device-specific transport key isolation and revocation semantics before broad distribution. +- Full browser navigation from glasses, which is outside the implemented terminal workflow. + +See [the setup guide](even-g2.md) and [browser scope](even-g2-browser-voice-next.md). diff --git a/docs/even-g2.md b/docs/even-g2.md new file mode 100644 index 000000000..0c2b733c8 --- /dev/null +++ b/docs/even-g2.md @@ -0,0 +1,77 @@ +# Even G2 local companion + +CanvasTTY's **Settings → Controls → Even G2** connects the desktop to an installed Even App companion. Both live in this repository. The desktop retains the original CanvasTTY name, application ID, and desktop version. The companion version is **0.5.6**. Integration is off by default. + +## Connect + +1. Build this integration branch of CanvasTTY and install its matching companion through an available Even Hub test channel. Open the companion in Even App with the glasses connected to the phone. The ordinary upstream release does not yet include this integration. +2. Put the Mac and phone on the same local network. In CanvasTTY, open **Settings → Controls → Even G2 → Connect Even G2**. Existing valid settings open the connection code directly. The Access tab lets you choose a project folder, shared sessions and permissions. +3. Enter the **six digits** shown on Mac into the companion’s Code field. Tap **Connect computer**. +4. Approve the pending connection on Mac. The companion saves its connection using Even App storage. No camera, QR, domain, account with a relay service, ADB, USB forwarding or separately launched tunnel is required for app traffic. + +The code is valid for two minutes. The generic companion discovers one of eight reserved local Bonjour names, then verifies the PIN with SRP-6a before receiving the encrypted connection key. No address or key is entered manually. Older CT- tokens and eight-digit codes are rejected. + +Local discovery currently targets macOS and requires local name resolution on the phone. The reported physical acceptance used Wi-Fi with the phone VPN off. VPN compatibility is not established. The integration itself never modifies VPN settings. + +The desktop listens only on the selected interface's private IPv4 and ULA IPv6 addresses, never an all-interface or public listener. Bonjour advertises a bound private IPv4 address when available, otherwise the selected bound address. The integration does not modify a VPN, OS firewall or router. A selected address still has to be reachable from the phone; guest-network isolation or unavailable IP families can prevent access. + +## Create sessions + +The phone offers every CanvasTTY hotbar provider: Terminal, Codex, Claude, Qwen Code, Kimi, OpenCode, Hermes, Grok Build, OMP and Pi. On glasses, New Codex and New Terminal retain their direct actions. More agents opens the other agent providers: swipe to choose, click to create, double-click to cancel. Holding while this picker is open does not start dictation. + +Creation uses the existing desktop launcher, the shared project folder and the normal launch profile. Each provider still needs its CLI and authentication on the Mac. The integration does not install providers or bypass their login. + +## Speech + +Recognition runs on Mac. An existing Handy installation/model remains supported, including Nemotron Streaming 3.5. On a new supported Mac, **Access → Voice on this Mac → Prepare Nemotron 3.5** downloads the pinned 716 MiB model, shows progress and checks its SHA-256 before enabling it. Cancel and retry are available. + +The packaged native helper uses **transcribe.cpp 0.2.3** and is built for Apple Silicon or Intel macOS. It receives bounded mono PCM16 at 16 kHz through stdin and returns a transcript; it does not record the OS microphone, download while transcribing, or write dictation history. The model stays local. Bundled recognition currently targets macOS; the existing Windows speech path remains unavailable in this preview. + +On the glasses, hold to dictate and release to submit. Rename Terminal opens a separate name preview: dictate, review, select Save and click. Name text is never sent as a terminal command. Closing a terminal also requires confirmation. The project-browser action currently opens the existing browser on Mac; full browser navigation from glasses is a separate unfinished feature. + +## Connection protection + +- The six-digit code authenticates an SRP-6a exchange using the pinned secure-remote-password implementation (2048-bit group, SHA-256). Neither the code nor a reusable PIN-derived encryption key is sent. The client verifies the server proof before accepting the encrypted connection key. Each two-minute offer accepts at most ten handshake starts; a proof can be redeemed once. +- Mac approval remains mandatory. Persisted bearer credentials are stored only in Even App storage; the desktop stores token hashes and scopes. Identity files use private permissions. +- Subsequent requests, responses and PCM are AES-GCM encrypted over local HTTP. Computer identity, packet ID and direction are authenticated. The app rejects public, credential-bearing and malformed connection addresses. +- Existing per-session grants, freshness checks, revocation and request-id replay protection remain enforced. Unknown microphone commands block overlapping native operations; a completed rejection permits a new explicit hold. No recording starts automatically on reconnect. +- Code bootstrap and ordinary API bodies are bounded. Address probes carry no bearer credential. An uncertain mutation is not automatically repeated at another address. + +The generic package declares eight exact local HTTP origins (`canvastty.local:3481` through `canvastty-8.local:3481`), matching the desktop discovery service. There are no network wildcards or machine-specific IPs in the release manifest. Private-build networking and public store review remain separate acceptance gates. + +## Build + +Use the Node version required by the pinned Vite packages and npm workspaces: + +```sh +npm ci +npm test +npm run test:even +npm run build +npx electron-builder --dir --mac --arm64 --publish never +npm run pack:even +``` + +The macOS build downloads a pinned native runtime archive, verifies its hash, compiles the small helper with the matching pinned C header, and packages the runtime/licenses from `artifacts/companion-speech`. Use `CANVASTTY_SPEECH_ARCH=x64` for a matching Intel build. No user models, credentials or local profiles go into the distribution. + +Output: `integrations/even-g2/release/canvastty-even-g2-0.5.6.ehpk`. Packing validates the emitted JS/HTML/CSS URLs against `network.whitelist` and writes a `.network-check.json` report alongside the package. No per-computer build variable is needed. The packer's explicit `--fixed-origin` option remains for legacy development only; the normal package uses code-based local pairing. + +For isolated desktop testing, provide an absolute `CANVASTTY_USER_DATA_DIR` before launch. The process removes that variable from launched terminals. Changes do not migrate another installation's sessions. + +## Acceptance and distribution + +The local implementation's 0.5.6 handoff records 622 desktop tests and 47 companion tests, type checking, production builds, a secret scan and successful packaging. Its network preflight rejects the former 0.5.5 dynamic URL template and accepts the eight literal origins in the 0.5.6 bundle. The integration branch is tested again after updating to upstream main; use the PR's CI for its current results. + +The user confirmed local pairing, microphone input, terminal interaction, session creation and the More agents menu on their Mac/Android/Even G2 setup before the packaging correction. A new physical install or voice run for 0.5.6 was not performed. Clean-Mac onboarding, USB-detached operation, phone lock/background/reconnect, Intel Mac and other desktop OS device acceptance remain unverified. Pi and OMP were added when integrating current upstream; their glasses menu routes have automated coverage, not fresh device acceptance. + +Companion 0.5.6 was submitted for Even Hub review on 15 September 2026. Store approval is pending. GitHub source review is independent of that process. The submitted package is retained separately; rebuilding this branch after upstream integration produces a different artifact and does not replace the store submission. + +There is no published compatible installer yet. Build from this branch with the commands above. The Mac bundle uses ad-hoc signing without notarization; it has not been validated as a frictionless install on a clean Mac. Do not direct users to an ordinary CanvasTTY release as a compatible G2 build. + +## Security review boundaries + +Pairing authenticates the short code and separate device grants control API operations. The current local transport distributes one persistent computer encryption key to paired clients. Revoking a device's API token does not rotate that shared transport key, so per-device confidentiality and forward secrecy are not claimed. An upstream security review should address that trust model before broad distribution. User profiles, pairing state and keys are excluded from source and package contents. + +The speech runtime and vendored ABI header include the upstream MIT license; packaging copies the runtime's third-party license directory. The ASR model is downloaded separately after user action and is not committed or bundled. Its model card and terms should be reviewed for the intended distribution. + +References: [Even Hub test modes](https://hub.evenrealities.com/docs/test), [transcribe.cpp](https://github.com/handy-computer/transcribe.cpp). diff --git a/electron-builder.yml b/electron-builder.yml index 11cc5c7e1..f55a4c2a3 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -17,6 +17,10 @@ asar: true asarUnpack: - node_modules/node-pty/**/* extraResources: + - from: integrations/even-g2/dist + to: even-g2-web + - from: src/main/services/companion/asr_worker.py + to: companion/asr_worker.py - from: src/agent-browser to: agent-browser filter: @@ -66,6 +70,9 @@ portable: artifactName: ${productName}-${version}-windows-${arch}-portable.${ext} mac: + extraResources: + - from: artifacts/companion-speech/${arch} + to: companion/speech icon: build/icon.png category: public.app-category.developer-tools # A free distribution has no Developer ID identity. Sign the bundle explicitly diff --git a/integrations/even-g2/.gitignore b/integrations/even-g2/.gitignore new file mode 100644 index 000000000..88223eaf8 --- /dev/null +++ b/integrations/even-g2/.gitignore @@ -0,0 +1,9 @@ +node_modules/ +dist/ +*.ehpk +.env +.env.* +!.env.example +.DS_Store +coverage/ +release/ diff --git a/integrations/even-g2/LICENSE b/integrations/even-g2/LICENSE new file mode 100644 index 000000000..3c48d4d2e --- /dev/null +++ b/integrations/even-g2/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 howdeploy + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/integrations/even-g2/README.md b/integrations/even-g2/README.md new file mode 100644 index 000000000..476751107 --- /dev/null +++ b/integrations/even-g2/README.md @@ -0,0 +1,18 @@ +# CanvasTTY for Even G2 + +This companion adapts CanvasTTY's terminal and agent workflows to Even G2 glasses. It includes a phone connection/control interface, glasses HUD, push-to-talk dictation, session creation, rename/close confirmations and a More agents picker. The desktop integration is opt-in under **Settings → Controls → Even G2**. + +Use six-digit local pairing on the same network, then approve access on the Mac. Speech recognition runs locally with Nemotron through the bundled helper or an existing supported Handy installation. Each agent still requires its own installed CLI and account on the computer. + +See [setup, transport, permissions, speech and acceptance](../../docs/even-g2.md). Run commands from the repository root: + +```sh +npm ci +npm run test:even +npm run build +npm run pack:even +``` + +The packer produces `integrations/even-g2/release/canvastty-even-g2-0.5.6.ehpk`. The generic package uses eight literal local discovery origins; packaging rejects undeclared URLs before producing the archive. No machine-specific address is needed. + +Version 0.5.6 is submitted for Even Hub review. A public store release and a compatible public desktop installer are not yet available. The source branch includes integration with newer upstream providers; its rebuilt package is separate from the submitted artifact. The companion UI is currently Russian; a complete English localization is not claimed. diff --git a/integrations/even-g2/app.json b/integrations/even-g2/app.json new file mode 100644 index 000000000..86e3429e5 --- /dev/null +++ b/integrations/even-g2/app.json @@ -0,0 +1,35 @@ +{ + "package_id": "ai.nexcore.canvastty.g2.home", + "edition": "202601", + "supported_languages": [ + "en" + ], + "name": "CanvasTTY G2", + "version": "0.5.6", + "min_app_version": "2.2.9", + "min_sdk_version": "0.0.14", + "entrypoint": "index.html", + "icon": "app-icon.png", + "author": "NEXCORE", + "description": "Companion for CanvasTTY: shared sessions, paged answers and voice from Even G2.", + "permissions": [ + { + "name": "g2-microphone", + "desc": "Glasses microphone during explicit push-to-talk" + }, + { + "name": "network", + "desc": "Connect to the configured CanvasTTY bridge.", + "whitelist": [ + "http://canvastty.local:3481", + "http://canvastty-2.local:3481", + "http://canvastty-3.local:3481", + "http://canvastty-4.local:3481", + "http://canvastty-5.local:3481", + "http://canvastty-6.local:3481", + "http://canvastty-7.local:3481", + "http://canvastty-8.local:3481" + ] + } + ] +} diff --git a/integrations/even-g2/index.html b/integrations/even-g2/index.html new file mode 100644 index 000000000..b0bd250b3 --- /dev/null +++ b/integrations/even-g2/index.html @@ -0,0 +1,259 @@ + + + + + + + CanvasTTY · Even G2 + + + +
+
+
+ +
CanvasTTYEVEN G2 COMPANION
+
+ +
+ +
+
+
+

ВАШЕ РАБОЧЕЕ МЕСТО

+

Сессии на очках.

+
+ +
+
+ +
+
+

Выберите сессию

+ — +
+
Ответ появится здесь и на очках.
+
+ 1/1 +
+ + +
+ +

Микрофон не запущен.

+
+ +
+
+

+ Выберите сессию. Свайп листает страницы, удержание включает голос. +

+
+ + +
+ + +
+
+
+ + + +
+ + + diff --git a/integrations/even-g2/package.json b/integrations/even-g2/package.json new file mode 100644 index 000000000..1713920ca --- /dev/null +++ b/integrations/even-g2/package.json @@ -0,0 +1,21 @@ +{ + "name": "canvastty-even-g2", + "version": "0.5.6", + "private": true, + "type": "module", + "scripts": { + "build": "vite build", + "pack": "node scripts/pack.mjs", + "test": "node --test tests/*.test.mjs", + "dev": "vite" + }, + "dependencies": { + "@evenrealities/even_hub_sdk": "0.0.14", + "@fontsource/ibm-plex-mono": "5.3.0", + "@fontsource/ibm-plex-sans": "5.3.0" + }, + "devDependencies": { + "@evenrealities/evenhub-cli": "0.1.14", + "vite": "8.2.2" + } +} diff --git a/integrations/even-g2/public/app-icon.png b/integrations/even-g2/public/app-icon.png new file mode 100644 index 000000000..5e06877af Binary files /dev/null and b/integrations/even-g2/public/app-icon.png differ diff --git a/integrations/even-g2/public/licenses/CanvasTTY.txt b/integrations/even-g2/public/licenses/CanvasTTY.txt new file mode 100644 index 000000000..3c48d4d2e --- /dev/null +++ b/integrations/even-g2/public/licenses/CanvasTTY.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 howdeploy + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/integrations/even-g2/public/licenses/Even-Hub-SDK.txt b/integrations/even-g2/public/licenses/Even-Hub-SDK.txt new file mode 100644 index 000000000..98f5eb3aa --- /dev/null +++ b/integrations/even-g2/public/licenses/Even-Hub-SDK.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Yangshun Tay + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/integrations/even-g2/public/licenses/IBM-Plex-OFL.txt b/integrations/even-g2/public/licenses/IBM-Plex-OFL.txt new file mode 100644 index 000000000..2ae8ef3cc --- /dev/null +++ b/integrations/even-g2/public/licenses/IBM-Plex-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2019 IBM Corp. All rights reserved. IBMPlexSans-Italic[wdth,wght].ttf: Copyright 2019 IBM Corp. All rights reserved. + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/integrations/even-g2/scripts/network-policy.mjs b/integrations/even-g2/scripts/network-policy.mjs new file mode 100644 index 000000000..75f988251 --- /dev/null +++ b/integrations/even-g2/scripts/network-policy.mjs @@ -0,0 +1,42 @@ +import { readFileSync, readdirSync } from "node:fs"; +import { extname, join, relative } from "node:path"; + +/** Conservative package preflight, not a replacement for Even Hub review. */ +export function checkBundleNetwork(root, manifest) { + const entries = manifest.permissions?.find(p => p.name === "network")?.whitelist; + if (!Array.isArray(entries) || !entries.length) + throw new Error("network.whitelist must contain explicit origins"); + const allowed = new Set(entries.map(value => { + const url = new URL(value); + if (!/^https?:$/.test(url.protocol) || url.origin !== value || + /[*$]/.test(value) || url.username || url.password) + throw new Error(`Invalid network.whitelist origin: ${value}`); + return url.origin; + })); + const found = new Set(), violations = []; + let scannedFiles = 0; + function walk(directory) { + for (const file of readdirSync(directory, { withFileTypes: true })) { + const path = join(directory, file.name); + if (file.isDirectory()) { walk(path); continue; } + if (!file.isFile() || ![".js", ".mjs", ".html", ".css"].includes(extname(path))) continue; + scannedFiles++; + const content = readFileSync(path, "utf8"); + // Scan the emitted text, including template URLs, as the review sees it. + for (const [value] of content.matchAll(/https?:\/\/[^\s"'`<>\\)]*/g)) { + found.add(value); + let covered = false; + try { + const url = new URL(value); + covered = !value.includes("${") && !url.username && !url.password && allowed.has(url.origin); + } catch {} + if (!covered) violations.push(`${relative(root, path)}: ${value}`); + } + } + } + walk(root); + if (!scannedFiles) throw new Error("No web bundle files to check"); + if (violations.length) + throw new Error(`Bundle URLs not covered by network.whitelist:\n${violations.join("\n")}`); + return { scannedFiles, whitelist: [...allowed], urls: [...found].sort() }; +} diff --git a/integrations/even-g2/scripts/origin.mjs b/integrations/even-g2/scripts/origin.mjs new file mode 100644 index 000000000..3e307e158 --- /dev/null +++ b/integrations/even-g2/scripts/origin.mjs @@ -0,0 +1,42 @@ +import { isIP } from "node:net"; + +export function buildOrigin(value, { localDevelopment = false } = {}) { + let url; + try { + url = new URL(value); + } catch { + throw new Error("Set CANVASTTY_BRIDGE_ORIGIN to an absolute HTTPS origin."); + } + if ( + url.username || + url.password || + url.search || + url.hash || + url.pathname !== "/" || + url.hostname.includes("*") + ) { + throw new Error( + "The bridge must be an exact origin without credentials, path, query, fragment, or wildcard.", + ); + } + const octets = + isIP(url.hostname) === 4 ? url.hostname.split(".").map(Number) : null; + const local = + url.hostname === "localhost" || + url.hostname === "[::1]" || + url.hostname.endsWith(".local") || + (octets && + (octets[0] === 127 || + octets[0] === 10 || + (octets[0] === 192 && octets[1] === 168) || + (octets[0] === 172 && octets[1] >= 16 && octets[1] <= 31))); + if ( + url.protocol !== "https:" && + !(localDevelopment && local && url.protocol === "http:") + ) { + throw new Error( + "HTTPS is required. --local-development permits HTTP only for a local test origin.", + ); + } + return url.origin; +} diff --git a/integrations/even-g2/scripts/pack.mjs b/integrations/even-g2/scripts/pack.mjs new file mode 100644 index 000000000..58d3637e1 --- /dev/null +++ b/integrations/even-g2/scripts/pack.mjs @@ -0,0 +1,74 @@ +import { + readFileSync, + writeFileSync, + mkdirSync, + mkdtempSync, + rmSync, +} from "node:fs"; +import { join, resolve, dirname } from "node:path"; +import { tmpdir } from "node:os"; +import { fileURLToPath } from "node:url"; +import { spawnSync } from "node:child_process"; +import { createRequire } from "node:module"; +import { LOCAL_DISCOVERY_ORIGINS } from "../../../src/shared/localDiscovery.ts"; +import { buildOrigin } from "./origin.mjs"; +import { checkBundleNetwork } from "./network-policy.mjs"; + +const require = createRequire(import.meta.url); +const root = resolve(fileURLToPath(new URL("..", import.meta.url))); +const fixed = process.argv.includes("--fixed-origin"); +const origin = fixed + ? buildOrigin(process.env.CANVASTTY_BRIDGE_ORIGIN, { + localDevelopment: process.argv.includes("--local-development"), + }) + : ""; +const manifest = JSON.parse(readFileSync(join(root, "app.json"), "utf8")); +const pkg = JSON.parse(readFileSync(join(root, "package.json"), "utf8")); +manifest.version = pkg.version; +manifest.permissions = manifest.permissions.map((permission) => + permission.name === "network" + ? { ...permission, whitelist: fixed ? [origin] : LOCAL_DISCOVERY_ORIGINS } + : permission, +); +const temporary = mkdtempSync(join(tmpdir(), "canvastty-even-manifest-")); +const output = join(root, "release", `canvastty-even-g2-${pkg.version}.ehpk`); +function run(script, args, extraEnv = {}) { + const result = spawnSync(process.execPath, [script, ...args], { + cwd: root, + stdio: "inherit", + env: { ...process.env, ...extraEnv }, + }); + if (result.error) throw result.error; + if (result.status !== 0) + throw new Error(`Build step failed (${result.status}).`); +} +try { + const path = join(temporary, "app.json"); + writeFileSync(path, JSON.stringify(manifest, null, 2)); + const webOutput = join(temporary, "web"); + run( + join(dirname(require.resolve("vite/package.json")), "bin/vite.js"), + ["build", "--outDir", webOutput], + { + VITE_CANVASTTY_BRIDGE_ORIGIN: origin, + VITE_CANVASTTY_LOCAL_ONLY: fixed ? "false" : "true", + }, + ); + const networkCheck = checkBundleNetwork(webOutput, manifest); + mkdirSync(join(root, "release"), { recursive: true }); + run(require.resolve("@evenrealities/evenhub-cli/main.js"), [ + "pack", + path, + webOutput, + "--sdk-ver", + "0.0.14", + "-o", + output, + ]); + writeFileSync(output + ".network-check.json", JSON.stringify(networkCheck, null, 2) + "\n"); + console.log( + `Built ${output} for ${origin || "local computers paired with six digits"}`, + ); +} finally { + rmSync(temporary, { recursive: true, force: true }); +} diff --git a/integrations/even-g2/src/build-config.mjs b/integrations/even-g2/src/build-config.mjs new file mode 100644 index 000000000..1367a2149 --- /dev/null +++ b/integrations/even-g2/src/build-config.mjs @@ -0,0 +1,3 @@ +// Set by the packaging workflow; no machine-specific address is committed. +export const BRIDGE_ORIGIN = import.meta.env.VITE_CANVASTTY_BRIDGE_ORIGIN || ""; +export const LOCAL_ONLY = import.meta.env.VITE_CANVASTTY_LOCAL_ONLY !== "false"; diff --git a/integrations/even-g2/src/connect.mjs b/integrations/even-g2/src/connect.mjs new file mode 100644 index 000000000..ec981224e --- /dev/null +++ b/integrations/even-g2/src/connect.mjs @@ -0,0 +1,55 @@ +export async function pairComputer( + base, + code, + { name = "Even App", onPending = () => {}, signal, fetcher = fetch } = {}, +) { + const send = (url, options) => + fetcher(url, { + ...options, + signal: signal + ? AbortSignal.any([signal, AbortSignal.timeout(12000)]) + : AbortSignal.timeout(12000), + }); + const start = await send(base + "/g2/api/pair", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ code, name }), + signal, + credentials: "omit", + redirect: "error", + }); + if (!start.ok) + throw new Error( + "Код недействителен или истёк. Создайте новый код на компьютере.", + ); + const pending = await start.json(); + if (!/^[a-f0-9]{64}$/.test(pending.token)) + throw new Error("Некорректный ответ подключения."); + onPending(); + const expires = Date.now() + 120000; + while (Date.now() < expires) { + if (signal?.aborted) throw new Error("Подключение отменено."); + const response = await send(base + "/g2/api/pair-status", { + headers: { Authorization: "Bearer " + pending.token }, + signal, + credentials: "omit", + redirect: "error", + }); + if (!response.ok) throw new Error("Не удалось проверить подтверждение."); + const result = await response.json(); + if (result.state === "approved") { + const home = await send(base + "/g2/api/home", { + headers: { Authorization: "Bearer " + pending.token }, + signal, + credentials: "omit", + redirect: "error", + }); + if (!home.ok) throw new Error("Компьютер недоступен."); + return { token: pending.token, home: await home.json() }; + } + if (result.state !== "pending") + throw new Error("Подключение отклонено или код истёк."); + await new Promise((resolve) => setTimeout(resolve, 800)); + } + throw new Error("Время подтверждения истекло."); +} diff --git a/integrations/even-g2/src/create-menu.mjs b/integrations/even-g2/src/create-menu.mjs new file mode 100644 index 000000000..f5a51aef6 --- /dev/null +++ b/integrations/even-g2/src/create-menu.mjs @@ -0,0 +1,6 @@ +import { CANVAS_LAUNCHER_ITEMS, PROVIDER_LABELS } from "../../../src/shared/contracts.ts"; + +// Codex and Terminal retain their existing direct OS menu actions. +export const MORE_AGENTS = CANVAS_LAUNCHER_ITEMS + .filter(provider => provider !== "terminal" && provider !== "codex") + .map(provider => ({ provider, label: PROVIDER_LABELS[provider] })); diff --git a/integrations/even-g2/src/hud-bridge.mjs b/integrations/even-g2/src/hud-bridge.mjs new file mode 100644 index 000000000..3ddb957b9 --- /dev/null +++ b/integrations/even-g2/src/hud-bridge.mjs @@ -0,0 +1,242 @@ +export class HudBridge { + constructor( + native, + sdk, + { + ackTimeoutMs = 5000, + setTimer = setTimeout, + clearTimer = clearTimeout, + } = {}, + ) { + if ( + !Number.isFinite(ackTimeoutMs) || + ackTimeoutMs <= 0 || + ackTimeoutMs > 2147483647 + ) + throw new RangeError("Invalid HUD ACK timeout"); + this.native = native; + this.sdk = sdk; + this.started = false; + this._starting = null; + this._inFlight = null; + this._pending = null; + this._audioPending = 0; + this._audioQueue = Promise.resolve(); + this._active = true; + this._generation = 0; + this._lifecycleWork = Promise.resolve(true); + this._ackTimeoutMs = ackTimeoutMs; + this._setTimer = setTimer; + this._clearTimer = clearTimer; + this.content = "CANVASTTY\nМикрофон не запущен"; + this.displayState = "unknown"; + this.acknowledgedContent = null; + } + startPage() { + if (!this._active) return Promise.resolve(false); + if (this.started) return Promise.resolve(true); + if (this._starting) return this._starting; + let resolve; + const result = new Promise((done) => { + resolve = done; + }); + this._starting = result; + this._send({ content: this.content, resolve, startup: true }); + return result; + } + microphone(on, { onDispatch = () => {}, canStart = () => true } = {}) { + if (on && !this._active) return Promise.resolve(false); + // Reserve the native channel before waiting for the current display write. + // A frame requested during audioControl must not race the hardware ACK. + this._audioPending++; + const operation = this._audioQueue.then(async () => { + await this.waitForIdle(); + if (on && (!this._active || !canStart())) return false; + onDispatch(); + return this.native.audioControl(on, this.sdk.AudioInputSource.Glasses); + }); + this._audioQueue = operation.catch(() => {}); + return operation.finally(() => { + this._audioPending--; + this._drain(); + }); + } + async waitForIdle() { + // Caller deadlines are not native settlement. Only done releases this barrier. + while (this._inFlight) await this._inFlight.done; + } + suspend({ shutdown = false } = {}) { + this._active = false; + this._generation++; + this.started = false; + this._starting = null; + this.displayState = "unknown"; + this.acknowledgedContent = null; + this._settle(this._pending, false); + this._pending = null; + this._settle(this._inFlight, false); + const previous = this._lifecycleWork; + let resolve, + expired = false; + const result = new Promise((done) => { + resolve = done; + }); + const timer = this._setTimer(() => { + expired = true; + resolve(false); + }, this._ackTimeoutMs); + this._lifecycleWork = (async () => { + const prior = await previous; + await this.waitForIdle(); + let ok = prior; + if (shutdown) { + // One bounded attempt; no optimistic retry or release on timeout. + const job = { shutdown: true }; + this._send(job); + await job.done; + ok = job.ok; + } + return ok === true && !expired; + })(); + this._lifecycleWork.then((ok) => { + this._clearTimer(timer); + resolve(ok); + }); + return result; + } + async reenter(content = "CANVASTTY\nМикрофон не запущен") { + const generation = ++this._generation; + this._active = false; + this.started = false; + this._starting = null; + this._settle(this._pending, false); + this._pending = null; + this._settle(this._inFlight, false); + const ok = await this._lifecycleWork; + await this.waitForIdle(); + if (generation !== this._generation || ok !== true) return false; + this._active = true; + this.content = content; + return this.startPage(); + } + render(content) { + if (!this._active || typeof content !== "string" || content.length > 4096) + return Promise.resolve(false); + this.content = content; + if (!this.started) { + if (this._starting) this._pending = { content, resolve: null }; + return Promise.resolve(false); + } + return new Promise((resolve) => { + this._settle(this._pending, false); + this._pending = { content, resolve }; + if (this._inFlight?.timedOut) this._settle(this._pending, false); + this._drain(); + }); + } + _settle(job, ok) { + if (!job?.resolve) return; + job.resolve(ok); + job.resolve = null; + } + _drain() { + if ( + !this._active || + !this.started || + this._inFlight || + this._audioPending || + !this._pending + ) + return; + const job = this._pending; + this._pending = null; + this._send(job); + } + _send(job) { + job.generation = this._generation; + let settled; + job.done = new Promise((resolve) => { + settled = resolve; + }); + this._inFlight = job; + this.displayState = "unknown"; + this.acknowledgedContent = null; + const timer = this._setTimer(() => { + job.timedOut = true; + this._settle(job, false); + this._settle(this._pending, false); + // Native may still apply this frame. Keep the slot until its real settlement. + }, this._ackTimeoutMs); + const finish = (ok) => { + this._clearTimer(timer); + this._inFlight = null; + const current = this._active && job.generation === this._generation; + job.ok = ok && !job.timedOut && (job.shutdown || current); + if (job.startup && current) { + this.started = ok; + this._starting = null; + if (!ok) { + this._settle(this._pending, false); + this._pending = null; + } + } + if (job.ok && !job.shutdown) { + this.displayState = "confirmed"; + this.acknowledgedContent = job.content; + } + this._settle(job, job.ok); + settled(); + this._drain(); + }; + try { + const { content } = job; + let result; + if (job.shutdown) { + result = this.native.shutDownPageContainer(1); + } else if (job.startup) { + const payload = new this.sdk.CreateStartUpPageContainer({ + containerTotalNum: 1, + textObject: [ + new this.sdk.TextContainerProperty({ + xPosition: 8, + yPosition: 0, + width: 560, + height: 288, + containerID: 1, + containerName: "canvastty-g2", + isEventCapture: 1, + content, + }), + ], + menuObject: { + menuItems: [ + { itemID: 1, itemName: "Home CanvasTTY" }, + { itemID: 2, itemName: "New Codex" }, + { itemID: 3, itemName: "New Terminal" }, + { itemID: 4, itemName: "Close Terminal" }, + { itemID: 5, itemName: "Project Browser" }, + { itemID: 6, itemName: "Rename Terminal" }, + { itemID: 7, itemName: "More agents" }, + ], + }, + }); + result = this.native.createStartUpPageContainer(payload); + } else { + const payload = new this.sdk.TextContainerUpgrade({ + containerID: 1, + containerName: "canvastty-g2", + contentOffset: 0, + contentLength: content.length, + content, + }); + result = this.native.textContainerUpgrade(payload); + } + Promise.resolve(result).then( + (ack) => finish(job.startup ? ack === 0 : ack === true), + () => finish(false), + ); + } catch { + finish(false); + } + } +} diff --git a/integrations/even-g2/src/input.mjs b/integrations/even-g2/src/input.mjs new file mode 100644 index 000000000..695a452ee --- /dev/null +++ b/integrations/even-g2/src/input.mjs @@ -0,0 +1,32 @@ +// Protobuf omits the default enum value (CLICK=0). The typed SDK event can +// therefore have no eventType, including on sysEvent. Never infer input from +// untyped messages, motion payloads, exit reasons or another container. +export function inputEvent(event, sdk) { + const parts = ["textEvent", "listEvent", "sysEvent"].flatMap((kind) => { + const part = event[kind]; + if ( + !part || + typeof part !== "object" || + part.imuData != null || + (part.systemExitReasonCode != null && part.eventType == null) + ) + return []; + if (part.containerID != null && part.containerID !== 1) return []; + return [{ kind, part }]; + }); + for (const { kind, part } of parts) { + if (part.eventType == null) continue; + const type = sdk.OsEventTypeList.fromJson(part.eventType); + if (type !== undefined) + return { kind, type, source: part.eventSource, defaulted: false }; + } + const omitted = parts.find(({ part }) => part.eventType == null); + return omitted + ? { + kind: omitted.kind, + type: sdk.OsEventTypeList.CLICK_EVENT, + source: omitted.part.eventSource, + defaulted: true, + } + : null; +} diff --git a/integrations/even-g2/src/local-fetch.mjs b/integrations/even-g2/src/local-fetch.mjs new file mode 100644 index 000000000..2494b03b4 --- /dev/null +++ b/integrations/even-g2/src/local-fetch.mjs @@ -0,0 +1,210 @@ +import srp from "secure-remote-password/client.js"; +import { LOCAL_DISCOVERY_ORIGINS, PAIRING_IDENTITY, sixDigitCode } from "../../../src/shared/localDiscovery.ts"; +import { + localOrigin, + sealLocal, + unsealLocal, + validateLocalConnection, +} from "../../../src/shared/localLink.ts"; + +/** Probe addresses without credentials; send each actual action only once. */ +export function localFetcher( + connection, + { fetcher = fetch, allowLoopback = false, probeTimeout = 3000 } = {}, +) { + const validated = validateLocalConnection(connection, allowLoopback); + let preferred = "", + discovery = null; + async function exchange(origin, packet, signal) { + const response = await fetcher(origin + "/g2/link", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(packet), + credentials: "omit", + redirect: "error", + signal, + }); + if (!response.ok) throw new Error("Local connection rejected"); + const reply = await response.json(); + if (reply.id !== packet.id) throw new Error("invalid-response-id"); + const result = await unsealLocal(validated, reply, "response"); + if ( + !Number.isInteger(result.status) || + result.status < 200 || + result.status > 599 + ) + throw new Error("invalid-response-status"); + return result; + } + async function discover(signal) { + if (preferred) return preferred; + if (discovery) return discovery; + discovery = (async () => { + const controllers = validated.origins.map(() => new AbortController()); + try { + const packet = await sealLocal( + validated, + { + path: "/g2/api/home", + method: "GET", + token: "", + sentAt: Date.now(), + }, + "request", + ); + preferred = await Promise.any( + validated.origins.map(async (origin, index) => { + const signals = [ + controllers[index].signal, + AbortSignal.timeout(probeTimeout), + ]; + if (signal) signals.push(signal); + // A decryptable 401 proves this is the paired computer, without sharing a bearer. + const result = await exchange( + origin, + packet, + AbortSignal.any(signals), + ); + if (result.status !== 401) + throw new Error("Unexpected pairing probe"); + return origin; + }), + ); + return preferred; + } finally { + for (const controller of controllers) controller.abort(); + discovery = null; + } + })(); + return discovery; + } + const send = async (input, options = {}) => { + const url = new URL(input, validated.origins[0]); + if ( + !validated.origins.includes(url.origin) || + !url.pathname.startsWith("/g2/api/") + ) + throw new Error("invalid-local-target"); + const headers = new Headers(options.headers); + try { + const origin = await discover(options.signal); + const packet = await sealLocal( + validated, + { + path: url.pathname + url.search, + method: options.method || "GET", + token: (headers.get("Authorization") || "").replace(/^Bearer /, ""), + body: options.body ? JSON.parse(options.body) : undefined, + sentAt: Date.now(), + }, + "request", + ); + const result = await exchange(origin, packet, options.signal); + if ( + url.pathname === "/g2/api/home" && + result.status === 200 && + Array.isArray(result.body?.localOrigins) + ) { + const origins = result.body.localOrigins + .slice(0, 8) + .map((value) => localOrigin(value, allowLoopback)); + validated.origins = [ + ...new Set([...origins, ...validated.origins]), + ].slice(0, 8); + } + return new Response(JSON.stringify(result.body), { + status: result.status, + headers: { "Content-Type": "application/json" }, + }); + } catch (error) { + preferred = ""; + if (options.signal?.aborted) throw options.signal.reason; + throw new Error( + "Нет локальной связи. Откройте CanvasTTY на Mac и подключите телефон к той же сети.", + { cause: error }, + ); + } + }; + send.connection = () => ({ ...validated, origins: [...validated.origins] }); + return send; +} +export function readLocalConnection(value) { + const saved = JSON.parse(value); + if (!/^[a-f0-9]{64}$/.test(saved?.token)) + throw new Error("invalid-saved-token"); + return { + token: saved.token, + connection: validateLocalConnection(saved.connection), + }; +} + +export async function connectionFromCode(value, { + fetcher = fetch, signal, origins = LOCAL_DISCOVERY_ORIGINS, allowLoopback = false, onTrace = () => {}, +} = {}) { + const code = sixDigitCode(value); + if (!Array.isArray(origins) || !origins.length || origins.length > 8) + throw new Error("invalid-discovery-origins"); + const controllers = origins.map(() => new AbortController()); + let found = false; + const failures = []; + const report = (message) => { try { onTrace(message); } catch {} }; + try { + return await Promise.any(origins.map(async (candidate, index) => { + let stage = "validate-origin", status = 0; + try { + const origin = localOrigin(candidate, allowLoopback); + const signals = [controllers[index].signal, AbortSignal.timeout(15000)]; + if (signal) signals.push(signal); + const requestSignal = AbortSignal.any(signals); + const get = async (path, body) => { + stage = path; status = 0; + report(`${new URL(origin).hostname} ${stage} start`); + const r = await fetcher(origin + path, { + method: body ? "POST" : "GET", + ...(body ? { headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) } : {}), + credentials: "omit", redirect: "error", signal: requestSignal, + }); + status = r.status; + report(`${new URL(origin).hostname} ${stage} HTTP ${status}`); + if (!r.ok) throw new Error("pairing-unavailable"); + return r.json(); + }; + const offer = await get("/g2/discover"); + stage = "validate-offer"; + if (offer.type !== "canvastty-local" || offer.version !== 2) + throw new Error("invalid-offer"); + found = true; + report(`${new URL(origin).hostname} offer pairing=${offer.pairing === true}`); + if (offer.pairing !== true) throw new Error("pairing-inactive"); + stage = "srp-client-start"; + const ephemeral = srp.generateEphemeral(); + const challenge = await get("/g2/pair-start", { public: ephemeral.public }); + if (!/^[a-f0-9]{64}$/.test(challenge.id) || !/^[a-f0-9]{64}$/.test(challenge.salt) || + !/^[a-f0-9]{512}$/.test(challenge.public)) throw new Error("invalid-pairing-challenge"); + const key = srp.derivePrivateKey(challenge.salt, PAIRING_IDENTITY, code); + const session = srp.deriveSession(ephemeral.secret, challenge.public, challenge.salt, + PAIRING_IDENTITY, key); + const reply = await get("/g2/pair-finish", { id: challenge.id, proof: session.proof }); + if (!/^[a-f0-9]{64}$/.test(reply.proof)) throw new Error("invalid-server-proof"); + srp.verifySession(ephemeral.public, session, reply.proof); + const result = await unsealLocal({ version: 1, computer: challenge.id, + key: session.key, origins: [origin] }, reply.packet, "response"); + const connection = validateLocalConnection(result.connection, allowLoopback); + if (!connection.origins.includes(origin) || result.code !== code) + throw new Error("invalid-pairing-result"); + return { connection, code }; + } catch (error) { + const host = new URL(candidate).hostname; + const detail = `${host} ${stage} ${status ? `HTTP ${status} ` : ""}${error.name}: ${String(error.message).slice(0,100)}`; + failures[index] = detail; report(detail); throw error; + } + })); + } catch (error) { + if (signal?.aborted) throw signal.reason; + const failure = new Error(found + ? "Код не принят или истёк. Получите новый шестизначный код на Mac." + : "Mac не найден. Откройте CanvasTTY → Управление → Even G2 и проверьте, что оба устройства в одной Wi-Fi сети.", { cause: error }); + failure.diagnostics = failures; + throw failure; + } finally { controllers.forEach(c => c.abort()); } +} diff --git a/integrations/even-g2/src/main.mjs b/integrations/even-g2/src/main.mjs new file mode 100644 index 000000000..eefa1be58 --- /dev/null +++ b/integrations/even-g2/src/main.mjs @@ -0,0 +1,1321 @@ +import { MORE_AGENTS } from "./create-menu.mjs"; +import { normalizeSessionTitle } from "../../../src/shared/companion.ts"; +import { pairComputer } from "./connect.mjs"; +import { BRIDGE_ORIGIN, LOCAL_ONLY } from "./build-config.mjs"; +import { + localFetcher, + readLocalConnection, + connectionFromCode, +} from "./local-fetch.mjs"; +import * as sdk from "@evenrealities/even_hub_sdk"; +import { HudBridge } from "./hud-bridge.mjs"; +import { ViewModel, Gestures, STATUS } from "./model.mjs"; +import { validatePairing } from "./pairing.mjs"; +import { inputEvent } from "./input.mjs"; + +const $ = (id) => document.getElementById(id), + BASE = BRIDGE_ORIGIN || location.origin, + KEY = "canvastty.g2.home.token.v4"; +const CONNECTION_KEY = "canvastty.g2.local.v1"; +let localConnection = null, + localFetch = null; +const model = new ViewModel(); +let native = null, + hud = null, + token = "", + alive = true, + foreground = true, + notice = "", + lastFrame = "", + latestHome = null; +let mic = null, + wanted = false, + generation = 0, + micQueue = Promise.resolve(), + micPending = 0, + parts = [], + size = 0, + lastAudioAt = 0, + startedAt = 0; +let voiceRequest = null, + voiceSession = null, + voiceBusy = false; +let voicePurpose = "input", + voiceRenameTarget = null; +let overlay = false, + pairBusy = false, + createBusy = false, + exitBusy = false; +let micFault = "", + micOffRequest = null, + micOffState = null; +let micUsed = false, + micTest = false, + micTestBytes = 0, + micTestTimer = null, + controlBusy = false, + controlFeedback = ""; +const drafts = new Map(); +const diagnostics = []; +const micDiagnostics = []; +let lastCaptureBytes = 0, + pairAbort = null; +let telemetrySignature = ""; +function trace(message) { + const line = new Date().toISOString().slice(11, 23) + " " + message; + diagnostics.push(line); + if (/^(mic:|voice:stop|lifecycle:)/.test(message)) { + micDiagnostics.push(line); + if (micDiagnostics.length > 16) micDiagnostics.shift(); + } + if (diagnostics.length > 64) diagnostics.shift(); + $("diagnostics").textContent = diagnostics.join("\n"); +} +function id() { + const b = new Uint8Array(16); + crypto.getRandomValues(b); + return Array.from(b, (x) => x.toString(16).padStart(2, "0")).join(""); +} +async function request(path, body, credential = token) { + if (!credential) throw new Error("Введите код подключения."); + if (LOCAL_ONLY && !localFetch) + throw new Error("Введите код подключения из CanvasTTY на Mac."); + const response = await (localFetch || fetch)( + (localFetch?.connection().origins[0] || BASE) + path, + { + method: body === undefined ? "GET" : "POST", + headers: { + Authorization: "Bearer " + credential, + "Content-Type": "application/json", + }, + body: + body === undefined + ? undefined + : JSON.stringify({ + ...body, + requestId: body.requestId || id(), + sentAt: body.sentAt || Date.now(), + }), + credentials: "omit", + redirect: "error", + signal: AbortSignal.timeout( + path.includes("voice") + ? 65000 + : path.includes("/create") || path.includes("/browser") + ? 15000 + : 8000, + ), + }, + ); + if (!response.ok) throw new Error("Компьютер не подтвердил запрос."); + return response.json(); +} +function clearAudio() { + for (const p of parts) p.fill(0); + parts = []; + size = 0; +} +function micDeadline(operation, on, state = { sent: true }) { + let timer; + const deadline = new Promise((_, reject) => { + timer = setTimeout(() => { + const error = new Error( + state.sent + ? "Нет подтверждения микрофона за 2,5 с." + : "Канал очков занят. Команда микрофона ещё не отправлена.", + ); + micFault = error.message; + trace( + "mic:" + + (on ? "on" : "off") + + (state.sent ? " deadline" : " queue-deadline"), + ); + paint(); + reject(error); + }, 2500); + }); + return Promise.race([operation, deadline]).finally(() => clearTimeout(timer)); +} +function setMic(on, ticket) { + if (!on && micOffRequest) + return micDeadline(micOffRequest, false, micOffState); + const state = { sent: false }; + micPending++; + const operation = micQueue.then(async () => { + if (on && (!wanted || ticket !== generation || !foreground)) return false; + if (on) { + if (!hud?.started) throw new Error("Экран G2 не готов."); + await hud.waitForIdle(); + } + if (on && (!wanted || ticket !== generation || !foreground || overlay)) + return false; + trace("mic:" + (on ? "on" : "off") + " queued"); + try { + const ack = await hud.microphone(on, { + canStart: () => + wanted && ticket === generation && foreground && !overlay, + onDispatch: () => { + state.sent = true; + if (on) micUsed = true; + mic = null; + trace("mic:" + (on ? "on" : "off") + " sent"); + }, + }); + if (!state.sent) return false; + trace( + "mic:" + + (on ? "on" : "off") + + " ack=" + + (ack === true ? "true" : ack === false ? "false" : typeof ack), + ); + if (ack !== true) + throw new Error( + "G2 не подтвердил " + + (on ? "включение" : "выключение") + + " микрофона.", + ); + mic = on; + micFault = ""; + paint(); + return true; + } catch (e) { + micFault = e.message; + trace("mic:" + (on ? "on" : "off") + " failed"); + paint(); + throw e; + } + }); + // A caller timeout does not release an unsettled native operation. A queued + // off still follows a late enable ACK, while navigation remains independent. + micQueue = operation.catch(() => {}); + void operation + .finally(() => { + micPending--; + paint(); + }) + .catch(() => {}); + if (!on) { + micOffRequest = operation; + micOffState = state; + void operation + .finally(() => { + if (micOffRequest === operation) { + micOffRequest = null; + micOffState = null; + } + paint(); + }) + .catch(() => {}); + } + return micDeadline(operation, on, state); +} +function statusLabel() { + if (wanted) return mic === true ? "Говорите…" : "Включаю микрофон…"; + if (voiceBusy) + return "Распознаю: " + (latestHome?.speechModel || "локально") + "…"; + return STATUS[model.state] || "Готов"; +} +function setPanel(name) { + if (name !== "workspace" && model.renameDialog) { + model.renameDialog = null; + void stopVoice(false, "rename-navigation"); + } + for (const section of document.querySelectorAll("[data-view]")) + section.hidden = section.dataset.view !== name; + for (const button of document.querySelectorAll("[data-panel]")) + button.setAttribute("aria-pressed", String(button.dataset.panel === name)); +} +function paint() { + $("speech-engine").textContent = latestHome?.speechModel || "Нет данных"; + $("session-empty").hidden = model.sessions.length > 0; + $("session-empty").textContent = latestHome + ? "Доступных сессий пока нет. Создайте терминал или измените доступ на Mac." + : "Подключите компьютер, чтобы увидеть доступные сессии."; + $("open-connection").dataset.connected = String( + $("connection").textContent.startsWith("Компьютер подключён"), + ); + $("clock").textContent = new Date().toLocaleTimeString("ru-RU", { + hour: "2-digit", + minute: "2-digit", + }); + $("summary").textContent = + `${model.sessions.length} терминалов · ${model.sessions.filter((s) => s.status === "working").length} в работе`; + $("title").textContent = + model.view === "home" + ? "Home CanvasTTY" + : model.sessions.find((s) => s.id === model.sessionId)?.title || + "Терминал"; + $("phase").textContent = statusLabel(); + $("answer").textContent = + model.view === "home" + ? "Выберите терминал из списка." + : model.choiceBody() || "Ответа пока нет."; + $("rename-form").hidden = !model.renameDialog; + $("rename-current").textContent = model.renameDialog?.original || ""; + $("rename-preview").textContent = model.renameDialog?.draft || ""; + $("rename-save").disabled = + controlBusy || voiceBusy || wanted || !model.renameDialog?.draft; + $("rename-cancel").disabled = controlBusy; + $("rename-input").disabled = controlBusy || voiceBusy || wanted; + $("rename-terminal").disabled = + controlBusy || + voiceBusy || + createBusy || + wanted || + !selectedSession() || + latestHome?.features?.sessionRename !== true; + $("close-dialog").hidden = !model.closeDialog; + $("close-title").textContent = model.closeDialog + ? "Закрыть «" + model.closeDialog.title + "»?" + : ""; + $("close-confirm").disabled = controlBusy; + $("close-cancel").disabled = controlBusy; + for (const button of document.querySelectorAll("[data-new]")) + button.disabled = + createBusy || !token || latestHome?.features?.sessionCreate !== true; + $("close-terminal").disabled = + controlBusy || + !selectedSession() || + latestHome?.features?.sessionClose !== true; + $("project-browser").disabled = + controlBusy || + !selectedSession() || + latestHome?.features?.projectBrowser !== true; + $("page").textContent = + model.view === "terminal" && model.interaction + ? `Вариант ${model.choiceSelection + 1}/${model.interaction.options.length}` + : `${model.page + 1}/${model.pages().length}`; + $("voice").disabled = + !native || + !token || + (!model.renameDialog && + (model.view !== "terminal" || !model.sessionId || !!model.interaction)) || + !!model.closeDialog || + !!model.createDialog || + !foreground || + overlay || + createBusy || + voiceBusy || + controlBusy || + (!wanted && (micPending > 0 || !!micOffRequest)) || + latestHome?.speechAvailable === false; + $("voice").classList.toggle("recording", wanted && mic === true); + $("voice").textContent = wanted + ? mic === true + ? "Говорите. Отпустите для отправки." + : "Включаю микрофон…" + : micFault + ? "Удерживайте, чтобы повторить включение" + : model.renameDialog + ? "Удерживайте, чтобы назвать терминал" + : "Удерживайте, чтобы говорить"; + $("mic-status").textContent = !micUsed + ? "Микрофон ещё не включался приложением." + : mic === null + ? micPending > 0 + ? "Жду завершения команды микрофона." + : "Состояние микрофона не подтверждено. Удерживайте для новой попытки или нажмите «Выключить микрофон»." + : mic === true + ? "Микрофон G2 включён." + : micFault || "Микрофон выключен."; + $("mic-stop").disabled = + !native || !foreground || overlay || !micUsed || (mic === false && !wanted); + $("mic-test").disabled = + !native || + !foreground || + overlay || + wanted || + voiceBusy || + controlBusy || + !!micOffRequest || + micPending > 0; + $("choices").hidden = + !!model.renameDialog || model.view !== "terminal" || !model.interaction; + $("choose").disabled = controlBusy; + $("custom").disabled = controlBusy || model.interaction?.customIndex == null; + $("manual").hidden = + model.view !== "terminal" || + !!model.interaction || + latestHome?.features?.manualInput !== true || + !!model.renameDialog; + $("send-text").disabled = controlBusy || voiceBusy; + $("notice").textContent = + notice || + "Свайп — выбор или страница. Клик — открыть. Удержание — голос. Home и создание — пункты системного меню очков."; + const override = createBusy + ? "СОЗДАЮ ТЕРМИНАЛ\nНа Mac…" + : wanted + ? micTest + ? "ПРОВЕРКА МИКРОФОНА\nАгенту ничего не отправляется." + : mic === true + ? voicePurpose === "rename" + ? "НОВОЕ ИМЯ\nПроизнесите название и отпустите." + : "СЛУШАЮ\nОтпустите удержание, когда закончите." + : "ВКЛЮЧАЮ МИКРОФОН\nЖду подтверждение G2." + : voiceBusy && model.view === "terminal" + ? "РАСПОЗНАЮ НА MAC\nМикрофон выключен." + : ""; + const warning = + !wanted && micUsed && mic !== false + ? mic === null + ? micPending > 0 + ? "Микрофон: жду ответ G2" + : "Микрофон: удержите для повтора" + : "Микрофон: выключаю…" + : controlBusy + ? "Передаю выбор…" + : controlFeedback; + const frame = model.frame(override, warning); + $("hud").textContent = frame; + if (frame !== lastFrame && foreground && !overlay) { + lastFrame = frame; + if (hud?.started) { + void hud.render(frame).then((ok) => { + if (frame === lastFrame) + $("ack").textContent = ok + ? "Дисплей подтвердил обновление" + : "Обновление дисплея не подтверждено"; + }); + } + } +} +function renderHome() { + const host = $("sessions"), + signature = JSON.stringify( + model.sessions.map((s) => [s.id, s.title, s.status]), + ); + if (host.dataset.signature !== signature) { + host.dataset.signature = signature; + host.replaceChildren(); + for (const s of model.sessions) { + const button = document.createElement("button"); + button.className = "session"; + button.dataset.id = s.id; + button.textContent = s.title; + const detail = document.createElement("small"); + detail.textContent = `${s.provider} · ${STATUS[s.status] || s.status}`; + button.append(detail); + button.onclick = () => void openSession(s.id); + host.append(button); + } + } + for (const b of host.children) + b.classList.toggle("active", b.dataset.id === model.sessionId); + const limits = $("limits"); + limits.replaceChildren(); + for (const p of model.limits?.providers || []) { + if (p.state === "unavailable") continue; + const w = + p.windows?.find((w) => w.isDefaultBucket && w.usedPercent !== null) || + p.windows?.find((w) => w.usedPercent !== null); + if (!w) continue; + const row = document.createElement("div"); + row.textContent = `${p.provider}: ${w.usedPercent}% использовано${p.state === "stale" ? " (прошлые данные)" : ""}`; + limits.append(row); + } + if (!limits.childNodes.length) limits.textContent = "Нет данных"; +} +async function openSession(sessionId) { + model.createDialog = null; + setPanel("workspace"); + model.closeDialog = null; + model.renameDialog = null; + void stopVoice(false); + if (model.sessionId) drafts.set(model.sessionId, $("draft").value); + model.open(sessionId); + $("draft").value = drafts.get(sessionId) || ""; + model.selection = Math.max( + 0, + model.sessions.findIndex((s) => s.id === sessionId), + ); + notice = ""; + controlFeedback = ""; + trace("navigation:terminal"); + paint(); + try { + model.terminal( + await request("/g2/api/terminal?id=" + encodeURIComponent(sessionId)), + ); + paint(); + } catch (e) { + model.loadFailed(sessionId); + notice = e.message; + paint(); + } +} +async function home() { + model.createDialog = null; + setPanel("workspace"); + model.closeDialog = null; + model.renameDialog = null; + void stopVoice(false); + model.view = "home"; + notice = ""; + controlFeedback = ""; + trace("navigation:home"); + paint(); +} +function selectedSession() { + return model.view === "terminal" + ? model.sessions.find((s) => s.id === model.sessionId) + : model.sessions[model.selection]; +} +function requestRename() { + if ( + controlBusy || + voiceBusy || + createBusy || + wanted || + latestHome?.features?.sessionRename !== true + ) + return; + const session = selectedSession(); + if (!session) return; + void stopVoice(false, "rename-open"); + model.closeDialog = null; + model.renameDialog = { + sessionId: session.id, + original: session.title, + draft: "", + selected: 0, + requestId: id(), + }; + $("rename-input").value = ""; + notice = "Продиктуйте новое имя удержанием или введите его ниже."; + controlFeedback = ""; + setPanel("workspace"); + paint(); + $("rename-form").scrollIntoView?.({ block: "center" }); +} +function cancelRename() { + if (controlBusy) return; + model.renameDialog = null; + void stopVoice(false, "rename-cancel"); + notice = "Переименование отменено."; + paint(); +} +function renameDraft(value) { + if (!model.renameDialog || controlBusy || voiceBusy || wanted) return; + try { + model.renameDialog.draft = normalizeSessionTitle(value); + notice = "Проверьте имя и нажмите «Сохранить»."; + } catch { + model.renameDialog.draft = ""; + notice = "Введите имя от 1 до 80 символов."; + } + model.renameDialog.selected = 0; + model.renameDialog.requestId = id(); + paint(); +} +async function confirmRename(fromPhone = false) { + const dialog = model.renameDialog; + if (!dialog || !dialog.draft || controlBusy || voiceBusy || wanted) return; + if (!fromPhone && dialog.selected === 0) { + cancelRename(); + return; + } + controlBusy = true; + controlFeedback = "Сохраняю имя…"; + paint(); + try { + const result = await request("/g2/api/session-rename", { + sessionId: dialog.sessionId, + title: normalizeSessionTitle(dialog.draft), + requestId: dialog.requestId, + }); + if (result.session?.id !== dialog.sessionId) + throw new Error("invalid-session"); + if (latestHome) { + latestHome = { + ...latestHome, + sessions: latestHome.sessions.map((session) => + session.id === dialog.sessionId ? result.session : session, + ), + }; + model.home(latestHome); + renderHome(); + } + if (model.renameDialog === dialog) model.renameDialog = null; + notice = "Новое имя: " + result.session.title; + controlFeedback = "Имя сохранено"; + trace("terminal:renamed"); + } catch { + notice = "Переименование не подтверждено. Проверьте имя в списке."; + controlFeedback = "Имя не подтверждено"; + } finally { + controlBusy = false; + paint(); + } +} +function requestClose() { + setPanel("workspace"); + if (controlBusy || latestHome?.features?.sessionClose !== true) return; + const session = selectedSession(); + if (!session) return; + void stopVoice(false); + model.renameDialog = null; + model.closeDialog = { + sessionId: session.id, + title: session.title, + selected: 0, + requestId: id(), + }; + paint(); +} +async function confirmClose() { + const dialog = model.closeDialog; + if (!dialog || controlBusy) return; + if (dialog.selected === 0) { + model.closeDialog = null; + paint(); + return; + } + controlBusy = true; + paint(); + try { + await request("/g2/api/session-close", { + sessionId: dialog.sessionId, + requestId: dialog.requestId, + }); + drafts.delete(dialog.sessionId); + model.closeDialog = null; + latestHome = await request("/g2/api/home"); + model.home(latestHome); + renderHome(); + notice = "Терминал закрыт. Файлы проекта сохранены."; + trace("terminal:closed"); + } catch { + notice = "Закрытие не подтверждено. Проверьте список терминалов."; + } finally { + controlBusy = false; + paint(); + } +} +async function openProjectBrowser() { + const session = selectedSession(); + if (!session || controlBusy || latestHome?.features?.projectBrowser !== true) + return; + void stopVoice(false); + model.closeDialog = null; + model.renameDialog = null; + controlBusy = true; + paint(); + try { + const result = await request("/g2/api/browser", { + sessionId: session.id, + requestId: id(), + }); + controlFeedback = "Браузер открыт на Mac"; + notice = + (result.title || "Браузер проекта") + + (result.url ? " · " + result.url : "") + + ". Управляйте страницей поручениями агенту."; + trace("browser:opened"); + } catch { + notice = "Открытие браузера не подтверждено."; + controlFeedback = "Браузер: ошибка"; + } finally { + controlBusy = false; + paint(); + } +} +async function chooseOption(custom = false) { + const menu = model.interaction; + if (!menu || controlBusy || model.view !== "terminal") return; + if (custom && menu.customIndex == null) { + notice = "В этом меню нет варианта для своего текста."; + controlFeedback = "Свой ответ не предусмотрен"; + paint(); + return; + } + const sessionId = model.sessionId, + index = custom ? menu.customIndex : model.choiceSelection; + controlBusy = true; + controlFeedback = ""; + void stopVoice(false); + paint(); + try { + await request("/g2/api/control", { + sessionId, + requestId: id(), + menuId: menu.id, + index, + action: custom ? "custom" : "choose", + }); + model.dismissMenu(menu.id); + notice = custom + ? "Введите свой ответ в поле ниже." + : "Нажатия переданы Codex."; + trace("choice:accepted"); + } catch { + notice = "Выбор не подтверждён. Проверьте обновлённое меню перед повтором."; + controlFeedback = "Выбор не подтверждён"; + } finally { + controlBusy = false; + paint(); + } +} +function chooseAgent() { + if (createBusy || voiceBusy || controlBusy || wanted || latestHome?.features?.sessionCreate !== true) return; + model.renameDialog = null; + model.closeDialog = null; + model.createDialog = { providers: MORE_AGENTS, selected: 0 }; + notice = "Выберите агента на очках: свайп и клик. Двойной клик — отмена."; + paint(); +} +async function createSession(provider) { + if (createBusy || latestHome?.features?.sessionCreate !== true) return; + createBusy = true; + model.createDialog = null; + model.renameDialog = null; + model.closeDialog = null; + notice = ""; + paint(); + for (const b of document.querySelectorAll("[data-new]")) b.disabled = true; + try { + void stopVoice(false); + const d = await request("/g2/api/create", { provider }); + latestHome = await request("/g2/api/home"); + model.home(latestHome); + renderHome(); + await openSession(d.session.id); + } catch (e) { + notice = + "Создание не подтверждено. Проверьте список перед повтором. " + e.message; + } finally { + createBusy = false; + for (const b of document.querySelectorAll("[data-new]")) b.disabled = false; + paint(); + } +} +async function back() { + if (model.createDialog) { model.createDialog = null; notice = ""; paint(); return; } + if (model.renameDialog) { + cancelRename(); + return; + } + if (model.closeDialog) { + model.closeDialog = null; + paint(); + return; + } + if (model.view === "terminal") return home(); + if (exitBusy) return; + exitBusy = true; + try { + void stopVoice(false); + // The OS asks for confirmation. Keep our page alive if the user cancels. + if (native && (await native.shutDownPageContainer(1)) !== true) { + notice = "Меню выхода не открылось. Приложение продолжает работать."; + paint(); + } + } finally { + exitBusy = false; + } +} +async function startVoice(test = false) { + const renameTarget = !test ? model.renameDialog : null; + if ( + !native || + !token || + !foreground || + overlay || + voiceBusy || + createBusy || + controlBusy || + model.closeDialog || + model.createDialog || + (!test && + !renameTarget && + (model.view !== "terminal" || !model.sessionId || model.interaction)) || + wanted || + micPending > 0 || + micOffRequest || + (!test && latestHome?.speechAvailable === false) + ) + return; + // A settled rejection is not a permanently busy channel. A new explicit + // hold may ask for ON again; only its own true ACK permits collecting PCM. + // Pending native operations remain a barrier, even after a caller timeout. + if (micFault) trace("mic:retry user-requested"); + const ticket = ++generation; + wanted = true; + micTest = test; + micTestBytes = 0; + clearAudio(); + voiceSession = test ? null : renameTarget?.sessionId || model.sessionId; + voicePurpose = renameTarget ? "rename" : "input"; + voiceRenameTarget = renameTarget; + startedAt = lastAudioAt = Date.now(); + notice = ""; + paint(); + try { + if ((await setMic(true, ticket)) !== true) return; + if (ticket === generation) { + paint(); + if (test) micTestTimer = setTimeout(() => void stopVoice(false), 2000); + } + } catch (e) { + if (ticket !== generation) return; + wanted = false; + micTest = false; + try { + if (micUsed) await setMic(false, ticket); + } catch {} + notice = e.message; + paint(); + } +} +async function stopVoice(submit, reason = "explicit", forceOff = false) { + const wasTest = micTest, + testBytes = micTestBytes; + micTest = false; + if (micTestTimer) { + clearTimeout(micTestTimer); + micTestTimer = null; + } + if (wasTest) submit = false; + const hadWanted = wanted; + const purpose = voicePurpose, + renameTarget = voiceRenameTarget; + wanted = false; + const ticket = ++generation; + const chunks = parts; + parts = []; + const length = size; + size = 0; + lastCaptureBytes = wasTest ? testBytes : length; + if (hadWanted || forceOff) + trace( + "voice:stop " + + reason + + " bytes=" + + length + + " elapsed=" + + Math.max(0, Date.now() - startedAt), + ); + if (!submit) { + for (const c of chunks) c.fill(0); + if (voiceRequest) + void request("/g2/api/cancel", { requestId: voiceRequest }).catch( + () => {}, + ); + } + paint(); + if ( + native && + micUsed && + (mic === true || hadWanted || micOffRequest || forceOff) + ) { + try { + await setMic(false, ticket); + } catch (e) { + for (const c of chunks) c.fill(0); + notice = e.message; + paint(); + return; + } + } + if (!submit) { + if (wasTest) { + notice = testBytes + ? "Тест: получено " + + testBytes + + " байт с G2. Агенту ничего не отправлено." + : "Тест: аудиоданные с G2 не получены. Агенту ничего не отправлено."; + trace("mic:test bytes=" + testBytes); + } + paint(); + return; + } + if (!hadWanted || ticket !== generation) { + for (const c of chunks) c.fill(0); + return; + } + if (length < 6400) { + for (const c of chunks) c.fill(0); + notice = "Недостаточно звука с очков. Повторите фразу."; + paint(); + return; + } + const pcm = new Uint8Array(length); + let offset = 0; + for (const c of chunks) { + pcm.set(c, offset); + offset += c.length; + c.fill(0); + } + let binary = ""; + for (let i = 0; i < pcm.length; i += 8192) + binary += String.fromCharCode(...pcm.subarray(i, i + 8192)); + pcm.fill(0); + const requestId = id(), + sessionId = voiceSession; + voiceRequest = requestId; + voiceBusy = true; + paint(); + try { + const result = await request("/g2/api/voice", { + requestId, + sessionId, + purpose, + audio: btoa(binary), + }); + binary = ""; + if (ticket !== generation) return; + if (result.accepted && purpose === "rename") { + if (model.renameDialog !== renameTarget) return; + const title = normalizeSessionTitle(result.transcript); + renameTarget.draft = title; + renameTarget.selected = 0; + renameTarget.requestId = id(); + $("rename-input").value = title; + notice = + "Проверьте имя: " + + title + + ". Для сохранения выберите «Сохранить имя»."; + } else if (result.accepted) { + model.pendingOwn = true; + notice = "Отправлено: " + result.transcript; + } else + notice = result.cancelled + ? "Отменено" + : "Речь не распознана. Команда не отправлена."; + } catch { + notice = + "Результат передачи неизвестен. Проверьте терминал перед повтором."; + } finally { + if (voiceRequest === requestId) { + voiceRequest = null; + voiceBusy = false; + } + paint(); + } +} +const gestures = new Gestures({ + select: () => { + if (model.createDialog) { + const selected = model.createDialog.providers[model.createDialog.selected]; + if (selected) void createSession(selected.provider); + } + else if (model.renameDialog) void confirmRename(); + else if (model.closeDialog) void confirmClose(); + else if (model.view === "terminal" && model.interaction) + void chooseOption(); + else if (model.view !== "terminal" && model.sessions.length) + void openSession(model.sessions[model.selection].id); + }, + back: () => void back(), + record: () => { + if (model.closeDialog || model.createDialog) return; + if (model.renameDialog) { + void startVoice(); + return; + } + if (model.view === "terminal" && model.interaction) void chooseOption(true); + else void startVoice(); + }, + send: () => void stopVoice(true, "hold-release"), +}); +async function event(e) { + if (e.menuItemClickEvent) { + gestures.cancel(); + const item = e.menuItemClickEvent.itemID; + if (item !== 7) model.createDialog = null; + trace("menu:" + Number(item)); + if (item === 1) await home(); + else if (item === 2) await createSession("codex"); + else if (item === 3) await createSession("terminal"); + else if (item === 4) requestClose(); + else if (item === 5) await openProjectBrowser(); + else if (item === 6) requestRename(); + else if (item === 7) chooseAgent(); + return; + } + if (e.audioEvent) { + const a = e.audioEvent; + if ( + !wanted || + mic !== true || + !foreground || + a.source !== sdk.AudioInputSource.Glasses + ) + return; + if ( + !(a.audioPcm instanceof Uint8Array) || + a.audioPcm.length % 2 || + a.audioPcm.length > 32000 + ) + return; + if (micTest) { + micTestBytes += a.audioPcm.length; + lastAudioAt = Date.now(); + return; + } + if (size + a.audioPcm.length > 960000) { + await stopVoice(false); + notice = "Фраза длиннее 30 секунд. Она не отправлена."; + paint(); + return; + } + if (a.audioPcm.length) { + parts.push(a.audioPcm.slice()); + size += a.audioPcm.length; + lastAudioAt = Date.now(); + } + return; + } + const input = inputEvent(e, sdk); + if (!input) return; + const t = input.type; + trace( + input.kind + + ":" + + t + + (input.defaulted ? " default-zero" : "") + + " view=" + + model.view + + " overlay=" + + overlay, + ); + // These events describe the OS overlay, not destruction/recreation of our page. + if (t === sdk.OsEventTypeList.FOREGROUND_ENTER_EVENT) { + overlay = true; + gestures.cancel(); + void stopVoice(false); + return; + } + if (t === sdk.OsEventTypeList.FOREGROUND_EXIT_EVENT) { + overlay = false; + lastFrame = ""; + paint(); + return; + } + if ( + t === sdk.OsEventTypeList.ABNORMAL_EXIT_EVENT || + t === sdk.OsEventTypeList.SYSTEM_EXIT_EVENT + ) { + foreground = false; + gestures.cancel(); + void stopVoice(false); + await hud.suspend(); + return; + } + if (!foreground || overlay) return; + if (t === sdk.OsEventTypeList.LONG_PRESS_EVENT) gestures.long(); + else if (t === sdk.OsEventTypeList.LONG_PRESS_RELEASE_EVENT) + gestures.release(); + else if (t === sdk.OsEventTypeList.DOUBLE_CLICK_EVENT) gestures.double(); + else if (t === sdk.OsEventTypeList.CLICK_EVENT) gestures.click(); + else if (t === sdk.OsEventTypeList.SCROLL_TOP_EVENT) { + controlFeedback = ""; + model.scroll(-1); + paint(); + } else if (t === sdk.OsEventTypeList.SCROLL_BOTTOM_EVENT) { + controlFeedback = ""; + model.scroll(1); + paint(); + } +} +async function poll() { + if (!alive) return; + try { + if (token && foreground) { + latestHome = await request("/g2/api/home"); + model.home(latestHome); + renderHome(); + const selected = + model.view === "terminal" + ? model.sessionId + : model.sessions[model.selection]?.id; + if (selected) { + const data = await request( + "/g2/api/terminal?id=" + encodeURIComponent(selected), + ); + model.terminal(data); + } + if (native) { + const state = { + clientVersion: "0.5.6", + display: hud?.displayState === "confirmed" ? "confirmed" : "unknown", + microphone: !micUsed + ? "never" + : mic === null + ? "unknown" + : mic + ? "on" + : "off", + audioBytes: lastCaptureBytes, + error: micFault, + diagnostics: [ + ...new Set([...micDiagnostics, ...diagnostics.slice(-8)]), + ] + .sort() + .slice(-24), + }; + const signature = JSON.stringify(state); + if (signature !== telemetrySignature) { + await request("/g2/api/device-state", state); + telemetrySignature = signature; + } + } + $("connection").textContent = native + ? "Компьютер подключён · G2" + : "Предпросмотр · без очков"; + paint(); + } + } catch { + $("connection").textContent = token + ? "Компьютер недоступен" + : "Нужно подключение"; + if (model.sessionId) model.loadFailed(model.sessionId); + if (wanted) await stopVoice(false); + paint(); + } finally { + if (alive) setTimeout(() => void poll(), 1200); + } +} +$("home").onclick = () => void home(); +$("prev").onclick = () => { + model.scroll(-1); + paint(); +}; +$("next").onclick = () => { + model.scroll(1); + paint(); +}; +$("back-home").onclick = () => void home(); +$("mic-stop").onclick = () => stopVoice(false, "retry-stop", true); +$("rename-terminal").onclick = requestRename; +$("rename-input").oninput = () => renameDraft($("rename-input").value); +$("rename-form").onsubmit = (e) => { + e.preventDefault(); + return confirmRename(true); +}; +$("rename-cancel").onclick = cancelRename; +$("close-terminal").onclick = requestClose; +$("project-browser").onclick = openProjectBrowser; +$("close-cancel").onclick = () => { + if (!controlBusy) { + model.closeDialog = null; + paint(); + } +}; +$("close-confirm").onclick = () => { + if (model.closeDialog) { + model.closeDialog.selected = 1; + return confirmClose(); + } +}; +$("mic-test").onclick = () => startVoice(true); +$("choose").onclick = () => chooseOption(); +$("custom").onclick = () => chooseOption(true); +$("manual").onsubmit = async (e) => { + e.preventDefault(); + if ( + controlBusy || + voiceBusy || + model.view !== "terminal" || + model.interaction || + model.renameDialog + ) + return; + const sessionId = model.sessionId, + text = $("draft").value; + if (!text.trim()) return; + controlBusy = true; + paint(); + try { + await request("/g2/api/control", { + sessionId, + requestId: id(), + action: "text", + text, + }); + drafts.delete(sessionId); + if (model.sessionId === sessionId) { + $("draft").value = ""; + model.pendingOwn = true; + notice = "Текст передан в терминал."; + } + } catch { + notice = + "Передача текста не подтверждена. Проверьте терминал перед повтором."; + } finally { + controlBusy = false; + paint(); + } +}; +$("voice").onpointerdown = (e) => { + e.preventDefault(); + e.currentTarget.setPointerCapture(e.pointerId); + void startVoice(); +}; +$("voice").onpointerup = (e) => { + e.preventDefault(); + void stopVoice(true); +}; +$("voice").onpointercancel = () => void stopVoice(false); +for (const b of document.querySelectorAll("[data-new]")) + b.onclick = () => createSession(b.dataset.new); +async function connectComputer(candidate) { + if (pairBusy) return; + $("pair-status").textContent = ""; + if (!candidate && token) { + notice = "Компьютер уже подключён. Выберите терминал."; + $("pair").closest("details").open = false; + paint(); + return; + } + pairBusy = true; + $("pair-submit").disabled = true; + try { + pairAbort = new AbortController(); + $("pair-status").textContent = "Соединяюсь с Mac по локальной сети…"; + const resolved = LOCAL_ONLY + ? await connectionFromCode(candidate, { signal: pairAbort.signal, onTrace: message => trace("pair:" + message) }) + : null; + const target = resolved?.connection || null; + candidate = resolved?.code || candidate; + const targetFetch = target ? localFetcher(target) : fetch; + const paired = await validatePairing(candidate, { + probe: (code) => + pairComputer(target?.origins[0] || BASE, code, { + fetcher: targetFetch, + signal: pairAbort.signal, + onPending: () => { + $("pair-status").textContent = + "Подтвердите подключение в CanvasTTY на компьютере."; + }, + }), + persist: native + ? (key) => + target + ? native.setLocalStorage( + CONNECTION_KEY, + JSON.stringify({ + connection: targetFetch.connection(), + token: key, + }), + ) + : native.setLocalStorage(KEY, key) + : null, + }); + token = paired.token; + localConnection = target ? targetFetch.connection() : null; + localFetch = target ? targetFetch : null; + latestHome = paired.home; + $("token").value = ""; + model.home(latestHome); + renderHome(); + notice = "Компьютер подключён"; + $("connection").textContent = native + ? "Компьютер подключён · G2" + : "Предпросмотр · без очков"; + $("pair").closest("details").open = false; + setPanel("workspace"); + } catch (e) { + notice = e.name === "AbortError" ? "Подключение отменено." : e.message; + $("pair-status").textContent = notice + (e.diagnostics?.length ? "\n" + e.diagnostics[0] : ""); + } finally { + pairAbort = null; + pairBusy = false; + $("pair-submit").disabled = false; + paint(); + } +} +$("pair").onsubmit = (e) => { + e.preventDefault(); + return connectComputer($("token").value.trim()); +}; +setInterval(() => { + if ( + wanted && + (Date.now() - lastAudioAt > 8000 || Date.now() - startedAt > 30000) + ) { + const reason = + Date.now() - lastAudioAt > 8000 ? "audio-gap" : "max-duration"; + void stopVoice(false, reason).then(() => { + notice = + "Приём остановлен: нет звука или превышено 30 секунд. Фраза не отправлена."; + paint(); + }); + } +}, 1000); +window.addEventListener("pagehide", () => { + trace("lifecycle:pagehide"); + pairAbort?.abort(); + alive = false; + foreground = false; + void stopVoice(false); + void hud?.suspend(); +}); +try { + native = await Promise.race([ + sdk.waitForEvenAppBridge(), + new Promise((_, reject) => + setTimeout(() => reject(new Error("No native bridge")), 5000), + ), + ]); + if (typeof native.callEvenApp === "function") { + const hostCall = native.callEvenApp.bind(native); + native.callEvenApp = async (method, params) => { + const result = await hostCall(method, params); + if (method === "audioControl") + trace( + "mic:host " + + (params?.isOpen ? "on" : "off") + + " result=" + + (result === null || ["boolean", "number"].includes(typeof result) + ? JSON.stringify(result) + : typeof result), + ); + return result; + }; + } + if (!(await native.getDeviceInfo())) throw new Error("No glasses"); + hud = new HudBridge(native, sdk); + native.onEvenHubEvent( + (e) => void event(e).catch(() => void stopVoice(false)), + ); + if (!(await hud.startPage())) throw new Error("No display acknowledgment"); + // No off probe at startup: an idle microphone can reject it. Do not claim + // hardware is off; first enable is an explicit user request requiring ACK. + trace("startup: microphone not requested"); + if (LOCAL_ONLY) { + const saved = await native.getLocalStorage(CONNECTION_KEY); + if (saved) { + try { + const value = readLocalConnection(saved); + token = value.token; + localConnection = value.connection; + localFetch = localFetcher(localConnection); + } catch { + notice = "Введите новый код подключения с компьютера."; + } + } + } else token = (await native.getLocalStorage(KEY)) || ""; +} catch { + native = null; + hud = null; +} +$("open-connection").onclick = () => { + void stopVoice(false, "connection"); + setPanel("connection"); +}; +$("pair-cancel").onclick = () => pairAbort?.abort(); +for (const button of document.querySelectorAll("[data-panel]")) + button.onclick = () => setPanel(button.dataset.panel); +const offeredCode = new URLSearchParams(location.hash.slice(1)).get("pair"); +if (offeredCode && /^\d{6}$/.test(offeredCode)) { + $("token").value = offeredCode; + $("pair-status").textContent = + "Нажмите «Подключить компьютер», затем подтвердите запрос на Mac."; + history.replaceState(null, "", location.pathname + location.search); +} +setPanel(offeredCode || !token ? "connection" : "workspace"); +$("connection").textContent = token + ? "Проверяю подключение…" + : "Нужно подключение"; +paint(); +void poll(); diff --git a/integrations/even-g2/src/model.mjs b/integrations/even-g2/src/model.mjs new file mode 100644 index 000000000..b3e166e05 --- /dev/null +++ b/integrations/even-g2/src/model.mjs @@ -0,0 +1,314 @@ +export const STATUS = { + idle: "Готов", + working: "Работает", + needs_approval: "Нужен ответ", + unavailable: "Статус неизвестен", + done: "Завершён", + failed: "Ошибка", +}; +export function wrap(text, width = 32) { + const lines = []; + for (const line of String(text || "") + .replace(/[\x00-\x08\x0b\x0c\x0e-\x1f]/g, "") + .split("\n")) { + const rest = Array.from(line.trim()); + if (!rest.length) { + if (lines.at(-1) !== "") lines.push(""); + continue; + } + while (rest.length) { + let end = Math.min(width, rest.length); + if (rest.length > width) { + const s = rest.slice(0, width + 1).lastIndexOf(" "); + if (s > 8) end = s; + } + lines.push(rest.splice(0, end).join("")); + while (rest[0] === " ") rest.shift(); + } + } + return lines; +} +export class ViewModel { + constructor() { + this.view = "home"; + this.sessions = []; + this.selection = 0; + this.sessionId = null; + this.page = 0; + this.body = ""; + this.revision = ""; + this.state = "idle"; + this.pendingOwn = false; + this.limits = null; + this.cache = new Map(); + this.interaction = null; + this.choiceSelection = 0; + this.consumedMenus = new Set(); + this.closeDialog = null; + this.renameDialog = null; + this.createDialog = null; + } + home(data) { + const selected = this.sessions[this.selection]?.id; + this.sessions = data.sessions; + this.limits = data.limits; + for (const id of this.cache.keys()) + if (!this.sessions.some((s) => s.id === id)) this.cache.delete(id); + const i = this.sessions.findIndex((s) => s.id === selected); + this.selection = + i >= 0 + ? i + : Math.min(this.selection, Math.max(0, this.sessions.length - 1)); + if (this.sessionId && !this.sessions.some((s) => s.id === this.sessionId)) { + this.view = "home"; + this.sessionId = null; + this.interaction = null; + } + if ( + this.closeDialog && + !this.sessions.some((s) => s.id === this.closeDialog.sessionId) + ) + this.closeDialog = null; + if ( + this.renameDialog && + !this.sessions.some( + (session) => session.id === this.renameDialog.sessionId, + ) + ) + this.renameDialog = null; + } + open(id) { + this.view = "terminal"; + this.sessionId = id; + this.page = 0; + this.body = "Загружаю ответ…"; + this.revision = ""; + this.interaction = null; + this.state = this.sessions.find((s) => s.id === id)?.status || "idle"; + this.pendingOwn = false; + const cached = this.cache.get(id); + if (cached) this.terminal(cached); + } + loadFailed(id) { + if (this.sessionId === id && !this.revision) { + this.body = "Не удалось загрузить ответ. Проверьте подключение к Mac."; + this.state = "failed"; + } + } + terminal(data) { + this.cache.set(data.session.id, data); + if (data.session.id !== this.sessionId) return; + this.state = data.session.status; + const next = data.interaction?.kind === "choices" ? data.interaction : null; + if (next && this.consumedMenus.has(next.id)) return; + if (next?.id !== this.interaction?.id) + this.choiceSelection = next?.selected ?? 0; + this.interaction = next; + if (data.revision !== this.revision) { + this.body = data.body; + this.revision = data.revision; + if (this.pendingOwn && this.state !== "working") { + this.page = 0; + this.pendingOwn = false; + } + this.page = Math.min(this.page, this.pages().length - 1); + } + } + dismissMenu(id) { + this.consumedMenus.add(id); + if (this.consumedMenus.size > 32) + this.consumedMenus.delete(this.consumedMenus.values().next().value); + if (this.interaction?.id === id) { + this.interaction = null; + this.body = "Выбор передан Codex. Ожидаю обновления."; + this.revision = ""; + } + } + choiceBody() { + const menu = this.interaction; + return menu + ? menu.title + + "\n\n" + + menu.options + .map( + (o, i) => + (i === this.choiceSelection ? "› " : " ") + + o.number + + ". " + + o.label, + ) + .join("\n") + : this.body; + } + pages() { + const lines = wrap(this.body); + const result = []; + for (let i = 0; i < Math.max(1, lines.length); i += 6) + result.push(lines.slice(i, i + 6).join("\n")); + return result; + } + scroll(delta) { + if (this.createDialog) { + this.createDialog.selected = Math.max(0, Math.min( + this.createDialog.providers.length - 1, this.createDialog.selected + delta, + )); + } else if (this.renameDialog) { + if (this.renameDialog.draft) + this.renameDialog.selected = Math.max( + 0, + Math.min(1, this.renameDialog.selected + delta), + ); + } else if (this.closeDialog) { + this.closeDialog.selected = Math.max( + 0, + Math.min(1, this.closeDialog.selected + delta), + ); + } else if (this.view === "terminal" && this.interaction) { + this.choiceSelection = Math.max( + 0, + Math.min( + this.interaction.options.length - 1, + this.choiceSelection + delta, + ), + ); + } else if (this.view === "terminal") { + this.page = Math.max( + 0, + Math.min(this.pages().length - 1, this.page + delta), + ); + } else { + this.selection = Math.max( + 0, + Math.min(this.sessions.length - 1, this.selection + delta), + ); + } + } + frame(override = "", warning = "") { + if (override) + return ["CANVASTTY", ...wrap(override).slice(0, 8)].join("\n"); + if (this.createDialog) { + const { providers, selected } = this.createDialog; + const start = Math.max(0, selected - 5); + return [ + "СОЗДАТЬ АГЕНТА", + ...providers.slice(start, start + 6).map((provider, i) => + `${start + i === selected ? "> " : " "}${provider.label}`), + "Свайп: выбрать · клик: создать", + "2 клика: отмена", + ].join("\n"); + } + if (this.renameDialog) { + const d = this.renameDialog; + return [ + "ПЕРЕИМЕНОВАТЬ ТЕРМИНАЛ", + ...wrap(d.draft || d.original).slice(0, 3), + ...(d.draft + ? [ + (d.selected === 0 ? "> " : " ") + "Отмена", + (d.selected === 1 ? "> " : " ") + "Сохранить имя", + warning || "Свайп: выбор · клик: подтвердить", + ] + : [ + warning || "Удерживай: продиктовать имя", + "Или введи имя на телефоне", + ]), + "2 клика: отмена", + ].join("\n"); + } + if (this.closeDialog) { + const d = this.closeDialog; + return [ + "ЗАКРЫТЬ ТЕРМИНАЛ?", + ...wrap(d.title).slice(0, 2), + "Процесс в окне будет остановлен.", + "Файлы проекта сохранятся.", + (d.selected === 0 ? "> " : " ") + "Отмена", + (d.selected === 1 ? "> " : " ") + "Закрыть терминал", + warning || "Свайп: выбрать · клик: подтвердить", + "2 клика: отмена", + ].join("\n"); + } + if (this.view === "terminal") { + if (this.interaction) { + const menu = this.interaction, + question = wrap(menu.title), + option = wrap(menu.options[this.choiceSelection]?.label || ""); + const short = (lines, n) => + lines.length > n + ? [...lines.slice(0, n - 1), lines[n - 1].slice(0, 29) + "…"] + : lines; + return [ + "CODEX · ВЫБОР", + warning || "Свайп: сменить вариант", + ...short(question, 2), + "> " + (this.choiceSelection + 1) + ".", + ...short(option, 3), + "Клик: выбрать · 2 клика: Home", + menu.customIndex !== null + ? "Удержание: свой ответ" + : "Свой ответ не предусмотрен", + ].join("\n"); + } + const s = this.sessions.find((s) => s.id === this.sessionId); + return [ + (s?.title || "Терминал").slice(0, 32), + warning || STATUS[this.state] || this.state, + this.pages()[this.page] || "", + `${this.page + 1}/${this.pages().length} · свайп / 2 клика: Home`, + ].join("\n"); + } + const provider = this.limits?.providers?.find( + (p) => p.provider === "codex", + ); + const window = + provider?.windows?.find( + (w) => w.isDefaultBucket && w.usedPercent !== null, + ) || provider?.windows?.find((w) => w.usedPercent !== null); + const quota = window + ? `Codex: ${window.usedPercent}% использовано${provider.state === "stale" ? " *" : ""}` + : "Codex: лимиты не получены"; + const active = this.sessions.filter((s) => s.status === "working").length; + const start = Math.max(0, this.selection - 1); + return [ + "CANVASTTY · HOME", + warning || quota, + `Терминалы: ${this.sessions.length} В работе: ${active}`, + ...this.sessions + .slice(start, start + 3) + .map( + (s, i) => + `${start + i === this.selection ? ">" : " "} ${s.title.slice(0, 20)} · ${STATUS[s.status] || "?"}`, + ), + this.sessions.length + ? "Клик: открыть выбранный" + : "Создать: в меню очков", + "Создать агента: меню More agents", + ].join("\n"); + } +} +export class Gestures { + constructor(actions) { + this.actions = actions; + this.recording = false; + } + click() { + this.actions.select(); + } + long() { + this.recording = true; + this.actions.record(); + } + release() { + if (this.recording) { + this.recording = false; + this.actions.send(); + } + } + cancel() { + this.recording = false; + } + double() { + this.cancel(); + this.actions.back(); + } +} diff --git a/integrations/even-g2/src/pairing.mjs b/integrations/even-g2/src/pairing.mjs new file mode 100644 index 000000000..cd01b56e5 --- /dev/null +++ b/integrations/even-g2/src/pairing.mjs @@ -0,0 +1,19 @@ +export async function validatePairing(code, { probe, persist }) { + const normalized = String(code || "").trim(); + if (!/^\d{6}$/.test(normalized)) + throw new Error( + "Введите шестизначный код из CanvasTTY. Текущее подключение сохранено.", + ); + const result = await probe(normalized); + if ( + !result || + !Array.isArray(result.home?.sessions) || + !/^[a-f0-9]{64}$/.test(result.token) + ) + throw new Error("Подключение не подтверждено."); + if (persist && (await persist(result.token)) !== true) + throw new Error( + "Не удалось сохранить подключение. Текущее подключение сохранено.", + ); + return result; +} diff --git a/integrations/even-g2/src/style.css b/integrations/even-g2/src/style.css new file mode 100644 index 000000000..fd3648434 --- /dev/null +++ b/integrations/even-g2/src/style.css @@ -0,0 +1,681 @@ +@import "@fontsource/ibm-plex-sans/400.css"; +@import "@fontsource/ibm-plex-sans/500.css"; +@import "@fontsource/ibm-plex-sans/600.css"; +@import "@fontsource/ibm-plex-mono/400.css"; + +:root { + font-family: "IBM Plex Sans", sans-serif; + color: #293027; + background: #f4f3ed; + font-synthesis: none; + --paper: #f4f3ed; + --panel: #fffef8; + --ink: #293027; + --muted: #71786c; + --line: #d7dbce; + --sage: #b8cf99; + --dark: #253b2a; + --danger: #974437; + --mono: "IBM Plex Mono", monospace; +} +* { + box-sizing: border-box; +} +[hidden] { + display: none !important; +} +body { + margin: 0; +} +button, +input, +textarea { + font: inherit; +} +button { + cursor: pointer; + min-height: 44px; + border: 1px solid var(--line); + border-radius: 10px; + background: var(--panel); + color: var(--ink); + padding: 10px 15px; + font-weight: 500; + transition: + background 0.15s, + border-color 0.15s; +} +button:hover:not(:disabled) { + background: #e8eddf; + border-color: #acb89b; +} +button:focus-visible, +input:focus-visible, +textarea:focus-visible, +summary:focus-visible { + outline: 3px solid #6a8255; + outline-offset: 3px; +} +button:disabled { + opacity: 0.4; + cursor: not-allowed; +} +button.primary { + background: var(--dark); + color: #fffef8; + border-color: var(--dark); +} +button.primary:hover:not(:disabled) { + background: #38543d; +} +button.quiet { + background: transparent; + border-color: transparent; + color: var(--muted); +} +button.danger { + background: var(--danger); + color: white; + border-color: var(--danger); +} +button.danger-quiet { + color: var(--danger); +} +.shell { + max-width: 1120px; + margin: auto; + padding: 22px 28px max(24px, env(safe-area-inset-bottom)); +} +.app-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 18px; + padding-bottom: 23px; +} +.wordmark { + display: flex; + align-items: center; + gap: 12px; +} +.brand-mark { + display: grid; + place-items: center; + width: 44px; + height: 44px; + border-radius: 12px; + background: var(--ink); + color: var(--sage); + font: 600 20px var(--mono); + letter-spacing: -2px; +} +.wordmark b { + display: block; + font-size: 22px; + letter-spacing: -0.8px; + line-height: 1.1; +} +.wordmark small { + display: block; + margin-top: 6px; + font: 9px var(--mono); + letter-spacing: 1.4px; + color: var(--muted); +} +.connection-pill { + display: flex; + align-items: center; + gap: 8px; + border: 0; + background: #e6eadf; + border-radius: 999px; + font-size: 11px; + min-height: 34px; + max-width: 50%; + text-align: left; +} +.status-dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--muted); + flex-shrink: 0; +} +.connection-pill[data-connected="true"] .status-dot { + background: #4b733c; +} +.app-nav { + display: flex; + gap: 4px; + border-bottom: 1px solid var(--line); + padding-bottom: 9px; +} +.app-nav button { + border: 0; + border-radius: 8px; + background: transparent; + color: var(--muted); + font-size: 14px; + padding: 10px 17px; +} +.app-nav button[aria-pressed="true"] { + background: var(--ink); + color: #fffef8; +} +.page-heading { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 22px; + padding: 28px 0 24px; +} +.eyebrow { + font: 10px var(--mono); + letter-spacing: 1.35px; + color: var(--muted); + margin: 0 0 10px; +} +h1 { + font-size: clamp(28px, 4vw, 38px); + line-height: 1.1; + letter-spacing: -1.4px; + font-weight: 500; + margin: 0; +} +h2 { + font-size: 17px; + letter-spacing: -0.35px; + font-weight: 600; + margin: 0; +} +h3 { + font-size: 19px; + line-height: 1.35; + margin: 4px 0 12px; +} +.lead { + font-size: 15px; + line-height: 1.5; + color: var(--muted); + max-width: 420px; + margin: 16px 0 0; +} +#clock { + font: 12px var(--mono); + color: var(--muted); + margin-bottom: 3px; +} +.workspace-grid { + display: grid; + grid-template-columns: 275px minmax(0, 1fr); + gap: 22px; + align-items: start; +} +.sessions-column { + display: grid; + gap: 18px; +} +.session-panel, +.answer-panel, +.settings-card, +.pairing-card { + border: 1px solid var(--line); + border-radius: 16px; + background: var(--panel); +} +.session-panel { + padding: 17px; +} +.section-heading { + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; +} +.section-heading .quiet { + min-height: 28px; + padding: 4px 0; + font-size: 12px; +} +.meta { + color: var(--muted); + font: 11px var(--mono); + margin: 7px 0 18px; +} +.session-list { + display: grid; + gap: 7px; +} +.session { + position: relative; + text-align: left; + padding: 13px 27px 13px 13px; + width: 100%; + border-color: transparent; + background: #eeefe7; + font-size: 14px; + line-height: 1.35; + overflow-wrap: anywhere; +} +.session small { + display: block; + font: 10px var(--mono); + color: var(--muted); + margin-top: 7px; +} +.session.active { + background: #dce8ca; + border-color: #bccbaa; +} +.session.active:after { + content: "›"; + position: absolute; + right: 12px; + top: 15px; + color: #3c5330; +} +.empty-state { + font-size: 14px; + line-height: 1.6; + color: var(--muted); + border-top: 1px solid var(--line); + padding-top: 18px; +} +.launch { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 7px; + margin-top: 16px; +} +.launch button { + flex: 1; + font-size: 12px; + padding: 10px 8px; +} +.quota-panel { + padding: 18px 19px; + border-left: 3px solid var(--sage); + background: #e9ecdf; + border-radius: 0 12px 12px 0; +} +.quota-panel #limits { + font-size: 13px; + line-height: 1.9; +} +.answer-panel { + padding: 22px; +} +.answer-heading { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 15px; + padding-bottom: 18px; + border-bottom: 1px solid var(--line); +} +.answer-heading h2 { + overflow-wrap: anywhere; + line-height: 1.35; +} +.phase { + font: 10px var(--mono); + color: var(--muted); + flex-shrink: 0; + max-width: 42%; + line-height: 1.5; + text-align: right; +} +#answer { + min-height: 150px; + max-height: 470px; + overflow: auto; + white-space: pre-wrap; + overflow-wrap: anywhere; + font: + 15px/1.65 "IBM Plex Sans", + sans-serif; + margin: 24px 0; + color: #333b2d; +} +.page-controls { + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; + margin-bottom: 18px; +} +.page-controls button { + width: 44px; + padding: 8px; +} +.page-controls span { + font: 11px var(--mono); + color: var(--muted); +} +label { + display: block; + font-size: 12px; + color: var(--muted); + margin-bottom: 9px; +} +input, +textarea { + width: 100%; + border: 1px solid #c9cfc0; + border-radius: 10px; + background: #fcfcf7; + color: var(--ink); + padding: 13px 14px; + resize: vertical; +} +input::placeholder, +textarea::placeholder { + color: #969c8e; +} +input { + min-height: 48px; +} +textarea { + font-size: 15px; + line-height: 1.5; + min-height: 92px; +} +#manual { + border-top: 1px solid var(--line); + padding-top: 17px; +} +#manual button { + display: block; + margin: 10px 0 0 auto; + font-size: 13px; +} +.voice-section { + padding-top: 20px; + margin-top: 20px; + border-top: 1px solid var(--line); +} +.voice { + width: 100%; + min-height: 58px; + background: var(--sage); + border-color: #abc08e; + font-size: 15px; + touch-action: none; + user-select: none; +} +.voice.recording { + background: var(--dark); + color: #fffef8; + box-shadow: 0 0 0 4px #b8cf993d; +} +.voice-section p { + font-size: 12px; + line-height: 1.45; + color: var(--muted); + margin: 11px 0 2px; +} +.action-row { + display: flex; + gap: 9px; + flex-wrap: wrap; +} +.voice-section .action-row { + justify-content: space-between; +} +.voice-section .quiet { + font-size: 11px; + padding: 5px 0; + min-height: 34px; +} +.notice { + font-size: 12px; + line-height: 1.55; + color: var(--muted); + overflow-wrap: anywhere; + min-height: 18px; +} +.session-actions { + display: flex; + gap: 8px; + padding-top: 16px; + margin-top: 20px; + border-top: 1px solid var(--line); +} +.session-actions button { + flex: 1; + font-size: 12px; +} +.close-dialog { + background: #f8eae3; + border: 1px solid #d9b8a8; + border-radius: 10px; + padding: 18px; + margin-top: 15px; +} +.close-dialog p { + font-size: 13px; + line-height: 1.5; +} +.close-dialog .action-row button { + flex: 1; +} +.connection-path { + display: flex; + align-items: center; + justify-content: space-between; + max-width: 510px; + margin: 7px 0 28px; + padding: 20px 0; + border-top: 1px solid var(--line); + border-bottom: 1px solid var(--line); +} +.connection-path > span { + font: 10px var(--mono); + color: #738267; +} +.connection-path > span > span { + display: block; + font: + 500 14px "IBM Plex Sans", + sans-serif; + color: var(--ink); + margin-top: 8px; +} +.connection-path i { + font-style: normal; + color: #a7b497; +} +.pairing-card { + max-width: 510px; + padding: 22px; +} +.pairing-card summary { + font-weight: 500; + margin-bottom: 22px; +} +.pairing-card .primary { + width: 100%; + margin-top: 10px; +} +.field-help { + font-size: 12px; + line-height: 1.6; + color: var(--muted); + margin: 10px 0 15px; +} +summary { + cursor: pointer; + min-height: 28px; + font-size: 14px; +} +.settings-panel { + max-width: 710px; +} +.settings-card { + padding: 22px; + margin-bottom: 13px; +} +.small-tag { + font: 9px var(--mono); + color: var(--muted); + letter-spacing: 0.7px; +} +.settings-card #speech-engine { + font-size: 19px; + letter-spacing: -0.3px; +} +.settings-card #mic-test { + width: 100%; + font-size: 12px; +} +.gesture-list { + margin: 15px 0 0; +} +.gesture-list div { + display: flex; + gap: 14px; + justify-content: space-between; + padding: 12px 0; + border-bottom: 1px solid var(--line); + font-size: 13px; +} +.gesture-list div:last-child { + border-bottom: 0; +} +.gesture-list dt { + font-weight: 500; +} +.gesture-list dd { + margin: 0; + color: var(--muted); + text-align: right; +} +.hud-preview { + font: 13px/1.5 var(--mono); + padding: 17px; + background: #152218; + color: #cae7ad; + border-radius: 10px; + white-space: pre-wrap; + overflow-wrap: anywhere; + margin: 16px 0; +} +.diagnostics { + font: 10px/1.8 var(--mono); + max-height: 240px; + overflow: auto; + white-space: pre-wrap; + overflow-wrap: anywhere; +} +footer { + display: flex; + justify-content: space-between; + gap: 15px; + color: #969c8f; + font: 9px var(--mono); + padding-top: 34px; + line-height: 1.6; +} +@media (max-width: 780px) { + .workspace-grid { + grid-template-columns: minmax(0, 1fr); + } + .sessions-column { + grid-template-columns: minmax(0, 1fr); + } + .quota-panel { + padding: 12px 17px; + } + .quota-panel .eyebrow { + margin-bottom: 4px; + } + .session-list { + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + } + .session-panel { + padding: 16px; + } + .session-panel .meta { + margin-bottom: 13px; + } + .launch { + max-width: 350px; + } + .page-heading { + padding-top: 25px; + } + #answer { + min-height: 120px; + max-height: 340px; + } + .answer-panel { + padding: 19px; + } +} +@media (max-width: 460px) { + .shell { + padding: 18px 16px max(22px, env(safe-area-inset-bottom)); + } + .wordmark b { + font-size: 20px; + } + .wordmark small { + font-size: 8px; + } + .brand-mark { + width: 38px; + height: 38px; + font-size: 18px; + } + .app-header { + gap: 8px; + padding-bottom: 17px; + } + .connection-pill { + font-size: 9px; + padding: 8px 10px; + max-width: 47%; + } + .app-nav { + gap: 2px; + } + .app-nav button { + flex: 1; + font-size: 13px; + padding: 9px 5px; + } + h1 { + font-size: 29px; + } + .session-list { + grid-template-columns: 1fr; + } + .answer-heading h2 { + font-size: 16px; + } + .small-tag { + font-size: 8px; + max-width: 37%; + text-align: right; + } + .settings-card { + padding: 19px; + } + .gesture-list { + font-size: 12px; + } + .action-row { + gap: 6px; + } + footer { + font-size: 8px; + } + .connection-path > span > span { + font-size: 12px; + } +} +@media (prefers-reduced-motion: reduce) { + * { + transition: none !important; + } +} diff --git a/integrations/even-g2/tests/client.test.mjs b/integrations/even-g2/tests/client.test.mjs new file mode 100644 index 000000000..1d555237c --- /dev/null +++ b/integrations/even-g2/tests/client.test.mjs @@ -0,0 +1,768 @@ +import { MORE_AGENTS } from "../src/create-menu.mjs"; +import { CANVAS_LAUNCHER_ITEMS } from "../../../src/shared/contracts.ts"; +import { normalizeSessionTitle } from "../../../src/shared/companion.ts"; +import test from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import vm from "node:vm"; +import * as sdk from "@evenrealities/even_hub_sdk"; +import { HudBridge } from "../src/hud-bridge.mjs"; +import { ViewModel, Gestures, STATUS } from "../src/model.mjs"; +import { validatePairing } from "../src/pairing.mjs"; +import { inputEvent } from "../src/input.mjs"; + +const source = readFileSync( + new URL("../src/main.mjs", import.meta.url), + "utf8", +).replace(/^import\b[\s\S]*?;\n/gm, ""); +const flush = async () => { + for (let i = 0; i < 8; i++) await new Promise(setImmediate); +}; +class Element { + constructor() { + this.textContent = ""; + this.value = ""; + this.dataset = {}; + this.children = []; + this.classList = { toggle() {} }; + this.details = { open: true }; + } + replaceChildren() { + this.children = []; + } + append(child) { + this.children.push(child); + } + get childNodes() { + return this.children; + } + closest() { + return this.details; + } +} +async function harness({ localOnly = false } = {}) { + const key = "a".repeat(64), + elements = new Map(), + buttons = ["codex", "terminal", ...CANVAS_LAUNCHER_ITEMS.filter(p => p !== "codex" && p !== "terminal")].map(provider => { + const button = new Element(); button.dataset.new = provider; return button; + }); + const el = (id) => { + if (!elements.has(id)) elements.set(id, new Element()); + return elements.get(id); + }; + const sessions = [ + { id: "existing", title: "Codex · G2", provider: "codex", status: "idle" }, + ]; + const requests = [], + frames = [], + exits = [], + micCalls = [], + timers = new Map(); + const saved = [], + enteredCodes = []; + const localTarget = { + version: 1, + computer: "c".repeat(64), + key: "d".repeat(64), + origins: ["http://192.168.2.108:3481"], + }; + let nextTimer = 0, + callback, + page, + failTerminal = false, + audioImpl = async () => true, + interaction = null; + const native = { + getDeviceInfo: async () => ({}), + onEvenHubEvent: (cb) => { + callback = cb; + }, + getLocalStorage: async () => (localOnly ? "" : key), + setLocalStorage: async (name, value) => { + saved.push({ name, value }); + return true; + }, + audioControl: (on, source) => { + micCalls.push({ on, source }); + return audioImpl(on, source); + }, + createStartUpPageContainer: async (p) => { + page = p; + return 0; + }, + textContainerUpgrade: async (p) => { + frames.push(p.content); + return true; + }, + shutDownPageContainer: async (mode) => { + exits.push(mode); + return true; + }, + }; + const context = { + LOCAL_ONLY: localOnly, + MORE_AGENTS, + connectionFromCode: async (value) => { + enteredCodes.push(value); + return { connection: localTarget, code: "123456" }; + }, + localFetcher: (connection) => + Object.assign((url, options) => context.fetch(url, options), { + connection: () => connection, + }), + BRIDGE_ORIGIN: "http://127.0.0.1:3480", + sdk: { ...sdk, waitForEvenAppBridge: async () => native }, + HudBridge, + ViewModel, + Gestures, + STATUS, + normalizeSessionTitle, + validatePairing, + inputEvent, + document: { + getElementById: el, + querySelectorAll: (selector) => + selector === "[data-new]" ? buttons : [], + createElement: () => new Element(), + }, + window: { addEventListener() {} }, + location: { hash: "" }, + history: { replaceState() {} }, + URLSearchParams, + AbortController, + AbortSignal, + pairComputer: async (base, code, options) => { + if (!localOnly) throw new Error("pairing rejected"); + assert.equal(code, "123456"); + options.onPending(); + return { + token: key, + home: await ( + await options.fetcher(base + "/g2/api/home", { + headers: { Authorization: "Bearer " + key }, + }) + ).json(), + }; + }, + Uint8Array, + Date, + btoa, + crypto: globalThis.crypto, + setInterval: () => 1, + setTimeout: (cb, ms) => { + const id = ++nextTimer; + timers.set(id, { cb, ms }); + return id; + }, + clearTimeout: (id) => timers.delete(id), + fetch: async (url, options) => { + requests.push({ + path: new URL(url).pathname, + authorization: options.headers.Authorization, + body: options.body ? JSON.parse(options.body) : null, + }); + if (options.headers.Authorization !== "Bearer " + key) + return { ok: false }; + const path = new URL(url).pathname; + let data; + if (path === "/g2/api/home") + data = { + sessions: sessions.map((s) => ({ ...s })), + limits: null, + speechAvailable: true, + speechModel: "Nemotron 3.5 (Handy)", + features: { + terminalChoices: true, + manualInput: true, + sessionClose: true, + projectBrowser: true, + sessionCreate: true, + sessionRename: true, + }, + }; + if (path === "/g2/api/terminal") { + if (failTerminal) throw new Error("Timeout"); + const session = sessions.find( + (s) => s.id === new URL(url).searchParams.get("id"), + ); + data = { + session: { ...session }, + body: "Понг.", + revision: interaction?.id || "1", + interaction, + }; + } + if (path === "/g2/api/create") { + const provider = JSON.parse(options.body).provider; + const session = { + id: "new-" + sessions.length, + title: provider, + provider, + status: "idle", + }; + sessions.push(session); + data = { session }; + } + if (path === "/g2/api/session-rename") { + const body = JSON.parse(options.body), + session = sessions.find((session) => session.id === body.sessionId); + session.title = body.title; + data = { session: { ...session } }; + } + if (path === "/g2/api/voice") + data = { accepted: true, transcript: "Бот поддержки" }; + if (path === "/g2/api/control") { + interaction = null; + data = { accepted: true }; + } + if (path === "/g2/api/session-close") { + const body = JSON.parse(options.body); + const i = sessions.findIndex((s) => s.id === body.sessionId); + if (i >= 0) sessions.splice(i, 1); + data = { closed: true }; + } + if (path === "/g2/api/browser") + data = { + opened: true, + title: "Project", + url: "http://127.0.0.1:5173/", + }; + return { ok: true, json: async () => data }; + }, + }; + await vm.runInNewContext(`(async()=>{${source}\n})()`, context); + await flush(); + return { + el, + saved, + enteredCodes, + buttons, + requests, + frames, + exits, + micCalls, + setMenu: (value) => { + interaction = value; + }, + setAudio: (fn) => { + audioImpl = fn; + }, + timeout: async (ms) => { + for (const t of [...timers.values()].filter((t) => t.ms === ms)) t.cb(); + await flush(); + }, + pressVoice: async () => { + el("voice").onpointerdown({ + preventDefault() {}, + currentTarget: { setPointerCapture() {} }, + pointerId: 1, + }); + await flush(); + }, + get page() { + return page; + }, + fail: () => { + failTerminal = true; + }, + event: async (e) => { + callback(e); + await flush(); + }, + poll: async () => { + const timer = [...timers.values()].find((t) => t.ms === 1200); + assert.ok(timer); + timer.cb(); + await flush(); + }, + }; +} +test("blank repeat pairing and rejected replacement preserve authentication for open and create", async () => { + const h = await harness(); + await h.el("pair").onsubmit({ preventDefault() {} }); + h.el("token").value = "b".repeat(64); + await h.el("pair").onsubmit({ preventDefault() {} }); + h.el("sessions").children[0].onclick(); + await flush(); + assert.equal(h.el("answer").textContent, "Понг."); + await h.buttons[0].onclick(); + await h.buttons[1].onclick(); + assert.equal(h.requests.filter((r) => r.path === "/g2/api/create").length, 2); + assert.match(h.el("summary").textContent, /3 терминалов/); + assert.equal(h.el("answer").textContent, "Понг."); +}); +test("real SDK startup declares valid OS menu and overlay dismissal keeps polling alive", async () => { + const h = await harness(); + assert.equal(sdk.validateEvenHubPageContainer(h.page).valid, true); + assert.deepEqual( + h.page.menuObject.menuItems.map((i) => i.itemName), + [ + "Home CanvasTTY", + "New Codex", + "New Terminal", + "Close Terminal", + "Project Browser", + "Rename Terminal", + "More agents", + ], + ); + await h.event({ + sysEvent: { eventType: sdk.OsEventTypeList.FOREGROUND_ENTER_EVENT }, + }); + await h.event({ menuItemClickEvent: { itemID: 2 } }); + await h.event({ + sysEvent: { eventType: sdk.OsEventTypeList.FOREGROUND_EXIT_EVENT }, + }); + const count = h.requests.length; + await h.poll(); + assert.ok(h.requests.length > count); + assert.equal(h.el("connection").textContent, "Компьютер подключён · G2"); + assert.equal(h.el("answer").textContent, "Понг."); + assert.match(h.frames.at(-1), /Понг/); + await h.event({ menuItemClickEvent: { itemID: 1 } }); + assert.match(h.frames.at(-1), /CANVASTTY · HOME/); + await h.event({ menuItemClickEvent: { itemID: 3 } }); + assert.equal(h.requests.filter((r) => r.path === "/g2/api/create").length, 2); +}); +test("motion and untyped messages do not open terminals", async () => { + const h = await harness(); + await h.event({ sysEvent: { imuData: { x: 1, y: 0, z: 0 } } }); + await h.event({ jsonData: { unrelated: true } }); + assert.match(h.frames.at(-1), /CANVASTTY · HOME/); +}); +test("a failed refresh preserves cached answer and reports disconnection", async () => { + const h = await harness(); + h.fail(); + h.el("sessions").children[0].onclick(); + await flush(); + assert.equal(h.el("answer").textContent, "Понг."); + await h.poll(); + assert.equal(h.el("connection").textContent, "Компьютер недоступен"); +}); +test("root double tap requests system confirmation and cancelling leaves display operational", async () => { + const h = await harness(); + await h.event({ + sysEvent: { eventType: sdk.OsEventTypeList.DOUBLE_CLICK_EVENT }, + }); + assert.deepEqual(h.exits, [1]); + await h.event({ + sysEvent: { eventType: sdk.OsEventTypeList.FOREGROUND_ENTER_EVENT }, + }); + await h.event({ + sysEvent: { eventType: sdk.OsEventTypeList.FOREGROUND_EXIT_EVENT }, + }); + h.el("sessions").children[0].onclick(); + await flush(); + await h.poll(); + assert.match(h.frames.at(-1), /Понг/); + assert.equal(h.el("connection").textContent, "Компьютер подключён · G2"); +}); +test("double tap in a terminal returns Home without requesting exit", async () => { + const h = await harness(); + h.el("sessions").children[0].onclick(); + await flush(); + await h.event({ + sysEvent: { eventType: sdk.OsEventTypeList.DOUBLE_CLICK_EVENT }, + }); + assert.deepEqual(h.exits, []); + assert.match(h.frames.at(-1), /CANVASTTY · HOME/); +}); +test("real SDK default-zero sys and text clicks open the selected session", async () => { + for (const type of ["sysEvent", "textEvent", "listEvent"]) { + const h = await harness(); + await h.event(sdk.evenHubEventFromJson({ type, jsonData: {} })); + assert.equal(h.el("answer").textContent, "Понг.", type); + assert.match(h.frames.at(-1), /Понг/); + } +}); +test("explicit system action is not shadowed by an empty text event", async () => { + const h = await harness(); + h.el("sessions").children[0].onclick(); + await flush(); + await h.event({ textEvent: {}, sysEvent: { eventType: 3 } }); + assert.match(h.frames.at(-1), /CANVASTTY · HOME/); +}); +test("settled microphone rejection keeps Home and permits a fresh explicit hold", async () => { + const h = await harness(); + h.el("sessions").children[0].onclick(); + await flush(); + h.setAudio(async () => false); + await h.pressVoice(); + assert.equal(h.el("voice").disabled, false); + assert.match(h.el("mic-status").textContent, /не подтвержден/); + await h.event({ sysEvent: { eventType: 3 } }); + assert.match(h.frames.at(-1), /CANVASTTY · HOME/); + assert.match(h.frames.at(-1), /Микрофон/); + assert.doesNotMatch(h.frames.at(-1), /СЛУШАЮ/); + h.setAudio(async () => true); + h.el("sessions").children[0].onclick(); + await flush(); + assert.equal(h.el("voice").disabled, false); + await h.pressVoice(); + assert.match(h.el("voice").textContent, /Говорите/); + await h.el("mic-stop").onclick(); + await flush(); + assert.deepEqual( + h.micCalls.map((c) => c.on), + [true, false, true, false], + ); + assert.equal(h.requests.filter((r) => r.path === "/g2/api/voice").length, 0); +}); +test("unsettled microphone keeps phone navigation usable and defers glasses writes until late ACK and off", async () => { + const h = await harness(); + h.el("sessions").children[0].onclick(); + await flush(); + let finish; + h.setAudio((on) => + on + ? new Promise((resolve) => { + finish = resolve; + }) + : Promise.resolve(true), + ); + await h.pressVoice(); + assert.match(h.el("voice").textContent, /Включаю/); + assert.doesNotMatch(h.el("voice").textContent, /Говорите/); + h.el("home").onclick(); + await flush(); + assert.match(h.el("hud").textContent, /CANVASTTY · HOME/); + assert.match(h.frames.at(-1), /ВКЛЮЧАЮ МИКРОФОН/); + await h.timeout(2500); + assert.match(h.el("hud").textContent, /CANVASTTY · HOME/); + assert.match(h.frames.at(-1), /ВКЛЮЧАЮ МИКРОФОН/); + assert.deepEqual( + h.micCalls.map((c) => c.on), + [true], + ); + await h.event({ sysEvent: { eventType: 3 } }); + assert.deepEqual(h.exits, [1]); + finish(true); + await flush(); + assert.deepEqual( + h.micCalls.map((c) => c.on), + [true, false], + ); + assert.match(h.frames.at(-1), /CANVASTTY · HOME/); + assert.equal(h.requests.filter((r) => r.path === "/g2/api/voice").length, 0); +}); +test("microphone stop timeout does not block selecting another terminal", async () => { + const h = await harness(); + h.el("sessions").children[0].onclick(); + await flush(); + await h.pressVoice(); + h.setAudio(() => new Promise(() => {})); + h.el("home").onclick(); + await flush(); + await h.timeout(2500); + assert.match(h.frames.at(-1), /CANVASTTY · HOME/); + h.el("sessions").children[0].onclick(); + await flush(); + assert.equal(h.el("answer").textContent, "Понг."); + assert.match(h.el("mic-status").textContent, /Жду завершения/); + assert.equal(h.el("voice").disabled, true); +}); +test("startup and navigation do not probe off before the first explicit microphone enable", async () => { + const h = await harness(); + assert.deepEqual(h.micCalls, []); + assert.match(h.el("mic-status").textContent, /ещё не включался/); + h.el("sessions").children[0].onclick(); + await flush(); + assert.equal(h.el("voice").disabled, false); + assert.deepEqual(h.micCalls, []); + h.setAudio((on) => Promise.resolve(on)); + await h.pressVoice(); + assert.equal(h.micCalls[0].on, true); + assert.match(h.el("voice").textContent, /Говорите/); + h.el("home").onclick(); + await flush(); + assert.match(h.el("mic-status").textContent, /не подтвержден/); +}); +test("microphone self-test counts G2 PCM and stops without an ASR or agent request", async () => { + const h = await harness(); + await h.el("mic-test").onclick(); + await flush(); + await h.event({ + audioEvent: { + source: sdk.AudioInputSource.Glasses, + audioPcm: new Uint8Array(32000), + }, + }); + await h.timeout(2000); + assert.deepEqual( + h.micCalls.map((c) => c.on), + [true, false], + ); + assert.match(h.el("notice").textContent, /32000/); + assert.equal( + h.requests.some( + (r) => r.path === "/g2/api/voice" || r.path === "/g2/api/control", + ), + false, + ); +}); +const testMenu = { + kind: "choices", + id: "menu-one", + title: "Выберите базу", + options: [ + { number: 1, label: "SQLite" }, + { number: 2, label: "Postgres" }, + { number: 3, label: "Type something else" }, + ], + selected: 0, + customIndex: 2, +}; +test("swipe highlights an option locally, click submits it once with its menu id", async () => { + const h = await harness(); + h.setMenu(testMenu); + h.el("sessions").children[0].onclick(); + await flush(); + await h.event({ textEvent: { eventType: 2 } }); + await h.poll(); + assert.match(h.frames.at(-1), /Postgres/); + assert.equal( + h.requests.some((r) => r.path === "/g2/api/control"), + false, + ); + await h.event({ sysEvent: {} }); + const sent = h.requests.filter((r) => r.path === "/g2/api/control"); + assert.equal(sent.length, 1); + assert.equal(sent[0].body.index, 1); + assert.equal(sent[0].body.menuId, "menu-one"); + assert.equal(sent[0].body.sessionId, "existing"); +}); +test("holding a choice selects explicit custom input without starting the microphone", async () => { + const h = await harness(); + h.setMenu(testMenu); + h.el("sessions").children[0].onclick(); + await flush(); + await h.event({ sysEvent: { eventType: 9 } }); + await h.event({ sysEvent: { eventType: 10 } }); + const sent = h.requests.find((r) => r.path === "/g2/api/control"); + assert.equal(sent.body.action, "custom"); + assert.deepEqual(h.micCalls, []); + h.el("draft").value = "Мой текст"; + await h.el("manual").onsubmit({ preventDefault() {} }); + assert.equal(h.requests.at(-1).body.text, "Мой текст"); +}); +test("hold does not pick a non-custom last approval option", async () => { + const h = await harness(); + h.setMenu({ ...testMenu, id: "approval", customIndex: null }); + h.el("sessions").children[0].onclick(); + await flush(); + await h.event({ sysEvent: { eventType: 9 } }); + await h.event({ sysEvent: { eventType: 10 } }); + assert.equal( + h.requests.some((r) => r.path === "/g2/api/control"), + false, + ); + assert.deepEqual(h.micCalls, []); +}); + +test("closing from the glasses menu defaults to cancel; only an explicit selection closes the captured terminal", async () => { + const h = await harness(); + await h.event({ menuItemClickEvent: { itemID: 4 } }); + assert.match(h.frames.at(-1), /ЗАКРЫТЬ ТЕРМИНАЛ/); + await h.event({ sysEvent: { eventType: 9 } }); + await h.event({ sysEvent: { eventType: 10 } }); + assert.equal( + h.requests.some((r) => r.path === "/g2/api/session-close"), + false, + ); + await h.event({ sysEvent: { eventType: 0 } }); + assert.equal( + h.requests.some((r) => r.path === "/g2/api/session-close"), + false, + ); + await h.event({ menuItemClickEvent: { itemID: 4 } }); + await h.event({ sysEvent: { eventType: 2 } }); + await h.event({ sysEvent: { eventType: 0 } }); + const closes = h.requests.filter((r) => r.path === "/g2/api/session-close"); + assert.equal(closes.length, 1); + assert.equal(closes[0].body.sessionId, "existing"); + assert.match(h.el("summary").textContent, /0 терминалов/); + assert.match(h.frames.at(-1), /CANVASTTY · HOME/); +}); + +test("project browser menu uses the selected terminal and reports the actual browser result", async () => { + const h = await harness(); + await h.event({ menuItemClickEvent: { itemID: 5 } }); + const opens = h.requests.filter((r) => r.path === "/g2/api/browser"); + assert.equal(opens.length, 1); + assert.equal(opens[0].body.sessionId, "existing"); + assert.match(h.frames.at(-1), /Браузер открыт на Mac/); + assert.match(h.el("notice").textContent, /127.0.0.1:5173/); +}); + +test("phone rename changes the captured session without sending a command", async () => { + const h = await harness(); + await h.event({ menuItemClickEvent: { itemID: 6 } }); + assert.match(h.frames.at(-1), /ПЕРЕИМЕНОВАТЬ/); + h.el("rename-input").value = "Бот поддержки"; + h.el("rename-input").oninput(); + await h.el("rename-form").onsubmit({ preventDefault() {} }); + await flush(); + const names = h.requests.filter((r) => r.path === "/g2/api/session-rename"); + assert.equal(names.length, 1); + assert.equal(names[0].body.sessionId, "existing"); + assert.equal(names[0].body.title, "Бот поддержки"); + assert.equal( + h.requests.some((r) => r.path === "/g2/api/control"), + false, + ); + assert.match(h.el("notice").textContent, /Новое имя/); +}); + +test("glasses rename dictation previews the name and requires an explicit save choice", async () => { + const h = await harness(); + await h.event({ menuItemClickEvent: { itemID: 6 } }); + await h.event({ + sysEvent: { eventType: sdk.OsEventTypeList.LONG_PRESS_EVENT }, + }); + await h.event({ + audioEvent: { + source: sdk.AudioInputSource.Glasses, + audioPcm: new Uint8Array(32000), + }, + }); + await h.event({ + sysEvent: { eventType: sdk.OsEventTypeList.LONG_PRESS_RELEASE_EVENT }, + }); + const audio = h.requests.find((r) => r.path === "/g2/api/voice"); + assert.equal(audio.body.purpose, "rename"); + assert.equal(audio.body.sessionId, "existing"); + assert.match(h.frames.at(-1), /Бот поддержки/); + assert.equal( + h.requests.some( + (r) => + r.path === "/g2/api/session-rename" || r.path === "/g2/api/control", + ), + false, + ); + await h.event({ + sysEvent: { eventType: sdk.OsEventTypeList.SCROLL_BOTTOM_EVENT }, + }); + await h.event({ sysEvent: { eventType: sdk.OsEventTypeList.CLICK_EVENT } }); + assert.equal( + h.requests.filter((r) => r.path === "/g2/api/session-rename").length, + 1, + ); +}); + +test("double tap cancels renaming without issuing any change", async () => { + const h = await harness(); + await h.event({ menuItemClickEvent: { itemID: 6 } }); + h.el("rename-input").value = "Не сохранять"; + h.el("rename-input").oninput(); + await h.event({ + sysEvent: { eventType: sdk.OsEventTypeList.DOUBLE_CLICK_EVENT }, + }); + assert.equal( + h.requests.some((r) => r.path === "/g2/api/session-rename"), + false, + ); + assert.match(h.frames.at(-1), /CANVASTTY · HOME/); +}); + +test("failed OFF can recover from a glasses hold without sending discarded audio", async () => { + const h = await harness(); + h.el("sessions").children[0].onclick(); + await flush(); + await h.event({ sysEvent: { eventType: 9 } }); + await h.event({ + audioEvent: { + source: sdk.AudioInputSource.Glasses, + audioPcm: new Uint8Array(32000), + }, + }); + h.setAudio(async () => false); + await h.event({ sysEvent: { eventType: 10 } }); + assert.equal(h.el("voice").disabled, false); + assert.equal(h.requests.filter((r) => r.path === "/g2/api/voice").length, 0); + h.setAudio(async () => true); + await h.event({ sysEvent: { eventType: 9 } }); + assert.match(h.el("voice").textContent, /Говорите/); + await h.event({ + audioEvent: { + source: sdk.AudioInputSource.Glasses, + audioPcm: new Uint8Array(32000), + }, + }); + await h.event({ sysEvent: { eventType: 10 } }); + assert.deepEqual( + h.micCalls.map((c) => c.on), + [true, false, true, false], + ); + assert.equal(h.requests.filter((r) => r.path === "/g2/api/voice").length, 1); +}); + +test("microphone failure telemetry survives navigation noise", async () => { + const h = await harness(); + h.el("sessions").children[0].onclick(); + await flush(); + h.setAudio(async () => false); + await h.pressVoice(); + for (let i = 0; i < 35; i++) await h.event({ textEvent: { eventType: 2 } }); + await h.poll(); + const state = h.requests + .filter((r) => r.path === "/g2/api/device-state") + .at(-1).body; + assert.ok( + state.diagnostics.some((line) => line.includes("mic:off ack=false")), + ); + assert.ok(state.diagnostics.length <= 24); +}); + +test("code-only phone form resolves the typed code, pairs, and persists the entire local connection", async () => { + const h = await harness({ localOnly: true }); + h.el("token").value = "123456"; + await h.el("pair").onsubmit({ preventDefault() {} }); + await flush(); + assert.deepEqual(h.enteredCodes, ["123456"]); + assert.match(h.el("connection").textContent, /Компьютер подключён/); + assert.equal(h.saved[0].name, "canvastty.g2.local.v1"); + assert.equal( + JSON.parse(h.saved[0].value).connection.origins[0], + "http://192.168.2.108:3481", + ); + assert.equal(h.el("token").value, ""); +}); + +test("each additional hotbar agent can be selected with glasses gestures without dictation", async () => { + for (let index = 0; index < MORE_AGENTS.length; index++) { + const h = await harness(); + await h.event({ menuItemClickEvent: { itemID: 7 } }); + assert.match(h.frames.at(-1), /СОЗДАТЬ АГЕНТА/); + for (let i = 0; i < index; i++) await h.event({ textEvent: { eventType: 2 } }); + await h.event({ sysEvent: { eventType: 9 } }); + await h.event({ sysEvent: { eventType: 10 } }); + assert.deepEqual(h.micCalls, []); + assert.equal(h.requests.filter(r => r.path === "/g2/api/create").length, 0); + await h.event({ sysEvent: { eventType: 0 } }); + const creates = h.requests.filter(r => r.path === "/g2/api/create"); + assert.equal(creates.length, 1); + assert.equal(creates[0].body.provider, MORE_AGENTS[index].provider); + } +}); + +test("cancelling the agent picker preserves the current terminal and sends no create", async () => { + const h = await harness(); + h.el("sessions").children[0].onclick(); + await flush(); + await h.event({ menuItemClickEvent: { itemID: 7 } }); + await h.event({ sysEvent: { eventType: 3 } }); + assert.equal(h.requests.filter(r => r.path === "/g2/api/create").length, 0); + assert.doesNotMatch(h.frames.at(-1), /СОЗДАТЬ АГЕНТА/); + assert.equal(h.exits.length, 0); +}); + +test("phone launch buttons send every hotbar provider through the create API", async () => { + const h = await harness(); + for (const button of h.buttons) { + await button.onclick(); + assert.equal(h.requests.filter(r => r.path === "/g2/api/create").at(-1).body.provider, button.dataset.new); + } + const html = readFileSync(new URL("../index.html", import.meta.url), "utf8"); + const providers = [...html.matchAll(/data-new="([a-z]+)"/g)].map(m => m[1]).sort(); + assert.deepEqual(providers, [...CANVAS_LAUNCHER_ITEMS].sort()); +}); diff --git a/integrations/even-g2/tests/hud.test.mjs b/integrations/even-g2/tests/hud.test.mjs new file mode 100644 index 000000000..639e46f56 --- /dev/null +++ b/integrations/even-g2/tests/hud.test.mjs @@ -0,0 +1,155 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import * as sdk from "@evenrealities/even_hub_sdk"; +import { HudBridge } from "../src/hud-bridge.mjs"; + +const flush = () => new Promise(setImmediate); +test("microphone OFF waits for the last display ACK before sending its command", async () => { + const calls = []; + let displayDone, micDone; + const native = { + createStartUpPageContainer: async () => 0, + textContainerUpgrade: () => { + calls.push("display"); + return new Promise((resolve) => { + displayDone = resolve; + }); + }, + audioControl: (on) => { + calls.push("mic:" + on); + return new Promise((resolve) => { + micDone = resolve; + }); + }, + }; + const hud = new HudBridge(native, sdk); + await hud.startPage(); + const first = hud.render("first"), + off = hud.microphone(false); + await flush(); + assert.deepEqual(calls, ["display"]); + displayDone(true); + await first; + await flush(); + assert.deepEqual(calls, ["display", "mic:false"]); + await flush(); + assert.deepEqual(calls, ["display", "mic:false"]); + micDone(true); + assert.equal(await off, true); + const next = hud.render("next"); + assert.deepEqual(calls, ["display", "mic:false", "display"]); + displayDone(true); + assert.equal(await next, true); +}); + +test("failed microphone calls preserve failure and release queued display updates", async () => { + const frames = []; + const native = { + createStartUpPageContainer: async () => 0, + textContainerUpgrade: async (p) => { + frames.push(p.content); + return true; + }, + audioControl: async () => { + throw new Error("device rejected"); + }, + }; + const hud = new HudBridge(native, sdk); + await hud.startPage(); + const off = hud.microphone(false), + frame = hud.render("navigation still works"); + await assert.rejects(off, /device rejected/); + assert.equal(await frame, true); + assert.deepEqual(frames, ["navigation still works"]); +}); + +test("display frames wait for an unsettled microphone ACK and resume afterwards", async () => { + const calls = []; + let acknowledge; + const native = { + createStartUpPageContainer: async () => 0, + textContainerUpgrade: async (frame) => { + calls.push("display:" + frame.content); + return true; + }, + audioControl: (on) => { + calls.push("audio:" + on); + return new Promise((resolve) => { + acknowledge = resolve; + }); + }, + }; + const hud = new HudBridge(native, sdk); + await hud.startPage(); + const audio = hud.microphone(false); + await flush(); + const frame = hud.render("Home"); + await flush(); + assert.deepEqual(calls, ["audio:false"]); + acknowledge(true); + assert.equal(await audio, true); + assert.equal(await frame, true); + assert.deepEqual(calls, ["audio:false", "display:Home"]); +}); + +test("queued microphone stop has priority over a pending frame", async () => { + const calls = []; + let acknowledgeOn; + const native = { + createStartUpPageContainer: async () => 0, + textContainerUpgrade: async (frame) => { + calls.push("display:" + frame.content); + return true; + }, + audioControl: (on) => { + calls.push("audio:" + on); + return on + ? new Promise((resolve) => { + acknowledgeOn = resolve; + }) + : Promise.resolve(true); + }, + }; + const hud = new HudBridge(native, sdk); + await hud.startPage(); + const on = hud.microphone(true); + await flush(); + const frame = hud.render("Recording stopped"), + off = hud.microphone(false); + acknowledgeOn(true); + await Promise.all([on, off, frame]); + assert.deepEqual(calls, [ + "audio:true", + "audio:false", + "display:Recording stopped", + ]); +}); + +test("a released hold cancels an enable waiting for a display acknowledgement", async () => { + let finish; + let wanted = true; + const calls = []; + const native = { + createStartUpPageContainer: async () => 0, + textContainerUpgrade: () => + new Promise((resolve) => { + finish = resolve; + }), + audioControl: async () => { + calls.push("microphone"); + return true; + }, + }; + const hud = new HudBridge(native, sdk); + await hud.startPage(); + const frame = hud.render("Preparing"); + const mic = hud.microphone(true, { + canStart: () => wanted, + onDispatch: () => calls.push("dispatched"), + }); + wanted = false; + finish(true); + assert.equal(await frame, true); + assert.equal(await mic, false); + assert.deepEqual(calls, []); +}); diff --git a/integrations/even-g2/tests/model.test.mjs b/integrations/even-g2/tests/model.test.mjs new file mode 100644 index 000000000..4f18b041b --- /dev/null +++ b/integrations/even-g2/tests/model.test.mjs @@ -0,0 +1,80 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { ViewModel, Gestures } from "../src/model.mjs"; +test("repeated polling never appends history or advances the page", () => { + const m = new ViewModel(); + m.open("a"); + const data = { + session: { id: "a", status: "idle" }, + body: "Новый ответ\n" + "текст ".repeat(100), + revision: "one", + }; + m.terminal(data); + m.scroll(1); + const before = m.frame(); + for (let i = 0; i < 20; i++) m.terminal(data); + assert.equal(m.frame(), before); + assert.equal(m.page, 1); +}); +test("background status preserves manual reading position and unrelated terminals are ignored", () => { + const m = new ViewModel(); + m.open("a"); + m.terminal({ + session: { id: "a", status: "idle" }, + body: "текст ".repeat(200), + revision: "one", + }); + m.page = 2; + m.terminal({ + session: { id: "a", status: "working" }, + body: m.body, + revision: "one", + }); + assert.equal(m.page, 2); + m.terminal({ + session: { id: "b", status: "idle" }, + body: "wrong", + revision: "other", + }); + assert.notEqual(m.body, "wrong"); +}); +test("OS overlay cancels a hold without submitting voice; double click goes back", () => { + const events = []; + const g = new Gestures({ + select: () => events.push("select"), + back: () => events.push("back"), + record: () => events.push("record"), + send: () => events.push("send"), + }); + g.click(); + g.long(); + g.cancel(); + g.release(); + assert.deepEqual(events, ["select", "record"]); + g.double(); + assert.equal(events.at(-1), "back"); +}); +test("opening a prefetched session has its answer immediately", () => { + const m = new ViewModel(); + const session = { id: "a", status: "idle" }; + m.home({ sessions: [session] }); + m.terminal({ session, body: "Понг.", revision: "1" }); + m.open("a"); + assert.equal(m.body, "Понг."); + m.loadFailed("a"); + assert.equal(m.body, "Понг."); +}); +test("failed initial load cannot leave an endless loading message", () => { + const m = new ViewModel(); + m.open("a"); + m.loadFailed("a"); + assert.doesNotMatch(m.body, /Загружаю/); + assert.equal(m.state, "failed"); + m.terminal({ + session: { id: "a", status: "idle" }, + body: "Ответ", + revision: "1", + }); + assert.equal(m.body, "Ответ"); + assert.equal(m.state, "idle"); +}); diff --git a/integrations/even-g2/tests/network-policy.test.mjs b/integrations/even-g2/tests/network-policy.test.mjs new file mode 100644 index 000000000..1badbf0b3 --- /dev/null +++ b/integrations/even-g2/tests/network-policy.test.mjs @@ -0,0 +1,58 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdtemp, writeFile, mkdir, rm, readFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { build } from "vite"; +import { checkBundleNetwork } from "../scripts/network-policy.mjs"; +import { LOCAL_DISCOVERY_ORIGINS, LOCAL_DISCOVERY_HOSTS } from "../../../src/shared/localDiscovery.ts"; + +const manifest = { permissions: [{ name: "network", whitelist: LOCAL_DISCOVERY_ORIGINS }] }; +async function fixture(t, content) { + const root = await mkdtemp(join(tmpdir(), "canvastty-network-policy-")); + t.after(() => rm(root, { recursive: true, force: true })); + await mkdir(join(root, "assets")); + await writeFile(join(root, "assets", "app.js"), content); + return root; +} + +test("rejects the exact minified template URL cited in the Even Hub rejection", async t => { + const root = await fixture(t, 'const origins = hosts.map(e => `http://${e}:3481`);'); + assert.throws(() => checkBundleNetwork(root, manifest), /not covered[\s\S]*http:\/\/\$\{e\}:3481/); +}); + +test("only complete whitelisted origins are accepted, including their API paths", async t => { + const root = await fixture(t, 'fetch("http://canvastty.local:3481/g2/discover");'); + assert.equal(checkBundleNetwork(root, manifest).urls.length, 1); + for (const value of ["http://canvastty.local.evil.test:3481", "https://example.test", "http://user@canvastty.local:3481", "http://"]) { + await writeFile(join(root, "assets", "app.js"), JSON.stringify(value)); + assert.throws(() => checkBundleNetwork(root, manifest), /not covered/); + } +}); + +test("an absent, wildcard or non-origin manifest cannot pass the package gate", async t => { + const root = await fixture(t, "export const value = 1;"); + for (const whitelist of [[], ["http://*"], ["http://canvastty.local:3481/path"]]) + assert.throws(() => checkBundleNetwork(root, { permissions: [{ name: "network", whitelist }] })); +}); + +test("source manifest, discovery names and the minified production bundle agree", async t => { + const root = fileURLToPath(new URL("..", import.meta.url)); + const source = JSON.parse(await readFile(join(root, "app.json"), "utf8")); + assert.deepEqual(source.permissions.find(p => p.name === "network").whitelist, LOCAL_DISCOVERY_ORIGINS); + assert.deepEqual(LOCAL_DISCOVERY_HOSTS, LOCAL_DISCOVERY_ORIGINS.map(origin => new URL(origin).hostname)); + assert.equal(LOCAL_DISCOVERY_ORIGINS.length, 8); + const output = await mkdtemp(join(tmpdir(), "canvastty-production-network-")); + t.after(() => rm(output, { recursive: true, force: true })); + await build({ + root, logLevel: "silent", + define: { + "import.meta.env.VITE_CANVASTTY_LOCAL_ONLY": JSON.stringify("true"), + "import.meta.env.VITE_CANVASTTY_BRIDGE_ORIGIN": JSON.stringify(""), + }, + build: { outDir: output, emptyOutDir: true }, + }); + const result = checkBundleNetwork(output, source); + assert.deepEqual(result.urls, [...LOCAL_DISCOVERY_ORIGINS].sort()); +}); diff --git a/integrations/even-g2/tests/origin.test.mjs b/integrations/even-g2/tests/origin.test.mjs new file mode 100644 index 000000000..90adf1cdc --- /dev/null +++ b/integrations/even-g2/tests/origin.test.mjs @@ -0,0 +1,38 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { buildOrigin } from "../scripts/origin.mjs"; + +test("production packaging requires an exact HTTPS origin without credentials", () => { + assert.equal( + buildOrigin("https://bridge.example.test/"), + "https://bridge.example.test", + ); + for (const value of [ + "", + "http://example.test", + "https://*.example.test", + "https://a:b@example.test", + "https://example.test/api", + "https://example.test?token=private", + "https://example.test#private", + ]) { + assert.throws(() => buildOrigin(value)); + } +}); + +test("local HTTP is an explicit test option and never permits a public cleartext origin", () => { + assert.throws(() => buildOrigin("http://127.0.0.1:3480")); + assert.equal( + buildOrigin("http://127.0.0.1:3480", { localDevelopment: true }), + "http://127.0.0.1:3480", + ); + assert.throws(() => + buildOrigin("http://public.example.test", { localDevelopment: true }), + ); + assert.throws(() => + buildOrigin("http://127.attacker.test", { localDevelopment: true }), + ); + assert.throws(() => + buildOrigin("http://192.168.attacker.test", { localDevelopment: true }), + ); +}); diff --git a/integrations/even-g2/tests/pairing.test.mjs b/integrations/even-g2/tests/pairing.test.mjs new file mode 100644 index 000000000..3c9d493ab --- /dev/null +++ b/integrations/even-g2/tests/pairing.test.mjs @@ -0,0 +1,98 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { pairComputer } from "../src/connect.mjs"; +import { validatePairing } from "../src/pairing.mjs"; +const token = "a".repeat(64), + home = { sessions: [] }; +const response = (data) => ({ ok: true, json: async () => data }); +test("code exchange waits for Mac approval and does not put credentials in the URL", async () => { + const paths = [], + events = []; + const result = await pairComputer("http://localhost:3481", "123456", { + onPending: () => events.push("pending"), + fetcher: async (url, options) => { + paths.push(url); + assert.equal(url.includes(token), false); + if (url.endsWith("/pair")) { + assert.deepEqual(JSON.parse(options.body), { + code: "123456", + name: "Even App", + }); + assert.equal(options.headers.Authorization, undefined); + return response({ token }); + } + assert.equal(options.headers.Authorization, "Bearer " + token); + return response( + url.endsWith("/pair-status") ? { state: "approved" } : home, + ); + }, + }); + assert.deepEqual(result, { token, home }); + assert.deepEqual(events, ["pending"]); + assert.equal(paths.length, 3); +}); +test("rejection and cancellation never fetch shared session content", async () => { + const abort = new AbortController(); + let calls = 0; + await assert.rejects( + pairComputer("", "123456", { + onPending: () => abort.abort(), + signal: abort.signal, + fetcher: async () => { + calls++; + return response({ token }); + }, + }), + /отменено/, + ); + assert.equal(calls, 1); + await assert.rejects( + pairComputer("", "123456", { + fetcher: async (url) => + response(url.endsWith("/pair") ? { token } : { state: "rejected" }), + }), + /отклонено/, + ); +}); +test("only an approved valid token is persisted; failure preserves the prior connection", async () => { + const saved = []; + let probed = ""; + assert.deepEqual( + await validatePairing("123456", { + probe: async (code) => { + probed = code; + return { token, home }; + }, + persist: async (value) => { + saved.push(value); + return true; + }, + }), + { token, home }, + ); + assert.equal(probed, "123456"); + assert.deepEqual(saved, [token]); + await assert.rejects( + validatePairing("123456", { + probe: async () => ({ token: "z".repeat(64), home }), + persist: async (value) => { + saved.push(value); + return true; + }, + }), + ); + assert.equal(saved.length, 1); + await assert.rejects( + validatePairing("123456", { + probe: async () => ({ token, home }), + persist: async () => false, + }), + /сохранить/, + ); +}); + +test("six-digit validation rejects old long tokens and overlong PINs before network calls", async () => { + for (const code of ["12345", "1234567", "12345678", "CT-EXAMPLE-CODE", "123 456", "12e456"]) { + await assert.rejects(validatePairing(code, { probe: () => { throw Error("network called"); } }), /шестизначный/); + } +}); diff --git a/integrations/even-g2/vite.config.mjs b/integrations/even-g2/vite.config.mjs new file mode 100644 index 000000000..2adcd60eb --- /dev/null +++ b/integrations/even-g2/vite.config.mjs @@ -0,0 +1,2 @@ +import { defineConfig } from "vite"; +export default defineConfig({ base: "./", build: { target: "es2022" } }); diff --git a/package-lock.json b/package-lock.json index 45eafe2e8..ad98ce81c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,15 +7,20 @@ "": { "name": "canvastty", "version": "1.5.1", - "license": "MIT", "hasInstallScript": true, + "license": "MIT", + "workspaces": [ + "integrations/even-g2" + ], "dependencies": { "@xterm/addon-fit": "0.11.0", "@xterm/addon-web-links": "0.12.0", + "@xterm/headless": "6.0.0", "@xterm/xterm": "6.0.0", "node-pty": "1.1.0", "react": "19.2.8", "react-dom": "19.2.8", + "secure-remote-password": "0.3.1", "yaml": "2.9.0" }, "devDependencies": { @@ -32,865 +37,2635 @@ "vite": "7.3.6" } }, - "node_modules/@babel/code-frame": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", - "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", - "dev": true, - "license": "MIT", + "integrations/even-g2": { + "name": "canvastty-even-g2", + "version": "0.5.6", "dependencies": { - "@babel/helper-validator-identifier": "^7.29.7", - "js-tokens": "^4.0.0", - "picocolors": "^1.1.1" + "@evenrealities/even_hub_sdk": "0.0.14", + "@fontsource/ibm-plex-mono": "5.3.0", + "@fontsource/ibm-plex-sans": "5.3.0" }, - "engines": { - "node": ">=6.9.0" + "devDependencies": { + "@evenrealities/evenhub-cli": "0.1.14", + "vite": "8.2.2" } }, - "node_modules/@babel/compat-data": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", - "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "integrations/even-g2/node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "peer": true, "engines": { - "node": ">=6.9.0" + "node": ">=18" } }, - "node_modules/@babel/core": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", - "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "integrations/even-g2/node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], "dev": true, "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.29.7", - "@babel/generator": "^7.29.7", - "@babel/helper-compilation-targets": "^7.29.7", - "@babel/helper-module-transforms": "^7.29.7", - "@babel/helpers": "^7.29.7", - "@babel/parser": "^7.29.7", - "@babel/template": "^7.29.7", - "@babel/traverse": "^7.29.7", - "@babel/types": "^7.29.7", - "@jridgewell/remapping": "^2.3.5", - "convert-source-map": "^2.0.0", - "debug": "^4.1.0", - "gensync": "^1.0.0-beta.2", - "json5": "^2.2.3", - "semver": "^6.3.1" - }, + "optional": true, + "os": [ + "android" + ], + "peer": true, "engines": { - "node": ">=6.9.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/babel" + "node": ">=18" } }, - "node_modules/@babel/generator": { - "version": "7.29.8", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", - "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", + "integrations/even-g2/node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", - "dependencies": { - "@babel/parser": "^7.29.8", - "@babel/types": "^7.29.8", - "@jridgewell/gen-mapping": "^0.3.12", - "@jridgewell/trace-mapping": "^0.3.28", - "jsesc": "^3.0.2" - }, + "optional": true, + "os": [ + "android" + ], + "peer": true, "engines": { - "node": ">=6.9.0" + "node": ">=18" } }, - "node_modules/@babel/helper-compilation-targets": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", - "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "integrations/even-g2/node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", - "dependencies": { - "@babel/compat-data": "^7.29.7", - "@babel/helper-validator-option": "^7.29.7", - "browserslist": "^4.24.0", - "lru-cache": "^5.1.1", - "semver": "^6.3.1" - }, + "optional": true, + "os": [ + "android" + ], + "peer": true, "engines": { - "node": ">=6.9.0" + "node": ">=18" } }, - "node_modules/@babel/helper-globals": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", - "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "integrations/even-g2/node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, "engines": { - "node": ">=6.9.0" + "node": ">=18" } }, - "node_modules/@babel/helper-module-imports": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", - "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "integrations/even-g2/node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", - "dependencies": { - "@babel/traverse": "^7.29.7", - "@babel/types": "^7.29.7" - }, + "optional": true, + "os": [ + "darwin" + ], + "peer": true, "engines": { - "node": ">=6.9.0" + "node": ">=18" } }, - "node_modules/@babel/helper-module-transforms": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", - "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "integrations/even-g2/node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", - "dependencies": { - "@babel/helper-module-imports": "^7.29.7", - "@babel/helper-validator-identifier": "^7.29.7", - "@babel/traverse": "^7.29.7" - }, + "optional": true, + "os": [ + "freebsd" + ], + "peer": true, "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" + "node": ">=18" } }, - "node_modules/@babel/helper-plugin-utils": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", - "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "integrations/even-g2/node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "peer": true, "engines": { - "node": ">=6.9.0" + "node": ">=18" } }, - "node_modules/@babel/helper-string-parser": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", - "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "integrations/even-g2/node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, "engines": { - "node": ">=6.9.0" + "node": ">=18" } }, - "node_modules/@babel/helper-validator-identifier": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", - "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "integrations/even-g2/node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, "engines": { - "node": ">=6.9.0" + "node": ">=18" } }, - "node_modules/@babel/helper-validator-option": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", - "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "integrations/even-g2/node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, "engines": { - "node": ">=6.9.0" + "node": ">=18" } }, - "node_modules/@babel/helpers": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", - "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "integrations/even-g2/node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], "dev": true, "license": "MIT", - "dependencies": { - "@babel/template": "^7.29.7", - "@babel/types": "^7.29.7" - }, + "optional": true, + "os": [ + "linux" + ], + "peer": true, "engines": { - "node": ">=6.9.0" + "node": ">=18" } }, - "node_modules/@babel/parser": { - "version": "7.29.8", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", - "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", + "integrations/even-g2/node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], "dev": true, "license": "MIT", - "dependencies": { - "@babel/types": "^7.29.8" - }, - "bin": { - "parser": "bin/babel-parser.js" - }, + "optional": true, + "os": [ + "linux" + ], + "peer": true, "engines": { - "node": ">=6.0.0" + "node": ">=18" } }, - "node_modules/@babel/plugin-transform-arrow-functions": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.29.7.tgz", - "integrity": "sha512-N7zArUXWzAMzm+/N0uPBeVB3Fam5lMxtUwMmDK5f/IBBS7a7p1qeUoxd/6CckXoxUdgsntq1Dh8xNW06maZbDQ==", + "integrations/even-g2/node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], "dev": true, "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.29.7" - }, + "optional": true, + "os": [ + "linux" + ], + "peer": true, "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "node": ">=18" } }, - "node_modules/@babel/plugin-transform-react-jsx-self": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", - "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "integrations/even-g2/node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], "dev": true, "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.29.7" - }, + "optional": true, + "os": [ + "linux" + ], + "peer": true, "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "node": ">=18" } }, - "node_modules/@babel/plugin-transform-react-jsx-source": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", - "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "integrations/even-g2/node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], "dev": true, "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.29.7" - }, + "optional": true, + "os": [ + "linux" + ], + "peer": true, "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "node": ">=18" } }, - "node_modules/@babel/template": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", - "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "integrations/even-g2/node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.29.7", - "@babel/parser": "^7.29.7", - "@babel/types": "^7.29.7" - }, + "optional": true, + "os": [ + "linux" + ], + "peer": true, "engines": { - "node": ">=6.9.0" + "node": ">=18" } }, - "node_modules/@babel/traverse": { - "version": "7.29.8", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz", - "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", + "integrations/even-g2/node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.29.7", - "@babel/generator": "^7.29.8", - "@babel/helper-globals": "^7.29.7", - "@babel/parser": "^7.29.8", - "@babel/template": "^7.29.7", - "@babel/types": "^7.29.8", - "debug": "^4.3.1" - }, + "optional": true, + "os": [ + "netbsd" + ], + "peer": true, "engines": { - "node": ">=6.9.0" + "node": ">=18" } }, - "node_modules/@babel/types": { - "version": "7.29.8", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", - "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "integrations/even-g2/node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", - "dependencies": { - "@babel/helper-string-parser": "^7.29.7", - "@babel/helper-validator-identifier": "^7.29.7" - }, + "optional": true, + "os": [ + "netbsd" + ], + "peer": true, "engines": { - "node": ">=6.9.0" + "node": ">=18" } }, - "node_modules/@electron-internal/extract-zip": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/@electron-internal/extract-zip/-/extract-zip-1.0.5.tgz", - "integrity": "sha512-+bqFCP98pLI0Tt0XQo1TmlXtwjWchISndDOxCkEcIuUgXWpBnLyRI+2DU+mesvnMMX6L1XDqYNA0lXNDHd/yiA==", + "integrations/even-g2/node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "BSD-2-Clause", + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "peer": true, "engines": { - "node": ">=22.12.0" + "node": ">=18" } }, - "node_modules/@electron/asar": { - "version": "3.4.1", - "resolved": "https://registry.npmjs.org/@electron/asar/-/asar-3.4.1.tgz", - "integrity": "sha512-i4/rNPRS84t0vSRa2HorerGRXWyF4vThfHesw0dmcWHp+cspK743UanA0suA5Q5y8kzY2y6YKrvbIUn69BCAiA==", + "integrations/even-g2/node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", - "dependencies": { - "commander": "^5.0.0", - "glob": "^7.1.6", - "minimatch": "^3.0.4" - }, - "bin": { - "asar": "bin/asar.js" - }, + "optional": true, + "os": [ + "openbsd" + ], + "peer": true, "engines": { - "node": ">=10.12.0" + "node": ">=18" } }, - "node_modules/@electron/asar/node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "integrations/even-g2/node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "MIT" + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "peer": true, + "engines": { + "node": ">=18" + } }, - "node_modules/@electron/asar/node_modules/brace-expansion": { - "version": "1.1.18", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", - "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "integrations/even-g2/node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" + "optional": true, + "os": [ + "sunos" + ], + "peer": true, + "engines": { + "node": ">=18" } }, - "node_modules/@electron/asar/node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "integrations/even-g2/node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, "engines": { - "node": "*" + "node": ">=18" } }, - "node_modules/@electron/fuses": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/@electron/fuses/-/fuses-1.8.0.tgz", - "integrity": "sha512-zx0EIq78WlY/lBb1uXlziZmDZI4ubcCXIMJ4uGjXzZW0nS19TjSPeXPAjzzTmKQlJUZm0SbmZhPKP7tuQ1SsEw==", + "integrations/even-g2/node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], "dev": true, "license": "MIT", - "dependencies": { - "chalk": "^4.1.1", - "fs-extra": "^9.0.1", - "minimist": "^1.2.5" - }, - "bin": { - "electron-fuses": "dist/bin.js" + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">=18" } }, - "node_modules/@electron/fuses/node_modules/fs-extra": { - "version": "9.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", - "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "integrations/even-g2/node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", - "dependencies": { - "at-least-node": "^1.0.0", - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" - }, + "optional": true, + "os": [ + "win32" + ], + "peer": true, "engines": { - "node": ">=10" + "node": ">=18" } }, - "node_modules/@electron/get": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/@electron/get/-/get-5.1.0.tgz", - "integrity": "sha512-3kSBtG8ObcTVfXanm5vVJ6UnBLEVmVsRk1M+vGqCuMBV+XLCbJYuWQful+yIy0GQDsSlK0kHEriEHn7SPk4EnA==", + "integrations/even-g2/node_modules/@evenrealities/evenhub-cli": { + "version": "0.1.14", + "resolved": "https://registry.npmjs.org/@evenrealities/evenhub-cli/-/evenhub-cli-0.1.14.tgz", + "integrity": "sha512-aSaCuj01LDaRUinXu1uAAw6743oGGB6Um8mEiXB5M0mj3dbqzdJ9dijSM1lGcuDX/+wFTa/qUdbx7QippBlDuQ==", "dev": true, - "license": "MIT", "dependencies": { - "debug": "^4.1.1", - "env-paths": "^3.0.0", - "graceful-fs": "^4.2.11", - "progress": "^2.0.3", - "semver": "^7.6.3", - "sumchecker": "^3.0.1" + "chalk": "^5.6.2", + "commander": "^14.0.2", + "inquirer": "^13.1.0", + "js-yaml": "^4.1.1", + "open": "^11.0.0", + "qr-image": "^3.2.0", + "qrcode-terminal": "^0.12.0", + "zod": "^4.3.2" }, - "engines": { - "node": ">=22.12.0" + "bin": { + "eh": "main.js", + "evenhub": "main.js" }, - "optionalDependencies": { - "undici": "^7.24.4" + "peerDependencies": { + "typescript": "^5" } }, - "node_modules/@electron/get/node_modules/semver": { - "version": "7.8.5", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", - "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "integrations/even-g2/node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, + "license": "MIT", "engines": { - "node": ">=10" + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/@electron/notarize": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/@electron/notarize/-/notarize-2.5.0.tgz", - "integrity": "sha512-jNT8nwH1f9X5GEITXaQ8IF/KdskvIkOFfB2CvwumsveVidzpSc+mvhhTMdAGSYF3O+Nq49lJ7y+ssODRXu06+A==", + "integrations/even-g2/node_modules/commander": { + "version": "14.0.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", + "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", "dev": true, "license": "MIT", - "dependencies": { - "debug": "^4.1.1", - "fs-extra": "^9.0.1", - "promise-retry": "^2.0.1" - }, "engines": { - "node": ">= 10.0.0" + "node": ">=20" } }, - "node_modules/@electron/notarize/node_modules/fs-extra": { - "version": "9.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", - "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "integrations/even-g2/node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", "dev": true, + "hasInstallScript": true, "license": "MIT", - "dependencies": { - "at-least-node": "^1.0.0", - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" + "optional": true, + "peer": true, + "bin": { + "esbuild": "bin/esbuild" }, "engines": { - "node": ">=10" - } - }, - "node_modules/@electron/osx-sign": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@electron/osx-sign/-/osx-sign-1.3.3.tgz", - "integrity": "sha512-KZ8mhXvWv2rIEgMbWZ4y33bDHyUKMXnx4M0sTyPNK/vcB81ImdeY9Ggdqy0SWbMDgmbqyQ+phgejh6V3R2QuSg==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "compare-version": "^0.1.2", - "debug": "^4.3.4", - "fs-extra": "^10.0.0", - "isbinaryfile": "^4.0.8", - "minimist": "^1.2.6", - "plist": "^3.0.5" + "node": ">=18" }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "integrations/even-g2/node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "peer": true, "bin": { - "electron-osx-flat": "bin/electron-osx-flat.js", - "electron-osx-sign": "bin/electron-osx-sign.js" + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" }, "engines": { - "node": ">=12.0.0" + "node": ">=14.17" } }, - "node_modules/@electron/osx-sign/node_modules/isbinaryfile": { - "version": "4.0.10", - "resolved": "https://registry.npmjs.org/isbinaryfile/-/isbinaryfile-4.0.10.tgz", - "integrity": "sha512-iHrqe5shvBUcFbmZq9zOQHBoeOhZJu6RQGrDpBgenUm/Am+F3JM2MgQj+rK3Z601fzrL5gLZWtAPH2OBaSVcyw==", + "integrations/even-g2/node_modules/vite": { + "version": "8.2.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.2.tgz", + "integrity": "sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==", "dev": true, "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.26", + "rolldown": "~1.2.4", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, "engines": { - "node": ">= 8.0.0" + "node": "^20.19.0 || >=22.12.0" }, "funding": { - "url": "https://github.com/sponsors/gjtorikian/" + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.4.0 || ^0.5.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } } }, - "node_modules/@electron/rebuild": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/@electron/rebuild/-/rebuild-4.2.0.tgz", - "integrity": "sha512-RKL/O+jGoXJMxrx/5771y1n0xTKmFuOYGO3gMmwypBM6rsH0kou0mswwdXA2JrhIkE4xyC7v9vGk0n6NPzgOxQ==", + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", "dev": true, "license": "MIT", "dependencies": { - "@malept/cross-spawn-promise": "^2.0.0", - "debug": "^4.1.1", - "node-abi": "^4.2.0", - "node-api-version": "^0.2.1", - "node-gyp": "^12.2.0", - "read-binary-file-arch": "^1.0.6" - }, - "bin": { - "electron-rebuild": "lib/cli.js" + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" }, "engines": { - "node": ">=22.12.0" + "node": ">=6.9.0" } }, - "node_modules/@electron/universal": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/@electron/universal/-/universal-2.0.3.tgz", - "integrity": "sha512-Wn9sPYIVFRFl5HmwMJkARCCf7rqK/EurkfQ/rJZ14mHP3iYTjZSIOSVonEAnhWeAXwtw7zOekGRlc6yTtZ0t+g==", + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", "dev": true, "license": "MIT", - "dependencies": { - "@electron/asar": "^3.3.1", - "@malept/cross-spawn-promise": "^2.0.0", - "debug": "^4.3.1", - "dir-compare": "^4.2.0", - "fs-extra": "^11.1.1", - "minimatch": "^9.0.3", - "plist": "^3.1.0" - }, "engines": { - "node": ">=16.4" + "node": ">=6.9.0" } }, - "node_modules/@electron/universal/node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@electron/universal/node_modules/brace-expansion": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", - "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "dev": true, "license": "MIT", "dependencies": { - "balanced-match": "^1.0.0" + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" } }, - "node_modules/@electron/universal/node_modules/fs-extra": { - "version": "11.4.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.4.0.tgz", - "integrity": "sha512-EQsFzMUJkCKGr1ePqlYADkIUmHW1s3ZXr5Yqy6wbGrfUCphpl2maM/kyOIRA2HpP3AaFQTZXD4ldjek+nccddA==", + "node_modules/@babel/generator": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", "dev": true, "license": "MIT", "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" }, "engines": { - "node": ">=14.14" + "node": ">=6.9.0" } }, - "node_modules/@electron/universal/node_modules/minimatch": { - "version": "9.0.9", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", - "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", "dev": true, - "license": "ISC", + "license": "MIT", "dependencies": { - "brace-expansion": "^2.0.2" + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" }, "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" + "node": ">=6.9.0" } }, - "node_modules/@electron/windows-sign": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@electron/windows-sign/-/windows-sign-1.2.2.tgz", - "integrity": "sha512-dfZeox66AvdPtb2lD8OsIIQh12Tp0GNCRUDfBHIKGpbmopZto2/A8nSpYYLoedPIHpqkeblZ/k8OV0Gy7PYuyQ==", + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", "dev": true, - "license": "BSD-2-Clause", - "optional": true, - "peer": true, - "dependencies": { - "cross-dirname": "^0.1.0", - "debug": "^4.3.4", - "fs-extra": "^11.1.1", - "minimist": "^1.2.8", - "postject": "^1.0.0-alpha.6" - }, - "bin": { - "electron-windows-sign": "bin/electron-windows-sign.js" - }, + "license": "MIT", "engines": { - "node": ">=14.14" + "node": ">=6.9.0" } }, - "node_modules/@electron/windows-sign/node_modules/fs-extra": { - "version": "11.4.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.4.0.tgz", - "integrity": "sha512-EQsFzMUJkCKGr1ePqlYADkIUmHW1s3ZXr5Yqy6wbGrfUCphpl2maM/kyOIRA2HpP3AaFQTZXD4ldjek+nccddA==", + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", "dev": true, "license": "MIT", - "optional": true, - "peer": true, "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { - "node": ">=14.14" + "node": ">=6.9.0" } }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", - "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", - "cpu": [ - "ppc64" - ], + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "aix" - ], + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, "engines": { - "node": ">=18" + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" } }, - "node_modules/@esbuild/android-arm": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", - "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", - "cpu": [ - "arm" - ], + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "android" - ], "engines": { - "node": ">=18" + "node": ">=6.9.0" } }, - "node_modules/@esbuild/android-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", - "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", - "cpu": [ - "arm64" - ], + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "android" - ], "engines": { - "node": ">=18" + "node": ">=6.9.0" } }, - "node_modules/@esbuild/android-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", - "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", - "cpu": [ - "x64" - ], + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "android" - ], "engines": { - "node": ">=18" + "node": ">=6.9.0" } }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", - "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", - "cpu": [ - "arm64" - ], + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], "engines": { - "node": ">=18" + "node": ">=6.9.0" } }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", - "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", - "cpu": [ - "x64" - ], + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, "engines": { - "node": ">=18" + "node": ">=6.9.0" } }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", - "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", - "cpu": [ - "arm64" - ], + "node_modules/@babel/parser": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, "engines": { - "node": ">=18" + "node": ">=6.0.0" } }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", - "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", - "cpu": [ - "x64" - ], + "node_modules/@babel/plugin-transform-arrow-functions": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.29.7.tgz", + "integrity": "sha512-N7zArUXWzAMzm+/N0uPBeVB3Fam5lMxtUwMmDK5f/IBBS7a7p1qeUoxd/6CckXoxUdgsntq1Dh8xNW06maZbDQ==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, "engines": { - "node": ">=18" + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, - "node_modules/@esbuild/linux-arm": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", - "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", - "cpu": [ - "arm" - ], + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, "engines": { - "node": ">=18" + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", - "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", - "cpu": [ - "arm64" - ], + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, "engines": { - "node": ">=18" + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", - "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", - "cpu": [ - "ia32" - ], + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz", + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.8", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@electron-internal/extract-zip": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@electron-internal/extract-zip/-/extract-zip-1.0.5.tgz", + "integrity": "sha512-+bqFCP98pLI0Tt0XQo1TmlXtwjWchISndDOxCkEcIuUgXWpBnLyRI+2DU+mesvnMMX6L1XDqYNA0lXNDHd/yiA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/@electron/asar": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/@electron/asar/-/asar-3.4.1.tgz", + "integrity": "sha512-i4/rNPRS84t0vSRa2HorerGRXWyF4vThfHesw0dmcWHp+cspK743UanA0suA5Q5y8kzY2y6YKrvbIUn69BCAiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "commander": "^5.0.0", + "glob": "^7.1.6", + "minimatch": "^3.0.4" + }, + "bin": { + "asar": "bin/asar.js" + }, + "engines": { + "node": ">=10.12.0" + } + }, + "node_modules/@electron/asar/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@electron/asar/node_modules/brace-expansion": { + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@electron/asar/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@electron/fuses": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@electron/fuses/-/fuses-1.8.0.tgz", + "integrity": "sha512-zx0EIq78WlY/lBb1uXlziZmDZI4ubcCXIMJ4uGjXzZW0nS19TjSPeXPAjzzTmKQlJUZm0SbmZhPKP7tuQ1SsEw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.1", + "fs-extra": "^9.0.1", + "minimist": "^1.2.5" + }, + "bin": { + "electron-fuses": "dist/bin.js" + } + }, + "node_modules/@electron/fuses/node_modules/fs-extra": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@electron/get": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@electron/get/-/get-5.1.0.tgz", + "integrity": "sha512-3kSBtG8ObcTVfXanm5vVJ6UnBLEVmVsRk1M+vGqCuMBV+XLCbJYuWQful+yIy0GQDsSlK0kHEriEHn7SPk4EnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "env-paths": "^3.0.0", + "graceful-fs": "^4.2.11", + "progress": "^2.0.3", + "semver": "^7.6.3", + "sumchecker": "^3.0.1" + }, + "engines": { + "node": ">=22.12.0" + }, + "optionalDependencies": { + "undici": "^7.24.4" + } + }, + "node_modules/@electron/get/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@electron/notarize": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@electron/notarize/-/notarize-2.5.0.tgz", + "integrity": "sha512-jNT8nwH1f9X5GEITXaQ8IF/KdskvIkOFfB2CvwumsveVidzpSc+mvhhTMdAGSYF3O+Nq49lJ7y+ssODRXu06+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "fs-extra": "^9.0.1", + "promise-retry": "^2.0.1" + }, + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@electron/notarize/node_modules/fs-extra": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@electron/osx-sign": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@electron/osx-sign/-/osx-sign-1.3.3.tgz", + "integrity": "sha512-KZ8mhXvWv2rIEgMbWZ4y33bDHyUKMXnx4M0sTyPNK/vcB81ImdeY9Ggdqy0SWbMDgmbqyQ+phgejh6V3R2QuSg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "compare-version": "^0.1.2", + "debug": "^4.3.4", + "fs-extra": "^10.0.0", + "isbinaryfile": "^4.0.8", + "minimist": "^1.2.6", + "plist": "^3.0.5" + }, + "bin": { + "electron-osx-flat": "bin/electron-osx-flat.js", + "electron-osx-sign": "bin/electron-osx-sign.js" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/@electron/osx-sign/node_modules/isbinaryfile": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/isbinaryfile/-/isbinaryfile-4.0.10.tgz", + "integrity": "sha512-iHrqe5shvBUcFbmZq9zOQHBoeOhZJu6RQGrDpBgenUm/Am+F3JM2MgQj+rK3Z601fzrL5gLZWtAPH2OBaSVcyw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/gjtorikian/" + } + }, + "node_modules/@electron/rebuild": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@electron/rebuild/-/rebuild-4.2.0.tgz", + "integrity": "sha512-RKL/O+jGoXJMxrx/5771y1n0xTKmFuOYGO3gMmwypBM6rsH0kou0mswwdXA2JrhIkE4xyC7v9vGk0n6NPzgOxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@malept/cross-spawn-promise": "^2.0.0", + "debug": "^4.1.1", + "node-abi": "^4.2.0", + "node-api-version": "^0.2.1", + "node-gyp": "^12.2.0", + "read-binary-file-arch": "^1.0.6" + }, + "bin": { + "electron-rebuild": "lib/cli.js" + }, + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/@electron/universal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@electron/universal/-/universal-2.0.3.tgz", + "integrity": "sha512-Wn9sPYIVFRFl5HmwMJkARCCf7rqK/EurkfQ/rJZ14mHP3iYTjZSIOSVonEAnhWeAXwtw7zOekGRlc6yTtZ0t+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@electron/asar": "^3.3.1", + "@malept/cross-spawn-promise": "^2.0.0", + "debug": "^4.3.1", + "dir-compare": "^4.2.0", + "fs-extra": "^11.1.1", + "minimatch": "^9.0.3", + "plist": "^3.1.0" + }, + "engines": { + "node": ">=16.4" + } + }, + "node_modules/@electron/universal/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@electron/universal/node_modules/brace-expansion": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", + "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@electron/universal/node_modules/fs-extra": { + "version": "11.4.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.4.0.tgz", + "integrity": "sha512-EQsFzMUJkCKGr1ePqlYADkIUmHW1s3ZXr5Yqy6wbGrfUCphpl2maM/kyOIRA2HpP3AaFQTZXD4ldjek+nccddA==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/@electron/universal/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@electron/windows-sign": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@electron/windows-sign/-/windows-sign-1.2.2.tgz", + "integrity": "sha512-dfZeox66AvdPtb2lD8OsIIQh12Tp0GNCRUDfBHIKGpbmopZto2/A8nSpYYLoedPIHpqkeblZ/k8OV0Gy7PYuyQ==", + "dev": true, + "license": "BSD-2-Clause", + "optional": true, + "peer": true, + "dependencies": { + "cross-dirname": "^0.1.0", + "debug": "^4.3.4", + "fs-extra": "^11.1.1", + "minimist": "^1.2.8", + "postject": "^1.0.0-alpha.6" + }, + "bin": { + "electron-windows-sign": "bin/electron-windows-sign.js" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/@electron/windows-sign/node_modules/fs-extra": { + "version": "11.4.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.4.0.tgz", + "integrity": "sha512-EQsFzMUJkCKGr1ePqlYADkIUmHW1s3ZXr5Yqy6wbGrfUCphpl2maM/kyOIRA2HpP3AaFQTZXD4ldjek+nccddA==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@evenrealities/even_hub_sdk": { + "version": "0.0.14", + "resolved": "https://registry.npmjs.org/@evenrealities/even_hub_sdk/-/even_hub_sdk-0.0.14.tgz", + "integrity": "sha512-lNtyIldZfe0dhAVxCfNuJ7waH2lMPli8DyklA8jF4DlvByQtbK1janodPNo7M8+7Plp30mlQT8d+vYU3gO8vKQ==", + "license": "MIT", + "engines": { + "node": "^20.0.0 || >=22.0.0" + } + }, + "node_modules/@fontsource/ibm-plex-mono": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource/ibm-plex-mono/-/ibm-plex-mono-5.3.0.tgz", + "integrity": "sha512-eTgnZjZEGk1QtD3ZstF+Vclo2HLAni8YMy34/DxllwZvyz1lR/1RF/xTiAquOBO7MvqBx8D2Ig2WCPMVfdZu7Q==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@fontsource/ibm-plex-sans": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource/ibm-plex-sans/-/ibm-plex-sans-5.3.0.tgz", + "integrity": "sha512-CbE4CbbEEZJX860XyUiRpsksXIQR8Rp2XDva2VO53NJox9tVNtusrysd2x5YkUEY3ErQ66W1IiiQL8/wihhw5w==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@inquirer/ansi": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-2.0.8.tgz", + "integrity": "sha512-WpQM+Ti6Z40EFwwt+uL2p4UabT+W179zHp6HhLVOzfbwnVn05IPO/eXIZXGNqcT1jbQ15SujNLzQ39k4QPPxBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + } + }, + "node_modules/@inquirer/checkbox": { + "version": "5.2.5", + "resolved": "https://registry.npmjs.org/@inquirer/checkbox/-/checkbox-5.2.5.tgz", + "integrity": "sha512-bRt8J8m+Fot9CXv+zNQGXUq2ET0MggR1fPz7v6edN6MFYmsbfGnMmkmWZJEegMKqrAC8ej/o1sqisHZXZJMAfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^2.0.8", + "@inquirer/core": "^12.0.3", + "@inquirer/figures": "^2.0.9", + "@inquirer/type": "4.1.1" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/checkbox/node_modules/@inquirer/core": { + "version": "12.0.3", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-12.0.3.tgz", + "integrity": "sha512-wsSy0sznmXwkty+2PzZwx00Cazc/E0r0B7mAzdGROz2Ct+DFZXaK7WDjGZvgjRldxH5ZhFVfF2lgkYrqgOw2KA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^2.0.8", + "@inquirer/figures": "^2.0.9", + "@inquirer/type": "4.1.1", + "cli-width": "^4.1.0", + "fast-wrap-ansi": "^0.2.0", + "mute-stream": "^3.0.0", + "signal-exit": "^4.1.0" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/checkbox/node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@inquirer/confirm": { + "version": "6.3.2", + "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-6.3.2.tgz", + "integrity": "sha512-Xvr/0HggjddPtGppuqVmxhTw+Hr8PvsZ/k0HmOEaAqQEt80OITNkFWnsdNmyT0/eM4Ab+iJLx2R8rctlEyfSVg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^12.0.3", + "@inquirer/type": "4.1.1" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/confirm/node_modules/@inquirer/core": { + "version": "12.0.3", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-12.0.3.tgz", + "integrity": "sha512-wsSy0sznmXwkty+2PzZwx00Cazc/E0r0B7mAzdGROz2Ct+DFZXaK7WDjGZvgjRldxH5ZhFVfF2lgkYrqgOw2KA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^2.0.8", + "@inquirer/figures": "^2.0.9", + "@inquirer/type": "4.1.1", + "cli-width": "^4.1.0", + "fast-wrap-ansi": "^0.2.0", + "mute-stream": "^3.0.0", + "signal-exit": "^4.1.0" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/confirm/node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@inquirer/core": { + "version": "11.2.1", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-11.2.1.tgz", + "integrity": "sha512-Qd6GJT1yVyrZZCfN8W2qKF5ApmqryXRhRKCuip8h01x2w/esJQ2XIYc6f9abMIHgKQdBfFTSOdbHRLAhuM09UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^2.0.7", + "@inquirer/figures": "^2.0.7", + "@inquirer/type": "^4.0.7", + "cli-width": "^4.1.0", + "fast-wrap-ansi": "^0.2.0", + "mute-stream": "^3.0.0", + "signal-exit": "^4.1.0" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/core/node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@inquirer/editor": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/@inquirer/editor/-/editor-5.3.3.tgz", + "integrity": "sha512-YsKkS2q63IiLtaDK/9nqzdComN97SDQrmKiyNggN+ceP4ty+Z6VwyTz3FpjeUWeW1Efss2xHFKCC9sx7hnrsxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^12.0.3", + "@inquirer/external-editor": "^3.0.5", + "@inquirer/type": "4.1.1" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/editor/node_modules/@inquirer/core": { + "version": "12.0.3", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-12.0.3.tgz", + "integrity": "sha512-wsSy0sznmXwkty+2PzZwx00Cazc/E0r0B7mAzdGROz2Ct+DFZXaK7WDjGZvgjRldxH5ZhFVfF2lgkYrqgOw2KA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^2.0.8", + "@inquirer/figures": "^2.0.9", + "@inquirer/type": "4.1.1", + "cli-width": "^4.1.0", + "fast-wrap-ansi": "^0.2.0", + "mute-stream": "^3.0.0", + "signal-exit": "^4.1.0" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/editor/node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@inquirer/expand": { + "version": "5.1.5", + "resolved": "https://registry.npmjs.org/@inquirer/expand/-/expand-5.1.5.tgz", + "integrity": "sha512-uHuXLmXW+TtIfT/9vSBotypAkqn1n34Ul+CLGPos/xANyO4Ff5xZzkYhbKR4NEcfVK4a9mHQOpwVZzluSHFRGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^12.0.3", + "@inquirer/type": "4.1.1" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/expand/node_modules/@inquirer/core": { + "version": "12.0.3", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-12.0.3.tgz", + "integrity": "sha512-wsSy0sznmXwkty+2PzZwx00Cazc/E0r0B7mAzdGROz2Ct+DFZXaK7WDjGZvgjRldxH5ZhFVfF2lgkYrqgOw2KA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^2.0.8", + "@inquirer/figures": "^2.0.9", + "@inquirer/type": "4.1.1", + "cli-width": "^4.1.0", + "fast-wrap-ansi": "^0.2.0", + "mute-stream": "^3.0.0", + "signal-exit": "^4.1.0" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/expand/node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@inquirer/external-editor": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@inquirer/external-editor/-/external-editor-3.0.5.tgz", + "integrity": "sha512-f3QQJRIX5ZEneBHNUIuPjmbdzHnmRFJA8r2dkcb8q+OM5Uv5KtnuAttQumnrjcBVBM3mcTX1CkmtAkU58VRZxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "chardet": "^2.1.1", + "iconv-lite": "^0.7.2" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/figures": { + "version": "2.0.9", + "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-2.0.9.tgz", + "integrity": "sha512-EAWgUTGQ/Umgga51dE3B2PUHbufuXarDfg86uVgoSgNHNNQnyFKcOrQLWVqYMghuSyHh8+2HUH0Js9cTC1WAdg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + } + }, + "node_modules/@inquirer/input": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/@inquirer/input/-/input-5.1.6.tgz", + "integrity": "sha512-HtcJhB2QFVXbLuJ5S3syhNbTUVxYvwqV4VRBDkQceBloC9bmTViUoRFP5PbSaDZb3HzfPmpuU/gG4ybVBz4FHA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^12.0.3", + "@inquirer/type": "4.1.1" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/input/node_modules/@inquirer/core": { + "version": "12.0.3", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-12.0.3.tgz", + "integrity": "sha512-wsSy0sznmXwkty+2PzZwx00Cazc/E0r0B7mAzdGROz2Ct+DFZXaK7WDjGZvgjRldxH5ZhFVfF2lgkYrqgOw2KA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^2.0.8", + "@inquirer/figures": "^2.0.9", + "@inquirer/type": "4.1.1", + "cli-width": "^4.1.0", + "fast-wrap-ansi": "^0.2.0", + "mute-stream": "^3.0.0", + "signal-exit": "^4.1.0" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/input/node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@inquirer/number": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/@inquirer/number/-/number-4.2.3.tgz", + "integrity": "sha512-6Yuwh1NGSbu1Lo4N1EWjXs1jKRntLg/ZCwhmeorEHde90v1XxAozdbd4Iu30eOQLW+6h1hp2O9ujNfLSbTPJnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^12.0.3", + "@inquirer/type": "4.1.1" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/number/node_modules/@inquirer/core": { + "version": "12.0.3", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-12.0.3.tgz", + "integrity": "sha512-wsSy0sznmXwkty+2PzZwx00Cazc/E0r0B7mAzdGROz2Ct+DFZXaK7WDjGZvgjRldxH5ZhFVfF2lgkYrqgOw2KA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^2.0.8", + "@inquirer/figures": "^2.0.9", + "@inquirer/type": "4.1.1", + "cli-width": "^4.1.0", + "fast-wrap-ansi": "^0.2.0", + "mute-stream": "^3.0.0", + "signal-exit": "^4.1.0" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/number/node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@inquirer/password": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/@inquirer/password/-/password-5.2.2.tgz", + "integrity": "sha512-W9zYdyzogK+6110mqwaSJWCBu2yA5Q/OfnGSjjZB1bNpHlmUozXxTl0+QOZBNeVd6Qo81/qT75gW05gLAtITxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^2.0.8", + "@inquirer/core": "^12.0.3", + "@inquirer/type": "4.1.1" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/password/node_modules/@inquirer/core": { + "version": "12.0.3", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-12.0.3.tgz", + "integrity": "sha512-wsSy0sznmXwkty+2PzZwx00Cazc/E0r0B7mAzdGROz2Ct+DFZXaK7WDjGZvgjRldxH5ZhFVfF2lgkYrqgOw2KA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^2.0.8", + "@inquirer/figures": "^2.0.9", + "@inquirer/type": "4.1.1", + "cli-width": "^4.1.0", + "fast-wrap-ansi": "^0.2.0", + "mute-stream": "^3.0.0", + "signal-exit": "^4.1.0" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/password/node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@inquirer/prompts": { + "version": "8.7.2", + "resolved": "https://registry.npmjs.org/@inquirer/prompts/-/prompts-8.7.2.tgz", + "integrity": "sha512-QoRB4wFIjgH5iOhSjoIKMkTvSHDuV+O3OITlIqAYO0oK5x364GJILXiMBvlPiE+klg7Xx9tq5XVqQHcGUDYYPA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/checkbox": "^5.2.5", + "@inquirer/confirm": "^6.3.2", + "@inquirer/editor": "^5.3.3", + "@inquirer/expand": "^5.1.5", + "@inquirer/input": "^5.1.6", + "@inquirer/number": "^4.2.3", + "@inquirer/password": "^5.2.2", + "@inquirer/rawlist": "^5.3.5", + "@inquirer/search": "^4.3.3", + "@inquirer/select": "^5.2.5" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/rawlist": { + "version": "5.3.5", + "resolved": "https://registry.npmjs.org/@inquirer/rawlist/-/rawlist-5.3.5.tgz", + "integrity": "sha512-1oHky1ONfCOwNrnkQGDE1oaSij/3fI6HFMSf2H/WsGO2lEyDX9My82iggITSy9ddSZ8yk8j9v41OI0fVoSIoaA==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "dependencies": { + "@inquirer/core": "^12.0.3", + "@inquirer/type": "4.1.1" + }, "engines": { - "node": ">=18" + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } } }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", - "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", - "cpu": [ - "loong64" - ], + "node_modules/@inquirer/rawlist/node_modules/@inquirer/core": { + "version": "12.0.3", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-12.0.3.tgz", + "integrity": "sha512-wsSy0sznmXwkty+2PzZwx00Cazc/E0r0B7mAzdGROz2Ct+DFZXaK7WDjGZvgjRldxH5ZhFVfF2lgkYrqgOw2KA==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "dependencies": { + "@inquirer/ansi": "^2.0.8", + "@inquirer/figures": "^2.0.9", + "@inquirer/type": "4.1.1", + "cli-width": "^4.1.0", + "fast-wrap-ansi": "^0.2.0", + "mute-stream": "^3.0.0", + "signal-exit": "^4.1.0" + }, "engines": { - "node": ">=18" + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } } }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", - "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", - "cpu": [ - "mips64el" - ], + "node_modules/@inquirer/rawlist/node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@inquirer/search": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@inquirer/search/-/search-4.3.3.tgz", + "integrity": "sha512-fyuIU1Nbpvwlikjg3gXwJFDI11+EFjqQ7P+iByfmivIKQ1vmaykNrD/vy5unHuUqUpsOsnvJ25//tPF7E/RBRA==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "dependencies": { + "@inquirer/core": "^12.0.3", + "@inquirer/figures": "^2.0.9", + "@inquirer/type": "4.1.1" + }, "engines": { - "node": ">=18" + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } } }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", - "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", - "cpu": [ - "ppc64" + "node_modules/@inquirer/search/node_modules/@inquirer/core": { + "version": "12.0.3", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-12.0.3.tgz", + "integrity": "sha512-wsSy0sznmXwkty+2PzZwx00Cazc/E0r0B7mAzdGROz2Ct+DFZXaK7WDjGZvgjRldxH5ZhFVfF2lgkYrqgOw2KA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^2.0.8", + "@inquirer/figures": "^2.0.9", + "@inquirer/type": "4.1.1", + "cli-width": "^4.1.0", + "fast-wrap-ansi": "^0.2.0", + "mute-stream": "^3.0.0", + "signal-exit": "^4.1.0" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/search/node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@inquirer/select": { + "version": "5.2.5", + "resolved": "https://registry.npmjs.org/@inquirer/select/-/select-5.2.5.tgz", + "integrity": "sha512-9kc15hr8r/kI+3DO/xLog5nOzTz1jqsHXa6JBFzmQKhkoJ8Slda1I1L/uD8ZSZ9tF1yp79wwXe7mclvX1rqR2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^2.0.8", + "@inquirer/core": "^12.0.3", + "@inquirer/figures": "^2.0.9", + "@inquirer/type": "4.1.1" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/select/node_modules/@inquirer/core": { + "version": "12.0.3", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-12.0.3.tgz", + "integrity": "sha512-wsSy0sznmXwkty+2PzZwx00Cazc/E0r0B7mAzdGROz2Ct+DFZXaK7WDjGZvgjRldxH5ZhFVfF2lgkYrqgOw2KA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^2.0.8", + "@inquirer/figures": "^2.0.9", + "@inquirer/type": "4.1.1", + "cli-width": "^4.1.0", + "fast-wrap-ansi": "^0.2.0", + "mute-stream": "^3.0.0", + "signal-exit": "^4.1.0" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/select/node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@inquirer/type": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-4.1.1.tgz", + "integrity": "sha512-yJoHYrMnxIsJZCY+0Vb66Dy3he3kL3e2wOBKhoSwWWAzZAY82emlxwgprCtp6yRixvNRNq9ztfRWQYPNr3Go7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@isaacs/fs-minipass": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", + "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.4" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@malept/cross-spawn-promise": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@malept/cross-spawn-promise/-/cross-spawn-promise-2.0.0.tgz", + "integrity": "sha512-1DpKU0Z5ThltBwjNySMC14g0CkbyhCaz9FkhxqNsZI6uAPJXFS8cMXlBKo26FJ8ZuW6S9GCMcR9IO5k2X5/9Fg==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/malept" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/subscription/pkg/npm-.malept-cross-spawn-promise?utm_medium=referral&utm_source=npm_fund" + } ], + "license": "Apache-2.0", + "dependencies": { + "cross-spawn": "^7.0.1" + }, + "engines": { + "node": ">= 12.13.0" + } + }, + "node_modules/@malept/flatpak-bundler": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@malept/flatpak-bundler/-/flatpak-bundler-0.4.0.tgz", + "integrity": "sha512-9QOtNffcOF/c1seMCDnjckb3R9WHcG34tky+FHpNKKCW0wc/scYLwMtO+ptyGUfMW0/b/n4qRiALlaFHc9Oj7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "fs-extra": "^9.0.0", + "lodash": "^4.17.15", + "tmp-promise": "^3.0.2" + }, + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@malept/flatpak-bundler/node_modules/fs-extra": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "dependencies": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, "engines": { - "node": ">=18" + "node": ">=10" } }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", - "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", "cpu": [ - "riscv64" + "x64" ], "dev": true, "license": "MIT", @@ -899,149 +2674,122 @@ "linux" ], "engines": { - "node": ">=18" + "node": "^22.20 || ^24.12 || >=25" } }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", - "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", - "cpu": [ - "s390x" - ], + "node_modules/@noble/hashes": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz", + "integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], "engines": { - "node": ">=18" + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" } }, - "node_modules/@esbuild/linux-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", - "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", - "cpu": [ - "x64" - ], + "node_modules/@oxc-project/types": { + "version": "0.149.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.149.0.tgz", + "integrity": "sha512-Efcc+iF0j3Bf67YjEqIqWXbX5XddXoK/Mw4K1/JuXwRCZ8N16VR7iT23nlCc9XrveFVh/E5Rqs2StT0V8v9LdA==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" + "funding": { + "url": "https://github.com/sponsors/oxc-project" } }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", - "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", - "cpu": [ - "arm64" - ], + "node_modules/@peculiar/asn1-schema": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.8.0.tgz", + "integrity": "sha512-7YT0U/ze0tF2QOBbE15gKZwy5tvgGyLRiRHLzhlbOpf7BT032oBSd0haZqXn5W6l26WLlu3dyxzjM+2638/z2Q==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" + "dependencies": { + "@peculiar/utils": "^2.0.2", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" } }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", - "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", - "cpu": [ - "x64" - ], + "node_modules/@peculiar/json-schema": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/@peculiar/json-schema/-/json-schema-1.1.12.tgz", + "integrity": "sha512-coUfuoMeIB7B8/NMekxaDzLhaYmp0HZNPEjYRm9goRou8UZIC3z21s0sL9AWoCw4EG876QyO3kYrc61WNF9B/w==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], + "dependencies": { + "tslib": "^2.0.0" + }, "engines": { - "node": ">=18" + "node": ">=8.0.0" } }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", - "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", - "cpu": [ - "arm64" - ], + "node_modules/@peculiar/utils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@peculiar/utils/-/utils-2.0.3.tgz", + "integrity": "sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" + "dependencies": { + "tslib": "^2.8.1" } }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", - "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", - "cpu": [ - "x64" - ], + "node_modules/@peculiar/webcrypto": { + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/@peculiar/webcrypto/-/webcrypto-1.7.1.tgz", + "integrity": "sha512-ODOov0sGMJMf3jPonOkgGqPknTsu+DdQ7kD++gz8aI+aFMOMHFbWAA2taqXXVTdP+OTOQR/znGvSpmkeI0WTYQ==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], + "dependencies": { + "@peculiar/asn1-schema": "^2.7.0", + "@peculiar/json-schema": "^1.1.12", + "@peculiar/utils": "^2.0.2", + "tslib": "^2.8.1", + "webcrypto-core": "^1.9.2" + }, "engines": { - "node": ">=18" + "node": ">=14.18.0" } }, - "node_modules/@esbuild/openharmony-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", - "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.8.tgz", + "integrity": "sha512-tN5aztYkKCte4i5SIrrz5yK/HMjEuCqCSCJa418jOV8tZ1cBY3YF2otxB1ktPxzsLA1BeTqwapK0bfjxNvHJVw==", "cpu": [ - "arm64" + "arm" ], "dev": true, "license": "MIT", "optional": true, "os": [ - "openharmony" + "android" ], "engines": { - "node": ">=18" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", - "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.8.tgz", + "integrity": "sha512-dIYTWl9XprMUiQFoc55KUyk/oS8SKYH3zFl0LTR7RT0Xj4hgSVyuJcroH8JUu8RcpF8fTB6E0aOwCkZoYPcDSQ==", "cpu": [ - "x64" + "arm64" ], "dev": true, "license": "MIT", "optional": true, "os": [ - "sunos" + "android" ], "engines": { - "node": ">=18" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", - "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.8.tgz", + "integrity": "sha512-PCSDQGXD2IyTEFrcgPyBM8jJuGmrbCMuoIOXdbEGVemruKACXoLQJrb+A45Z0L5t1RQkdfJprAYPkikbh7dzdA==", "cpu": [ "arm64" ], @@ -1049,33 +2797,33 @@ "license": "MIT", "optional": true, "os": [ - "win32" + "darwin" ], "engines": { - "node": ">=18" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", - "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.8.tgz", + "integrity": "sha512-Uk7lRsGhPFHVX/sAUC6D5H9Ol30dFHd6iquokll2th3LpdJ3F5CzQB+7DHn0Ri2mG+U7k2zXiPHDrwZenXhwSA==", "cpu": [ - "ia32" + "x64" ], "dev": true, "license": "MIT", "optional": true, "os": [ - "win32" + "darwin" ], "engines": { - "node": ">=18" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@esbuild/win32-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", - "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.8.tgz", + "integrity": "sha512-DjszaTEVogPqA5bYzsEeqDCQxbcp2fexQwKcRspYji2yzR68fCf+e4fx6kBSRDwX5/brZaHw/hWS9+A/+/w9sQ==", "cpu": [ "x64" ], @@ -1083,213 +2831,180 @@ "license": "MIT", "optional": true, "os": [ - "win32" + "freebsd" ], "engines": { - "node": ">=18" - } - }, - "node_modules/@isaacs/fs-minipass": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", - "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", - "dev": true, - "license": "ISC", - "dependencies": { - "minipass": "^7.0.4" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.13", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", - "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0", - "@jridgewell/trace-mapping": "^0.3.24" - } - }, - "node_modules/@jridgewell/remapping": { - "version": "2.3.5", - "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", - "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.24" - } - }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", - "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "dev": true, - "license": "MIT" - }, - "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.31", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", - "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.8.tgz", + "integrity": "sha512-zmwa7FTmdzB6aaEEuuls18H6Ap5JmJPSoPTuXixeJZV6tG40SyLkApQtz1g8ptZtiEKqj9OM0oNLPh1AgvE31Q==", + "cpu": [ + "arm" + ], "dev": true, "license": "MIT", - "dependencies": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@malept/cross-spawn-promise": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@malept/cross-spawn-promise/-/cross-spawn-promise-2.0.0.tgz", - "integrity": "sha512-1DpKU0Z5ThltBwjNySMC14g0CkbyhCaz9FkhxqNsZI6uAPJXFS8cMXlBKo26FJ8ZuW6S9GCMcR9IO5k2X5/9Fg==", + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.8.tgz", + "integrity": "sha512-KdYQDPHwJVnbFwdTGMgxsI9SqblBlz6STGM+w1We/d5B8OWWidYH0MwkU/uA1wM5fIpO2MkOVxXrNzzuZhw9ew==", + "cpu": [ + "arm64" + ], "dev": true, - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/malept" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/subscription/pkg/npm-.malept-cross-spawn-promise?utm_medium=referral&utm_source=npm_fund" - } + "license": "MIT", + "optional": true, + "os": [ + "linux" ], - "license": "Apache-2.0", - "dependencies": { - "cross-spawn": "^7.0.1" - }, "engines": { - "node": ">= 12.13.0" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@malept/flatpak-bundler": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/@malept/flatpak-bundler/-/flatpak-bundler-0.4.0.tgz", - "integrity": "sha512-9QOtNffcOF/c1seMCDnjckb3R9WHcG34tky+FHpNKKCW0wc/scYLwMtO+ptyGUfMW0/b/n4qRiALlaFHc9Oj7Q==", + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.8.tgz", + "integrity": "sha512-jFJTifHnNPY+yzOoNZQfSIysrVyXzEQPhPnOUjmD1bcQGHH6s7c8cViKWar8YplQImE5N9JRqMCLrM2CdxOrZA==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", - "dependencies": { - "debug": "^4.1.1", - "fs-extra": "^9.0.0", - "lodash": "^4.17.15", - "tmp-promise": "^3.0.2" - }, + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">= 10.0.0" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@malept/flatpak-bundler/node_modules/fs-extra": { - "version": "9.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", - "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.8.tgz", + "integrity": "sha512-FhiOziBDWPBjbcmRzfLyIJnaP7AVMFXT7YCXPjXxj7wKU3vx24RjrCNN/zjvVa+N2vVoHJwCoUBvsrN/DG3zIA==", + "cpu": [ + "ppc64" + ], "dev": true, "license": "MIT", - "dependencies": { - "at-least-node": "^1.0.0", - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" - }, + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=10" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@napi-rs/lzma-linux-x64-gnu": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", - "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.8.tgz", + "integrity": "sha512-WnHfADMzOV2Y55wlx1hzzQnar/wDt/VdvWSD99r18Mz9ylNieIGOkRx3UV21h7m/eJvjySYJkO26VvGNFkwsIQ==", "cpu": [ - "x64" + "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { - "node": "^22.20 || ^24.12 || >=25" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@noble/hashes": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz", - "integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==", + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.8.tgz", + "integrity": "sha512-H9tRr5ibfXFVLxbPOseVewewFpl28zcEdjRDt2FTUZU7odxP0gEv1ki4/kGmcGOh78oRwZuuQllGLZ9zTJp84g==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">= 20.19.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@peculiar/asn1-schema": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.8.0.tgz", - "integrity": "sha512-7YT0U/ze0tF2QOBbE15gKZwy5tvgGyLRiRHLzhlbOpf7BT032oBSd0haZqXn5W6l26WLlu3dyxzjM+2638/z2Q==", + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.8.tgz", + "integrity": "sha512-UefiqfM3D6IVNlZ8tSGs9+Ejjud2T+oxO0IHADU45Y+lyEjD2dVFyZHbkfX0LUb5Zugo/oIv1eCO/KVYhgYJYA==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", - "dependencies": { - "@peculiar/utils": "^2.0.2", - "asn1js": "^3.0.10", - "tslib": "^2.8.1" + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@peculiar/json-schema": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/@peculiar/json-schema/-/json-schema-1.1.12.tgz", - "integrity": "sha512-coUfuoMeIB7B8/NMekxaDzLhaYmp0HZNPEjYRm9goRou8UZIC3z21s0sL9AWoCw4EG876QyO3kYrc61WNF9B/w==", + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.8.tgz", + "integrity": "sha512-637Ke4kWSy6rp9cxQ9gMOXlxPgIw/c1beASV4M//3+9I4uwBVOOl74G+e3zyU3u19U7RkRl/HuewixZ/Z6+Rjg==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", - "dependencies": { - "tslib": "^2.0.0" - }, + "optional": true, + "os": [ + "openharmony" + ], "engines": { - "node": ">=8.0.0" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@peculiar/utils": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/@peculiar/utils/-/utils-2.0.3.tgz", - "integrity": "sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ==", + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.8.tgz", + "integrity": "sha512-xWBkPOF1Q9k/Gv1nQXnVdLxKu74jXppuOM4Z3mnypVUJJJwLsMl7hNJGRAUJoG8A5MgOI1ACKM+wBFxSJzKy4A==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", - "dependencies": { - "tslib": "^2.8.1" + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@peculiar/webcrypto": { - "version": "1.7.1", - "resolved": "https://registry.npmjs.org/@peculiar/webcrypto/-/webcrypto-1.7.1.tgz", - "integrity": "sha512-ODOov0sGMJMf3jPonOkgGqPknTsu+DdQ7kD++gz8aI+aFMOMHFbWAA2taqXXVTdP+OTOQR/znGvSpmkeI0WTYQ==", + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.8.tgz", + "integrity": "sha512-uz2ZvfgXbxqNwijjjbxrnvALwpyODDcgc1T1N8N3rf/DXKQmaFwmB4LX4yyjggpwN2obdQLb2rgirX5ffCWYng==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", - "dependencies": { - "@peculiar/asn1-schema": "^2.7.0", - "@peculiar/json-schema": "^1.1.12", - "@peculiar/utils": "^2.0.2", - "tslib": "^2.8.1", - "webcrypto-core": "^1.9.2" - }, + "optional": true, + "os": [ + "win32" + ], "engines": { - "node": ">=14.18.0" + "node": "^20.19.0 || >=22.12.0" } }, "node_modules/@rolldown/pluginutils": { @@ -1391,9 +3106,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1408,9 +3120,6 @@ "arm" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1425,9 +3134,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1442,9 +3148,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1459,9 +3162,6 @@ "loong64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1476,9 +3176,6 @@ "loong64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1493,9 +3190,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1510,9 +3204,6 @@ "ppc64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1527,9 +3218,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1544,9 +3232,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1561,9 +3246,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1578,9 +3260,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1595,9 +3274,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2246,6 +3922,15 @@ "integrity": "sha512-4Smom3RPyVp7ZMYOYDoC/9eGJJJqYhnPLGGqJ6wOBfB8VxPViJNSKdgRYb8NpaM6YSelEKbA2SStD7lGyqaobw==", "license": "MIT" }, + "node_modules/@xterm/headless": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@xterm/headless/-/headless-6.0.0.tgz", + "integrity": "sha512-5Yj1QINYCyzrZtf8OFIHi47iQtI+0qYFPHmouEfG8dHNxbZ9Tb9YGSuLcsEwj9Z+OL75GJqPyJbyoFer80a2Hw==", + "license": "MIT", + "workspaces": [ + "addons/*" + ] + }, "node_modules/@xterm/xterm": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz", @@ -2514,6 +4199,12 @@ "dev": true, "license": "Python-2.0" }, + "node_modules/array-buffer-to-hex": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/array-buffer-to-hex/-/array-buffer-to-hex-1.0.0.tgz", + "integrity": "sha512-arycdkxgK1cj6s03GDb96tlCxOl1n3kg9M2OHseUc6Pqyqp+lgfceFPmG507eI5V+oxOSEnlOw/dFc7LXBXF4Q==", + "license": "MIT" + }, "node_modules/asn1js": { "version": "3.0.10", "resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.10.tgz", @@ -2724,6 +4415,22 @@ "node": ">=12.0.0" } }, + "node_modules/bundle-name": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", + "integrity": "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "run-applescript": "^7.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/bytestreamjs": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/bytestreamjs/-/bytestreamjs-2.0.1.tgz", @@ -2808,6 +4515,10 @@ ], "license": "CC-BY-4.0" }, + "node_modules/canvastty-even-g2": { + "resolved": "integrations/even-g2", + "link": true + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -2825,6 +4536,13 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/chardet": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/chardet/-/chardet-2.2.0.tgz", + "integrity": "sha512-rddelWYNPRrXq6PtNEN2S3f6t9ILzvqaN5pVgi4kqt9jHQaXIial9PznB5iSPVlQSLNaaH22ItWz3EJtQ10+OA==", + "dev": true, + "license": "MIT" + }, "node_modules/chownr": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", @@ -2858,6 +4576,16 @@ "node": ">=8" } }, + "node_modules/cli-width": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", + "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 12" + } + }, "node_modules/cliui": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", @@ -2984,6 +4712,21 @@ "node": ">= 8" } }, + "node_modules/crypto-digest-sync": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/crypto-digest-sync/-/crypto-digest-sync-1.0.0.tgz", + "integrity": "sha512-UQBOB5z+HF4iA8shKQ3PPwhCmdFAihwcytD1Qh4uiz78x04cZZmKtZ1F1VyAjkrA8uEZqXt2tMXfj3dJHtcbng==", + "license": "MIT" + }, + "node_modules/crypto-random-hex": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/crypto-random-hex/-/crypto-random-hex-1.0.0.tgz", + "integrity": "sha512-1DuZQ03El13TRgfrqbbjW40Gvi4OKInny/Wxqj23/JMXe214C/3Tlz92bKXWDW3NZT5RjXUGdYW4qiIOUPf+cA==", + "license": "MIT", + "dependencies": { + "array-buffer-to-hex": "^1.0.0" + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -3038,6 +4781,36 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/default-browser": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.5.1.tgz", + "integrity": "sha512-m1pAzaJgZ/gssEqlOhJkPJp8Xly7QyW6xcrkUa2KKcDeDSEMP7X8xipU3snUcfisTQx0w1AGae+9UtJSfVnXGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "bundle-name": "^4.1.0", + "default-browser-id": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/default-browser-id": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-5.0.1.tgz", + "integrity": "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/defer-to-connect": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-2.0.1.tgz", @@ -3067,6 +4840,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/define-lazy-prop": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz", + "integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/define-properties": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", @@ -3096,6 +4882,16 @@ "node": ">=0.4.0" } }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, "node_modules/detect-node": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz", @@ -3426,6 +5222,12 @@ "dev": true, "license": "MIT" }, + "node_modules/encode-utf8": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/encode-utf8/-/encode-utf8-1.0.3.tgz", + "integrity": "sha512-ucAnuBEhUK4boH2HjVYG5Q2mQyPorvv0u/ocS+zhdw0S8AlHYY+GOFhP1Gio5z4icpP2ivFSvhtFjQi8+T9ppw==", + "license": "MIT" + }, "node_modules/end-of-stream": { "version": "1.4.5", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", @@ -3593,6 +5395,23 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-string-truncated-width": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/fast-string-truncated-width/-/fast-string-truncated-width-3.0.3.tgz", + "integrity": "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-string-width": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/fast-string-width/-/fast-string-width-3.0.2.tgz", + "integrity": "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-string-truncated-width": "^3.0.2" + } + }, "node_modules/fast-uri": { "version": "3.1.8", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz", @@ -3610,6 +5429,16 @@ ], "license": "BSD-3-Clause" }, + "node_modules/fast-wrap-ansi": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/fast-wrap-ansi/-/fast-wrap-ansi-0.2.2.tgz", + "integrity": "sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-string-width": "^3.0.2" + } + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -4023,6 +5852,12 @@ "node": ">= 0.4" } }, + "node_modules/hex-to-array-buffer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/hex-to-array-buffer/-/hex-to-array-buffer-1.1.0.tgz", + "integrity": "sha512-vvl3IM8FfT1uOnHtEqyjkDK9Luqz6MQrH82qIvVnjyXxRhkeaEZyRRPiBgf2yym3nweRVEfayxt/1SoTXZYd4Q==", + "license": "MIT" + }, "node_modules/hosted-git-info": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz", @@ -4105,6 +5940,23 @@ "node": ">= 14" } }, + "node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/inflight": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", @@ -4124,6 +5976,49 @@ "dev": true, "license": "ISC" }, + "node_modules/inquirer": { + "version": "13.4.3", + "resolved": "https://registry.npmjs.org/inquirer/-/inquirer-13.4.3.tgz", + "integrity": "sha512-EPd3IqieHSavSOXh+LZhrIkdQcOELWeRblLT6kslQr+cF9XTh/HxZdSt1YkHH1iq4dvqBnV42uwg2YlorgOy6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^2.0.5", + "@inquirer/core": "^11.1.10", + "@inquirer/prompts": "^8.4.3", + "@inquirer/type": "^4.0.5", + "mute-stream": "^3.0.0", + "run-async": "^4.0.6", + "rxjs": "^7.8.2" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/is-docker": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", + "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==", + "dev": true, + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-fullwidth-code-point": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", @@ -4134,6 +6029,54 @@ "node": ">=8" } }, + "node_modules/is-in-ssh": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-in-ssh/-/is-in-ssh-1.0.0.tgz", + "integrity": "sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-inside-container": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", + "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-docker": "^3.0.0" + }, + "bin": { + "is-inside-container": "cli.js" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-wsl": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.1.tgz", + "integrity": "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-inside-container": "^1.0.0" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/isarray": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", @@ -4215,87 +6158,365 @@ "dependencies": { "argparse": "^2.0.1" }, - "bin": { - "js-yaml": "bin/js-yaml.js" + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsbn": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-1.1.0.tgz", + "integrity": "sha512-4bYVV3aAMtDTTu4+xsDYa6sy9GyJ69/amsu9sYF2zqjiEoZA5xJi3BrfX3uY+/IekIu7MwdObdbDWpoZdBv3/A==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stringify-safe": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", + "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", + "dev": true, + "license": "ISC", + "optional": true + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/lazy-val": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/lazy-val/-/lazy-val-1.0.5.tgz", + "integrity": "sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/jsesc": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", - "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], "dev": true, - "license": "MIT", - "bin": { - "jsesc": "bin/jsesc" - }, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, "engines": { - "node": ">=6" + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/json-buffer": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", - "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "MIT" + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } }, - "node_modules/json-stringify-safe": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", - "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "ISC", - "optional": true + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } }, - "node_modules/json5": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", - "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], "dev": true, - "license": "MIT", - "bin": { - "json5": "lib/cli.js" - }, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, "engines": { - "node": ">=6" + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/jsonfile": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", - "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], "dev": true, - "license": "MIT", - "dependencies": { - "universalify": "^2.0.0" + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" }, - "optionalDependencies": { - "graceful-fs": "^4.1.6" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/keyv": { - "version": "4.5.4", - "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", - "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "MIT", - "dependencies": { - "json-buffer": "3.0.1" + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/lazy-val": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/lazy-val/-/lazy-val-1.0.5.tgz", - "integrity": "sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q==", + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], "dev": true, - "license": "MIT" + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } }, "node_modules/lodash": { "version": "4.18.1", @@ -4474,6 +6695,16 @@ "dev": true, "license": "MIT" }, + "node_modules/mute-stream": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-3.0.0.tgz", + "integrity": "sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, "node_modules/nanoid": { "version": "3.3.19", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", @@ -4709,6 +6940,27 @@ "wrappy": "1" } }, + "node_modules/open": { + "version": "11.0.2", + "resolved": "https://registry.npmjs.org/open/-/open-11.0.2.tgz", + "integrity": "sha512-RWqF+pBSkqecEvCKOn8QYhaNdRMJDZRIrlS/7rTDdLHaPcfXGCZ/h8zb413NfvdeAV0MR7T1yJcA34/q+CSm1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "default-browser": "^5.5.1", + "define-lazy-prop": "^3.0.0", + "is-in-ssh": "^1.0.0", + "is-inside-container": "^1.0.0", + "powershell-utils": "^0.2.1", + "wsl-utils": "^1.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/p-cancelable": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-2.1.1.tgz", @@ -4735,6 +6987,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/pad-start": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/pad-start/-/pad-start-1.0.2.tgz", + "integrity": "sha512-EBN8Ez1SVRcZT1XsIE4WkdnZ5coLoaChkIgAET6gIlaLhXqCz9upVk0DQWFtOYkrpTVvbEppRUnqhTiJrBdkfw==", + "license": "MIT" + }, "node_modules/path-is-absolute": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", @@ -4837,9 +7095,9 @@ } }, "node_modules/postcss": { - "version": "8.5.25", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz", - "integrity": "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==", + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", "dev": true, "funding": [ { @@ -4857,7 +7115,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.16", + "nanoid": "^3.3.18", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -4895,6 +7153,19 @@ "node": "^12.20.0 || >=14" } }, + "node_modules/powershell-utils": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/powershell-utils/-/powershell-utils-0.2.1.tgz", + "integrity": "sha512-C+y9x90UElAddDZmV4qOx9W53B61PO7cIqWz2dQsWlwswuq4mr8NEwytdGKboYbQlGZ3awrkTeNvcZiZNHnQ8A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/proc-log": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-6.1.0.tgz", @@ -4979,6 +7250,22 @@ "node": ">=16.0.0" } }, + "node_modules/qr-image": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/qr-image/-/qr-image-3.2.0.tgz", + "integrity": "sha512-rXKDS5Sx3YipVsqmlMJsJsk6jXylEpiHRC2+nJy66fxA5ExYyGa4PqwteW69SaVmAb2OQ18HbYriT7cGQMbduw==", + "dev": true, + "license": "MIT" + }, + "node_modules/qrcode-terminal": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/qrcode-terminal/-/qrcode-terminal-0.12.0.tgz", + "integrity": "sha512-EXtzRZmC+YGmGlDFbXKxQiMZNwCLEO6BANKXG4iCtSIM0yqc/pappSx3RIKr4r0uh5JsBckOXeKrB3Iz7mdQpQ==", + "dev": true, + "bin": { + "qrcode-terminal": "bin/qrcode-terminal.js" + } + }, "node_modules/quick-lru": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz", @@ -5154,6 +7441,47 @@ "node": ">=8.0" } }, + "node_modules/rolldown": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.8.tgz", + "integrity": "sha512-Z67nTmhZe7anqnM/EjI392w5i/ANUinjip7QYsOyN37oayduxt3ksdX0hf5OOamkAd53BiIHfbfSzfUmzKFQqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.149.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.8", + "@rolldown/binding-android-arm64": "1.2.8", + "@rolldown/binding-darwin-arm64": "1.2.8", + "@rolldown/binding-darwin-x64": "1.2.8", + "@rolldown/binding-freebsd-x64": "1.2.8", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.8", + "@rolldown/binding-linux-arm64-gnu": "1.2.8", + "@rolldown/binding-linux-arm64-musl": "1.2.8", + "@rolldown/binding-linux-ppc64-gnu": "1.2.8", + "@rolldown/binding-linux-s390x-gnu": "1.2.8", + "@rolldown/binding-linux-x64-gnu": "1.2.8", + "@rolldown/binding-linux-x64-musl": "1.2.8", + "@rolldown/binding-openharmony-arm64": "1.2.8", + "@rolldown/binding-win32-arm64-msvc": "1.2.8", + "@rolldown/binding-win32-x64-msvc": "1.2.8" + } + }, + "node_modules/rolldown/node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, "node_modules/rollup": { "version": "4.62.4", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.4.tgz", @@ -5200,6 +7528,39 @@ "fsevents": "~2.3.2" } }, + "node_modules/run-applescript": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz", + "integrity": "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/run-async": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/run-async/-/run-async-4.0.6.tgz", + "integrity": "sha512-IoDlSLTs3Yq593mb3ZoKWKXMNu3UpObxhgA/Xuid5p4bbfi2jdY1Hj0m1K+0/tEuQTxIGMhQDqGjKb7RuxGpAQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/rxjs": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, "node_modules/safe-buffer": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", @@ -5207,6 +7568,13 @@ "dev": true, "license": "MIT" }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, "node_modules/sanitize-filename": { "version": "1.6.4", "resolved": "https://registry.npmjs.org/sanitize-filename/-/sanitize-filename-1.6.4.tgz", @@ -5233,6 +7601,21 @@ "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", "license": "MIT" }, + "node_modules/secure-remote-password": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/secure-remote-password/-/secure-remote-password-0.3.1.tgz", + "integrity": "sha512-iEp/qLRfb9XYhfKFrPFfdeD7KVreCjhDKSTRP1G1nRIO0Sw1hjnVHD58ymOhiy9Zf5quHbDIbG9cTupji7qwnA==", + "license": "MIT", + "dependencies": { + "array-buffer-to-hex": "^1.0.0", + "crypto-digest-sync": "^1.0.0", + "crypto-random-hex": "^1.0.0", + "encode-utf8": "^1.0.1", + "hex-to-array-buffer": "^1.1.0", + "jsbn": "^1.1.0", + "pad-start": "^1.0.2" + } + }, "node_modules/semver": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", @@ -6329,6 +8712,36 @@ "dev": true, "license": "ISC" }, + "node_modules/wsl-utils": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-1.0.0.tgz", + "integrity": "sha512-Hl0ZOAs672vg+06kfujwRhoS6/jehvULrlFkuF2dRu6pHgA8U06h3xqNIqNNU1LTXPcedxByAR4GS6pwQK0mgA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-wsl": "^3.1.0", + "powershell-utils": "^0.1.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/wsl-utils/node_modules/powershell-utils": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/powershell-utils/-/powershell-utils-0.1.0.tgz", + "integrity": "sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/xmlbuilder": { "version": "15.1.1", "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz", @@ -6412,6 +8825,16 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zod": { + "version": "4.6.1", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.1.tgz", + "integrity": "sha512-341aRWQsve0rvronKNTqZpjmzdbUDlFuzHaI/XLg/Ej82qffDJRRfBTCuv7+9q/rMjB6LSLyEBnW4InJeMtt/Q==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/package.json b/package.json index eb3068396..5f95142cc 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,7 @@ "type": "module", "scripts": { "dev": "electron-vite dev", - "build": "npm run typecheck && electron-vite build", + "build": "npm run build:speech && npm run build:even && npm run typecheck && electron-vite build", "package": "npm run build && electron-builder --dir", "package:linux": "npm run build && electron-builder --linux AppImage deb --publish never", "prepackage:win": "node scripts/build-windows-agent-pipe-host.mjs --require-windows --self-test", @@ -33,15 +33,23 @@ "test:windows-pipe-host": "node scripts/build-windows-agent-pipe-host.mjs --require-windows --self-test", "test": "node --test --test-concurrency=1 --test-reporter=tap tests/*.test.mjs", "typecheck": "tsc --noEmit -p tsconfig.node.json && tsc --noEmit -p tsconfig.web.json", - "postinstall": "install-electron && electron-rebuild -f -w node-pty && node scripts/build-windows-agent-pipe-host.mjs" + "postinstall": "install-electron && electron-rebuild -f -w node-pty && node scripts/build-windows-agent-pipe-host.mjs", + "build:even": "npm run build --workspace integrations/even-g2", + "test:even": "npm test --workspace integrations/even-g2", + "pack:even:local": "npm run pack --workspace integrations/even-g2 -- --local-development", + "predev": "npm run build:even", + "build:speech": "node scripts/build-companion-speech.mjs", + "pack:even": "npm run pack --workspace integrations/even-g2" }, "dependencies": { "@xterm/addon-fit": "0.11.0", "@xterm/addon-web-links": "0.12.0", + "@xterm/headless": "6.0.0", "@xterm/xterm": "6.0.0", "node-pty": "1.1.0", "react": "19.2.8", "react-dom": "19.2.8", + "secure-remote-password": "0.3.1", "yaml": "2.9.0" }, "devDependencies": { @@ -64,5 +72,8 @@ "esbuild@0.25.12": true, "esbuild@0.28.1": true, "node-pty@1.1.0": true - } + }, + "workspaces": [ + "integrations/even-g2" + ] } diff --git a/scripts/build-companion-speech.mjs b/scripts/build-companion-speech.mjs new file mode 100644 index 000000000..bf1938f04 --- /dev/null +++ b/scripts/build-companion-speech.mjs @@ -0,0 +1,41 @@ +import { mkdir, readFile, writeFile, readdir, copyFile, mkdtemp, rm } from "node:fs/promises"; +import { createHash } from "node:crypto"; +import { join, resolve } from "node:path"; +import { tmpdir } from "node:os"; +import { spawnSync } from "node:child_process"; + +if (process.platform !== "darwin") { console.log("Bundled G2 speech is built on macOS only."); process.exit(0); } +const arch = process.env.CANVASTTY_SPEECH_ARCH || process.arch; +const artifacts = { + arm64: ["macos-arm64-metal", "1cc5e89d442f55c165a3f90e49090cec75cec349071d834c0aa656161afa9543"], + x64: ["macos-x86_64-cpu", "39bb982430f25dcff93e1a1b81555a6cfbd7342f780d2fd13d2e91a78afdda8a"], +}; +if (!artifacts[arch]) throw new Error("Unsupported speech architecture"); +const [lane, digest] = artifacts[arch], version = "0.2.3"; +const output = resolve("artifacts/companion-speech", arch); +const cache = resolve("node_modules/.cache/canvastty-speech"); +await mkdir(output, { recursive: true }); await mkdir(cache, { recursive: true }); +const archive = join(cache, `transcribe-${version}-${lane}.tar.gz`); +const sha = bytes => createHash("sha256").update(bytes).digest("hex"); +let bytes = await readFile(archive).catch(() => null); +if (!bytes || sha(bytes) !== digest) { + const response = await fetch(`https://github.com/handy-computer/transcribe.cpp/releases/download/v${version}/transcribe-native-${version}-${lane}.tar.gz`, { signal: AbortSignal.timeout(60_000) }); + if (!response.ok) throw new Error("Speech runtime download failed"); + bytes = Buffer.from(await response.arrayBuffer()); + if (sha(bytes) !== digest) throw new Error("Speech runtime checksum mismatch"); + await writeFile(archive, bytes); +} +const header = await readFile("src/native/speech/vendor/transcribe.h"); +if (sha(header) !== "07fff3489a3c282ab7ee8835b010f1f9f9abd5b61426f8c0e80424dabaaf9a6b") throw new Error("Pinned speech ABI header changed"); +const temporary = await mkdtemp(join(tmpdir(), "canvas-speech-")); +const run = (command, args) => { const r = spawnSync(command, args, { stdio: "inherit" }); if (r.error) throw r.error; if (r.status) throw new Error(`${command} failed`); }; +try { + run("tar", ["-xzf", archive, "-C", temporary]); + const source = join(temporary, "transcribe-native-" + lane); + for (const file of await readdir(source)) if (file.endsWith(".dylib")) await copyFile(join(source, file), join(output, file)); + run("ditto", [join(source, "licenses"), join(output, "licenses")]); + run("clang++", ["-std=c++17", "-O2", "-arch", arch === "x64" ? "x86_64" : "arm64", "src/native/speech/main.cpp", "-L" + output, "-ltranscribe", "-Wl,-rpath,@executable_path", "-o", join(output, "canvastty-speech")]); + for (const file of await readdir(output)) if (file.endsWith(".dylib") || file === "canvastty-speech") run("codesign", ["--force", "--sign", "-", join(output, file)]); + run(join(output, "canvastty-speech"), ["--version"]); + console.log("Built bundled G2 speech for " + arch); +} finally { await rm(temporary, { recursive: true, force: true }); } diff --git a/src/agent-runtime/hook-helper.mjs b/src/agent-runtime/hook-helper.mjs index 90223bba5..f6d8ab71c 100644 --- a/src/agent-runtime/hook-helper.mjs +++ b/src/agent-runtime/hook-helper.mjs @@ -28,7 +28,9 @@ const turnId = firstString( input?.prompt_id, input?.promptId ); -await reportLifecycle({ state, event, turnId }); +const lastAssistantMessage = event === 'Stop' && typeof input?.last_assistant_message === 'string' + ? input.last_assistant_message.slice(0, 4000) : undefined; +await reportLifecycle({ state, event, turnId, lastAssistantMessage }); function firstString(...values) { return values.find((value) => typeof value === "string" && value.length > 0) ?? null; diff --git a/src/agent-runtime/runtime-client.mjs b/src/agent-runtime/runtime-client.mjs index 22a125501..9225768a2 100644 --- a/src/agent-runtime/runtime-client.mjs +++ b/src/agent-runtime/runtime-client.mjs @@ -8,7 +8,7 @@ import { const CONNECT_TIMEOUT_MS = 1_000; -export async function reportLifecycle({ state, event, turnId = null }) { +export async function reportLifecycle({ state, event, turnId = null, lastAssistantMessage = undefined }) { if (!RUNTIME_STATES.includes(state)) return false; if (typeof event !== "string" || event.length === 0 || event.length > 80) return false; const address = process.env[AGENT_RUNTIME_ENV.address]; @@ -27,6 +27,7 @@ export async function reportLifecycle({ state, event, turnId = null }) { event, turnId: normalizedId(turnId) }; + if(provider==='codex'&&event==='Stop'&&typeof lastAssistantMessage==='string') message.lastAssistantMessage=lastAssistantMessage.slice(0,4000); const payload = Buffer.from(`${JSON.stringify(message)}\n`, "utf8"); if (payload.length > MAX_RUNTIME_MESSAGE_BYTES) return false; diff --git a/src/main/index.ts b/src/main/index.ts index 266394984..c92f5a8d8 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1,3 +1,7 @@ +import { ipcMain } from "electron"; +import { randomUUID } from "node:crypto"; +import { isAbsolute } from "node:path"; +import { EvenG2Controller } from "./services/companion/EvenG2Controller"; import { join } from "node:path"; import { app, BrowserWindow, dialog, net, protocol, safeStorage } from "electron"; import { IPC, type PluginCanvasRequest } from "../shared/contracts"; @@ -47,6 +51,12 @@ import { import { startupPageUrl } from "./startupPage"; import { mainWindowChromeOptions } from "./windowChrome"; +if (process.env.CANVASTTY_USER_DATA_DIR) { + if (!isAbsolute(process.env.CANVASTTY_USER_DATA_DIR)) throw new Error("CANVASTTY_USER_DATA_DIR must be absolute"); + app.setPath("userData", process.env.CANVASTTY_USER_DATA_DIR); + delete process.env.CANVASTTY_USER_DATA_DIR; +} + protocol.registerSchemesAsPrivileged([ { scheme: "canvastty-plugin", @@ -71,6 +81,26 @@ protocol.registerSchemesAsPrivileged([ ]); let mainWindow: BrowserWindow | null = null; +let evenG2: EvenG2Controller | null = null; +const browserRequests = new Map }>(); +ipcMain.on(IPC.evenG2BrowserResponse, (event, response: {requestId?:unknown;ok?:unknown}) => { + if (!mainWindow || event.sender !== mainWindow.webContents || event.senderFrame !== mainWindow.webContents.mainFrame || !response || typeof response.requestId !== "string" || typeof response.ok !== "boolean") return; + const request = browserRequests.get(response.requestId); if (!request) return; + clearTimeout(request.timer); browserRequests.delete(response.requestId); + if (response.ok) request.resolve(); else request.reject(new Error("Browser could not be shown")); +}); +async function showCompanionBrowser():Promise<{title:string;url:string}> { + const window = mainWindow; + if (!window || window.isDestroyed() || window.webContents.isDestroyed() || !browserService) throw new Error("Window unavailable"); + if (browserRequests.size) throw new Error("Browser is opening"); + window.show(); window.focus(); + await new Promise((resolve,reject) => { + const id=randomUUID(), timer=setTimeout(()=>{browserRequests.delete(id);reject(new Error("Browser display timeout"));},12000); + browserRequests.set(id,{resolve,reject,timer});window.webContents.send(IPC.evenG2BrowserRequest,id); + }); + const state=browserService.getState(), tab=state.tabs.find(tab=>tab.id===state.activeTabId); + return {title:tab?.title||"Browser",url:tab?.url||""}; +} let terminalManager: TerminalManager | null = null; let limitsService: LimitsService | null = null; let pluginManager: PluginManager | null = null; @@ -191,7 +221,7 @@ async function initializeServices(): Promise { }, (state) => { browserService?.setCanvasNavigationActive(state.navigationActive); - if (mainWindow && !mainWindow.isDestroyed()) { + if (mainWindow && !mainWindow.isDestroyed() && !mainWindow.webContents.isDestroyed()) { mainWindow.webContents.send(IPC.canvasNavigationOverrideState, state); } } @@ -248,6 +278,7 @@ async function initializeServices(): Promise { state: signal.state, ...(signal.turnId ? { requestId: signal.turnId } : {}) }); + if (signal.lastAssistantMessage !== undefined) evenG2?.answer(terminalSessionId, signal.lastAssistantMessage, signal.turnId); } }); await runtimeGateway.start(); @@ -288,7 +319,8 @@ async function initializeServices(): Promise { } terminalManager = new TerminalManager((channel, payload) => { - if (mainWindow && !mainWindow.isDestroyed()) { + evenG2?.observe(channel, payload); + if (mainWindow && !mainWindow.isDestroyed() && !mainWindow.webContents.isDestroyed()) { mainWindow.webContents.send(channel, payload); } }, providerClis, agentBrowserBridge ?? undefined, agentRuntimeBridge ?? undefined, settings.get().agentLifecycleHooksEnabled); @@ -296,6 +328,21 @@ async function initializeServices(): Promise { terminalManager.configureSessionPersistence(terminalSessionStore, settings.get().restoreTerminalSessions); await terminalManager.restorePersistedSessions(); limitsService = new LimitsService(providerClis, app.getVersion()); + evenG2 = new EvenG2Controller({ + userDataPath, terminals: terminalManager, + localDiscovery: process.platform === "darwin", + defaultWorkspace: join(app.getPath("documents"), "CanvasTTY Projects"), + bundledSpeech: process.platform === "darwin" ? (app.isPackaged ? join(process.resourcesPath, "companion/speech/canvastty-speech") : join(app.getAppPath(), "artifacts/companion-speech", process.arch, "canvastty-speech")) : undefined, + webRoot: app.isPackaged ? join(process.resourcesPath,"even-g2-web") : join(app.getAppPath(),"integrations/even-g2/dist"), + speechWorker: app.isPackaged ? join(process.resourcesPath,"companion/asr_worker.py") : join(app.getAppPath(),"src/main/services/companion/asr_worker.py"), + limits: () => limitsService!.get(), openBrowser: showCompanionBrowser + }); + await evenG2.load(); + const assertCompanionSender = (event: Electron.IpcMainInvokeEvent):void => { + if (!mainWindow || event.sender !== mainWindow.webContents || event.senderFrame !== mainWindow.webContents.mainFrame) throw new Error("Untrusted companion caller"); + }; + ipcMain.handle(IPC.evenG2State, event => { assertCompanionSender(event); return evenG2!.state(); }); + ipcMain.handle(IPC.evenG2Command, async (event, command) => { assertCompanionSender(event); return evenG2!.command(command); }); githubAuth = new GithubAuthService(app.getPath("userData"), undefined, { fetcher: (input, init) => net.fetch(input, init) }); @@ -526,7 +573,7 @@ if (hasSingleInstanceLock) { }); app.on("activate", () => { - if (BrowserWindow.getAllWindows().length === 0) void startApplication(); + if (app.isReady() && !shutdownRunning && BrowserWindow.getAllWindows().length === 0) void startApplication(); }); // The rejected second launch exits silently (the lock is never released), so @@ -567,6 +614,9 @@ app.on("window-all-closed", () => { void IPC.terminalData; async function shutdownServices(): Promise { + for (const request of browserRequests.values()) { clearTimeout(request.timer); request.reject(new Error("App closing")); } + browserRequests.clear(); + await evenG2?.close(); if (terminalManager) await terminalManager.shutdown(); limitsService?.dispose(); if (agentGateway) await Promise.allSettled([agentGateway.close()]); diff --git a/src/main/services/TerminalManager.ts b/src/main/services/TerminalManager.ts index 64196118c..8980c8487 100644 --- a/src/main/services/TerminalManager.ts +++ b/src/main/services/TerminalManager.ts @@ -148,6 +148,22 @@ export class TerminalManager { return [...this.sessions.values()].map((session) => snapshot(session)); } + listMetadata(): SessionMetadata[] { + return [...this.sessions.values()].map(session => structuredClone(session.metadata)); + } + + geometry(id: string): { cols: number; rows: number } { + const session = this.sessions.get(id); + if (!session) throw new Error("Terminal unavailable"); + return { cols: session.cols, rows: session.rows }; + } + + inputChecked(id: string, data: string): boolean { + const session = this.sessions.get(id); + if (!session?.process || session.metadata.exitCode !== null) return false; + return tryPtyOperation(() => session.process!.write(data)); + } + readBuffer(id: string): TerminalBufferSnapshot { const session = this.sessions.get(id); if (!session) throw new Error("Terminal session does not exist."); diff --git a/src/main/services/agent-runtime/RuntimeGateway.ts b/src/main/services/agent-runtime/RuntimeGateway.ts index d85af6c9e..d528b6b6e 100644 --- a/src/main/services/agent-runtime/RuntimeGateway.ts +++ b/src/main/services/agent-runtime/RuntimeGateway.ts @@ -27,6 +27,7 @@ export interface RuntimeLifecycleSignal { state: RuntimeLifecycleState; event: string; turnId: string | null; + lastAssistantMessage?: string; } export interface RuntimeSessionCapability { @@ -51,6 +52,7 @@ interface ParsedLifecycleMessage { state: RuntimeLifecycleState; event: string; turnId: string | null; + lastAssistantMessage?: string; } export interface RuntimeGatewayOptions { @@ -237,6 +239,7 @@ export class RuntimeGateway { event: message.event, turnId: message.turnId }; + if(message.lastAssistantMessage!==undefined)signal.lastAssistantMessage=message.lastAssistantMessage; lease.latest = signal; this.onSignal?.(message.terminalSessionId, signal); } @@ -244,7 +247,7 @@ export class RuntimeGateway { function parseLifecycleMessage(value: unknown): ParsedLifecycleMessage { if (!isRecord(value)) throw new Error("Runtime message must be an object."); - const keys = Object.keys(value).sort(); + const keys = Object.keys(value).filter(key=>key!=='lastAssistantMessage').sort(); const expected = [ "capabilityToken", "event", "provider", "state", "terminalSessionId", "turnId", "type", "v" ].sort(); @@ -269,6 +272,7 @@ function parseLifecycleMessage(value: unknown): ParsedLifecycleMessage { || value.event.length > 80 || (value.turnId !== null && (typeof value.turnId !== "string" || value.turnId.length > 160)) ) throw new Error("Runtime message fields are invalid."); + if(value.lastAssistantMessage!==undefined&&(value.provider!=='codex'||value.event!=='Stop'||value.state!=='idle'||typeof value.lastAssistantMessage!=='string'||value.lastAssistantMessage.length>4000))throw new Error('Invalid final-answer signal'); return value as unknown as ParsedLifecycleMessage; } diff --git a/src/main/services/companion/CompanionSessions.ts b/src/main/services/companion/CompanionSessions.ts new file mode 100644 index 000000000..0c7063bd3 --- /dev/null +++ b/src/main/services/companion/CompanionSessions.ts @@ -0,0 +1,145 @@ +import type { LimitsSnapshot, ProviderId } from "../../../shared/contracts.ts"; +import { + CompanionError, + parseCompanionRequest, + normalizeSessionTitle, + type CompanionAction, + type CompanionGrant, + type CompanionSession, +} from "../../../shared/companion.ts"; +import { RequestLedger } from "./RequestLedger.ts"; +import { SessionAccess } from "./SessionAccess.ts"; + +export interface CompanionView { + body: string; + revision: string; +} + +export interface CompanionHost { + list(): CompanionSession[]; + read(sessionId: string): Promise; + input(sessionId: string, data: string): boolean; + close(sessionId: string): void; + rename(sessionId: string, title: string): CompanionSession; + create(provider: ProviderId): CompanionSession; + openBrowser(): Promise<{ title: string; url: string }>; + limits(): Promise; +} + +function publicSession(session: CompanionSession): CompanionSession { + return { + id: session.id, + provider: session.provider, + title: session.title, + status: session.status, + }; +} + +function publicBrowserUrl(value: string): string { + try { + const url = new URL(value); + if (url.protocol !== "https:" && url.protocol !== "http:") return ""; + return url.origin + url.pathname; + } catch { + return ""; + } +} + +/** Transport-independent operations for an already authenticated device. */ +export class CompanionSessions { + private readonly host: CompanionHost; + private readonly access: SessionAccess; + private readonly ledger: RequestLedger; + + constructor( + host: CompanionHost, + access: SessionAccess, + ledger = new RequestLedger(), + ) { + this.host = host; + this.access = access; + this.ledger = ledger; + } + + async dispatch(deviceId: string, value: unknown): Promise { + const request = parseCompanionRequest(value); + this.authorize(this.access.get(deviceId), request.action); + const result = await this.ledger.run(deviceId, request, async () => { + const grant = this.access.get(deviceId); + this.authorize(grant, request.action); + return this.perform(grant, request.action); + }); + // A receipt does not restore access after the desktop revokes a device. + this.authorize(this.access.get(deviceId), request.action); + return result; + } + + private authorize(grant: CompanionGrant, action: CompanionAction): void { + if ("sessionId" in action) + this.access.assertSession(grant, action.sessionId); + if ( + ((action.type === "session.input" || + action.type === "session.interrupt" || + action.type === "session.rename") && + !grant.allowInput) || + (action.type === "session.create" && !grant.allowCreate) || + (action.type === "session.close" && !grant.allowClose) || + (action.type === "browser.open" && !grant.allowBrowser) + ) { + throw new CompanionError("not-permitted"); + } + } + + private async perform( + grant: CompanionGrant, + action: CompanionAction, + ): Promise { + if (action.type === "sessions.list") { + return this.host + .list() + .filter((session) => grant.sessionIds.includes(session.id)) + .map(publicSession); + } + if (action.type === "limits.read") { + const limits = await this.host.limits(); + this.access.assertCurrent(grant); + return limits; + } + if (action.type === "session.create") { + if (grant.sessionIds.length >= 64) throw new CompanionError("busy"); + const session = this.host.create(action.provider); + this.access.includeCreatedSession(grant, session.id); + return publicSession(session); + } + if (!this.host.list().some((session) => session.id === action.sessionId)) + throw new CompanionError("unavailable"); + if (action.type === "session.read") { + const view = await this.host.read(action.sessionId); + this.access.assertCurrent(grant); + return { body: view.body.slice(-16_000), revision: view.revision }; + } + if (action.type === "browser.open") { + const browser = await this.host.openBrowser(); + this.access.assertCurrent(grant); + return { + title: browser.title.slice(0, 200), + url: publicBrowserUrl(browser.url), + }; + } + if (action.type === "session.rename") + return publicSession( + this.host.rename(action.sessionId, normalizeSessionTitle(action.title)), + ); + if (action.type === "session.close") { + this.host.close(action.sessionId); + return { closed: true, sessionId: action.sessionId }; + } + const data = + action.type === "session.interrupt" + ? "\x03" + : `\x1b[200~${action.text}\x1b[201~\r`; + if (!this.host.input(action.sessionId, data)) + throw new CompanionError("unavailable"); + return { delivered: true, sessionId: action.sessionId }; + } +} diff --git a/src/main/services/companion/EvenG2Controller.ts b/src/main/services/companion/EvenG2Controller.ts new file mode 100644 index 000000000..abc59deac --- /dev/null +++ b/src/main/services/companion/EvenG2Controller.ts @@ -0,0 +1,1064 @@ +import { + createServer, + type Server, + type IncomingMessage, + type ServerResponse, +} from "node:http"; +import { createHash, randomBytes, randomInt } from "node:crypto"; +import { readFile, writeFile, mkdir, rename, stat, rm } from "node:fs/promises"; +import { readFileSync, existsSync } from "node:fs"; +import { join, resolve, sep, extname } from "node:path"; +import { hostname } from "node:os"; +import type { TerminalManager } from "../TerminalManager.ts"; +import type { LimitsSnapshot, ProviderId } from "../../../shared/contracts.ts"; +import type { + CompanionGrant, + CompanionRequest, +} from "../../../shared/companion.ts"; +import type { + EvenG2Config, + EvenG2State, + EvenG2Command, + EvenG2Telemetry, + EvenG2Address, +} from "../../../shared/evenG2.ts"; +import { normalizeSessionTitle } from "../../../shared/companion.ts"; +import { SessionAccess } from "./SessionAccess.ts"; +import { RequestLedger } from "./RequestLedger.ts"; +import { CompanionSessions } from "./CompanionSessions.ts"; +import { TerminalPresentation } from "./TerminalPresentation.ts"; +import { SpeechRecognizer } from "./SpeechRecognizer.ts"; +import { lanAddresses, httpsOrigin, addressOrigin } from "./LanNetwork.ts"; +import { LocalLink } from "./LocalLink.ts"; +import { + sealLocal, + LOCAL_LINK_LIMIT, + type LocalPacket, + type LocalRequest, +} from "../../../shared/localLink.ts"; +import { LocalPairing } from "./LocalPairing.ts"; +import { LocalDiscovery } from "./LocalDiscovery.ts"; +import { SpeechSetup } from "./SpeechSetup.ts"; + +type Terminals = Pick< + TerminalManager, + | "listMetadata" + | "geometry" + | "readBuffer" + | "create" + | "dispose" + | "rename" + | "inputChecked" +>; +type SpeechPort = Pick< + SpeechRecognizer, + | "configure" + | "inspect" + | "available" + | "model" + | "cancel" + | "cancelAll" + | "run" +>; +type StoredPeer = { + id: string; + name: string; + tokenHash: string; + grant: CompanionGrant; +}; +const MODEL = + "handy-computer/nemotron-3.5-asr-streaming-0.6b-gguf/nemotron-3.5-asr-streaming-0.6b-Q8_0.gguf"; +const defaultConfig = (): EvenG2Config => ({ + enabled: false, + workspace: "", + interfaceName: "", + publicOrigin: "", + sessionIds: [], + allowInput: true, + allowCreate: true, + allowClose: false, + allowBrowser: true, + speechExecutable: + process.platform === "darwin" && + existsSync("/Applications/Handy.app/Contents/MacOS/handy") + ? "/Applications/Handy.app/Contents/MacOS/handy" + : "", + speechModel: MODEL, +}); +const hash = (value: string) => + createHash("sha256").update(value).digest("hex"); +const safeSession = (s: ReturnType[number]) => ({ + id: s.id, + title: s.title, + status: s.status, + provider: s.provider, +}); + +export class EvenG2Controller { + private config = defaultConfig(); + private readonly file: string; + private readonly terminals: Terminals; + private readonly webRoot: string; + private readonly discover: () => EvenG2Address[]; + private addresses: EvenG2Address[] = []; + private boundAddress = ""; + private closing = false; + private readonly speech: SpeechPort; + private readonly localLink: LocalLink; + private readonly localName: string; + private readonly discovery: LocalDiscovery | null; + private readonly defaultWorkspace: string; + private readonly speechSetup: SpeechSetup | null; + private extraServers = new Map(); + private readonly access = new SessionAccess(); + private readonly ledger = new RequestLedger(); + readonly presentation: TerminalPresentation; + private readonly actions: CompanionSessions; + private peers: StoredPeer[] = []; + private seen = new Map< + string, + { lastSeen: number; telemetry: EvenG2Telemetry | null } + >(); + private pairing: { + code: string; + expiresAt: number; + attempts: number; + local: LocalPairing; + pending: StoredPeer | null; + } | null = null; + private server: Server | null = null; + private timer: ReturnType | null = null; + private port: number; + private error = ""; + private saveQueue = Promise.resolve(); + private commandBusy = false; + private pairingDiagnostics: string[] = []; + private diagnosticsWrite = Promise.resolve(); + private limits: () => Promise; + + constructor(options: { + userDataPath: string; + terminals: Terminals; + webRoot: string; + speechWorker: string; + speech?: SpeechPort; + limits: () => Promise; + openBrowser: () => Promise<{ title: string; url: string }>; + port?: number; + addresses?: () => EvenG2Address[]; + defaultWorkspace?: string; + bundledSpeech?: string; + localHostname?: string; + localDiscovery?: boolean; + }) { + this.file = join(options.userDataPath, "even-g2.json"); + this.localLink = new LocalLink(options.userDataPath); + this.discovery = options.localDiscovery ? new LocalDiscovery() : null; + this.localName = + options.localHostname || hostname().split(".")[0] + ".local"; + this.defaultWorkspace = + options.defaultWorkspace || join(options.userDataPath, "projects"); + this.speechSetup = options.bundledSpeech + ? new SpeechSetup({ + userDataPath: options.userDataPath, + binary: options.bundledSpeech, + }) + : null; + this.terminals = options.terminals; + this.webRoot = options.webRoot; + this.port = options.port ?? 3481; + this.discover = options.addresses ?? lanAddresses; + this.speech = options.speech ?? new SpeechRecognizer(options.speechWorker); + this.limits = options.limits; + this.presentation = new TerminalPresentation(this.terminals); + this.actions = new CompanionSessions( + { + list: () => this.terminals.listMetadata().map(safeSession), + read: (id) => this.presentation.read(id), + input: (id, data) => { + const written = this.terminals.inputChecked(id, data); + if (written) this.presentation.pending(id); + return written; + }, + close: (id) => this.terminals.dispose(id), + rename: (id, title) => safeSession(this.terminals.rename(id, title)), + create: (provider) => { + if (!this.config.workspace) throw new Error("workspace-required"); + return safeSession( + this.terminals.create({ + provider, + profile: "normal", + cwd: this.config.workspace, + position: { + x: 1600, + y: this.terminals.listMetadata().length * 470, + }, + }), + ); + }, + openBrowser: options.openBrowser, + limits: options.limits, + }, + this.access, + this.ledger, + ); + } + async load(): Promise { + await this.localLink.load(); + await this.speechSetup?.inspect(); + this.addresses = this.discover(); + try { + const value = JSON.parse(await readFile(this.file, "utf8")); + // A previous loopback/ADB grant does not silently enable a LAN listener. + if ( + value.config && + ("adbPath" in value.config || "selectedSerial" in value.config) + ) { + value.config.enabled = false; + value.peers = []; + } + this.config = await this.validateConfig({ + ...defaultConfig(), + ...value.config, + }); + if (Array.isArray(value.peers)) + for (const peer of value.peers.slice(0, 8)) { + if ( + typeof peer.id === "string" && + /^[a-f0-9]{32}$/.test(peer.id) && + /^[a-f0-9]{64}$/.test(peer.tokenHash) && + Array.isArray(peer.grant?.sessionIds) + ) { + const grant = this.access.share({ + deviceId: peer.id, + sessionIds: peer.grant.sessionIds + .filter((id: unknown) => typeof id === "string") + .slice(0, 64), + allowInput: peer.grant.allowInput === true, + allowCreate: peer.grant.allowCreate === true, + allowClose: peer.grant.allowClose === true, + allowBrowser: peer.grant.allowBrowser === true, + }); + this.peers.push({ + id: peer.id, + name: String(peer.name || "Even App").slice(0, 80), + tokenHash: peer.tokenHash, + grant, + }); + } + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") + this.error = "settings-unavailable"; + } + if (!this.config.workspace) this.config.workspace = this.defaultWorkspace; + this.speech.configure( + this.config.speechExecutable, + this.config.speechModel, + ); + if (this.config.enabled) { + try { + await this.start(); + } catch { + await this.stop(); + this.error = "listener-unavailable"; + } + } + } + observe(channel: string, payload: unknown): void { + if (this.config.enabled) this.presentation.observe(channel, payload); + } + answer(id: string, text: string, turnId: string | null): void { + if (this.config.enabled) this.presentation.answer(id, text, turnId); + } + private async validateConfig(value: EvenG2Config): Promise { + for (const key of [ + "enabled", + "allowInput", + "allowCreate", + "allowClose", + "allowBrowser", + ] as const) + if (typeof value[key] !== "boolean") throw new Error("invalid-settings"); + for (const key of [ + "workspace", + "interfaceName", + "publicOrigin", + "speechExecutable", + "speechModel", + ] as const) + if ( + typeof value[key] !== "string" || + value[key].length > 4096 || + /[\x00-\x1f]/.test(value[key]) + ) + throw new Error("invalid-settings"); + if ( + !Array.isArray(value.sessionIds) || + value.sessionIds.length > 64 || + value.sessionIds.some( + (id) => typeof id !== "string" || !id || id.length > 128, + ) + ) + throw new Error("invalid-settings"); + if (value.workspace === this.defaultWorkspace) + await mkdir(value.workspace, { recursive: true }); + if (value.workspace && !(await stat(value.workspace)).isDirectory()) + throw new Error("workspace-unavailable"); + return { + enabled: value.enabled, + workspace: value.workspace ? resolve(value.workspace) : "", + interfaceName: value.interfaceName, + publicOrigin: httpsOrigin(value.publicOrigin), + sessionIds: [...new Set(value.sessionIds)], + allowInput: value.allowInput, + allowCreate: value.allowCreate, + allowClose: value.allowClose, + allowBrowser: value.allowBrowser, + speechExecutable: value.speechExecutable, + speechModel: value.speechModel, + }; + } + private grant(id: string, ids = this.config.sessionIds): CompanionGrant { + return this.access.share({ + deviceId: id, + sessionIds: ids, + allowInput: this.config.allowInput, + allowCreate: this.config.allowCreate, + allowClose: this.config.allowClose, + allowBrowser: this.config.allowBrowser, + }); + } + private save(): Promise { + const payload = JSON.stringify( + { + version: 1, + config: this.config, + peers: this.peers.map((p) => ({ ...p, grant: this.access.get(p.id) })), + }, + null, + 2, + ); + const operation = this.saveQueue.then(async () => { + await mkdir(resolve(this.file, ".."), { recursive: true, mode: 0o700 }); + const temporary = + this.file + "." + randomBytes(8).toString("hex") + ".tmp"; + try { + await writeFile(temporary, payload, { mode: 0o600, flag: "wx" }); + await rename(temporary, this.file); + } finally { + await rm(temporary, { force: true }); + } + }); + this.saveQueue = operation.catch(() => undefined); + return operation; + } + state(): EvenG2State { + if (this.pairing && Date.now() > this.pairing.expiresAt) + this.pairing = null; + return { + config: structuredClone(this.config), + availableSessions: this.terminals.listMetadata().map(safeSession), + peers: this.peers.map((p) => ({ + id: p.id, + name: p.name, + grant: this.access.get(p.id), + ...(this.seen.get(p.id) || { lastSeen: 0, telemetry: null }), + })), + pairing: this.pairing + ? { + code: this.pairing.code, + expiresAt: this.pairing.expiresAt, + pending: this.pairing.pending + ? { id: this.pairing.pending.id, name: this.pairing.pending.name } + : null, + } + : null, + transport: { + kind: this.config.publicOrigin ? "https" : "lan", + addresses: structuredClone(this.addresses), + origin: this.boundAddress + ? this.config.publicOrigin || + addressOrigin(this.boundAddress, this.port) + : "", + origins: this.localOrigins(), + ready: !!this.server?.listening, + }, + listening: !!this.server?.listening, + port: this.port, + error: this.error, + speechSetup: this.speechSetup?.state(), + speech: { available: this.speech.available, model: this.speech.model }, + }; + } + async command(command: EvenG2Command): Promise { + if (!command || typeof command !== "object") + throw new Error("invalid-command"); + if (this.commandBusy) throw new Error("operation-pending"); + this.commandBusy = true; + try { + if (command.type === "configure") { + const config = await this.validateConfig(command.config); + this.speech.cancelAll(); + this.config = config; + this.pairing = null; + for (const peer of this.peers) peer.grant = this.grant(peer.id); + this.speech.configure(config.speechExecutable, config.speechModel); + if (config.enabled) await this.start(); + else await this.stop(); + await this.save(); + } else if (command.type === "prepare-speech") { + if (!this.speechSetup) throw new Error("bundled-speech-unavailable"); + const current = this.config.speechExecutable; + void this.speechSetup + .prepare() + .then(async () => { + if (this.closing || this.config.speechExecutable !== current) + return; + this.config.speechExecutable = this.speechSetup!.binary; + this.config.speechModel = this.speechSetup!.modelPath; + this.speech.configure( + this.config.speechExecutable, + this.config.speechModel, + ); + await this.speech.inspect(); + await this.save(); + }) + .catch(() => {}); + } else if (command.type === "cancel-speech-setup") { + this.speechSetup?.cancel(); + } else if (command.type === "refresh") { + this.addresses = this.discover(); + if (this.config.enabled) await this.reconcileNetwork(); + } else if (command.type === "begin-pairing") { + if (!this.config.enabled || !this.server?.listening) + throw new Error("enable-first"); + if ( + !this.config.sessionIds.length && + !(this.config.allowCreate && this.config.workspace) + ) + throw new Error("choose-sessions"); + if (this.peers.length >= 8) throw new Error("device-limit"); + if (this.discovery && !this.discovery.host) throw new Error("local-discovery-unavailable"); + const code = randomInt(0, 1_000_000).toString().padStart(6, "0"); + const expiresAt = Date.now() + 120000; + this.pairing = { + code, expiresAt, attempts: 0, + local: new LocalPairing(code, expiresAt), + pending: null, + }; + } else if (command.type === "cancel-pairing" || command.type === "reject") + this.pairing = null; + else if (command.type === "approve") { + const pending = this.pairing?.pending; + if ( + !pending || + pending.id !== command.id || + this.pairing!.expiresAt < Date.now() + ) + throw new Error("pairing-expired"); + pending.grant = this.grant(pending.id); + this.peers.push(pending); + this.pairing = null; + try { + await this.save(); + } catch (error) { + this.access.revoke(pending.id); + this.peers = this.peers.filter((peer) => peer.id !== pending.id); + throw error; + } + } else if (command.type === "revoke") { + this.access.revoke(command.id); + this.ledger.forgetDevice(command.id); + this.peers = this.peers.filter((p) => p.id !== command.id); + this.seen.delete(command.id); + await this.save(); + } else throw new Error("unknown-command"); + if (!this.config.enabled || this.server?.listening) this.error = ""; + return this.state(); + } finally { + this.commandBusy = false; + } + } + private async start(): Promise { + await this.reconcileNetwork(); + await this.speech.inspect(); + if (!this.timer) + this.timer = setInterval(() => { + if (this.commandBusy || this.closing) return; + this.commandBusy = true; + void this.reconcileNetwork() + .catch(() => { + this.error = "listener-unavailable"; + }) + .finally(() => { + this.commandBusy = false; + }); + }, 5000); + } + private async reconcileNetwork(): Promise { + this.addresses = this.discover(); + const selected = this.config.publicOrigin + ? { name: "loopback", address: "127.0.0.1" } + : this.config.interfaceName + ? this.addresses.find( + (address) => address.name === this.config.interfaceName, + ) + : this.addresses[0]; + const targets = selected + ? this.config.publicOrigin + ? [selected.address] + : [ + ...new Set( + this.addresses + .filter((a) => a.name === selected.name) + .map((a) => a.address), + ), + ].slice(0, 6) + : []; + const old = this.boundAddress + ? [this.boundAddress, ...this.extraServers.keys()] + : []; + if (JSON.stringify(targets) !== JSON.stringify(old)) + await this.closeListener(); + if (!selected || this.closing) { + this.error = "network-unavailable"; + return; + } + if (this.server?.listening) { + if (!this.config.publicOrigin && this.discovery && !this.discovery.host) + await this.discovery.start(targets.find(a => !a.includes(":")) || this.boundAddress, selected.name, this.port); + return; + } + try { + for (const address of targets) { + const server = createServer((req, res) => { + void this.handle(req, res, address).catch(() => { + if (!res.headersSent) + this.json(res, 409, { error: "request-failed" }); + else res.destroy(); + }); + }); + server.requestTimeout = 70000; + server.headersTimeout = 10000; + try { + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(this.port, address, () => { + server.off("error", reject); + resolve(); + }); + }); + } catch (error) { + server.close(); + throw error; + } + server.on("error", () => { + this.error = "listener-failed"; + }); + if (!this.server) { + this.server = server; + this.boundAddress = address; + this.port = (server.address() as { port: number }).port; + } else this.extraServers.set(address, server); + } + if (!this.config.publicOrigin && this.discovery && !this.closing) + await this.discovery.start(targets.find(a => !a.includes(":")) || this.boundAddress, selected.name, this.port); + if (this.closing) await this.closeListener(); + this.error = ""; + } catch (error) { + await this.closeListener(); + throw error; + } + } + private localOrigins(): string[] { + if (!this.boundAddress || this.config.publicOrigin) return []; + if (this.discovery?.host) return [`http://${this.discovery.host}:${this.port}`]; + const origins = [this.boundAddress, ...this.extraServers.keys()].map((a) => + addressOrigin(a, this.port), + ); + if (/^[a-z0-9][a-z0-9-]*\.local$/i.test(this.localName)) + origins.push(`http://${this.localName}:${this.port}`); + return origins; + } + private async closeListener(): Promise { + this.discovery?.stop(); + this.pairing = null; + this.speech.cancelAll(); + const servers = [this.server, ...this.extraServers.values()].filter( + (s): s is Server => !!s, + ); + this.server = null; + this.boundAddress = ""; + this.extraServers.clear(); + await Promise.all( + servers.map( + (server) => + new Promise((resolve) => { + server.close(() => resolve()); + server.closeAllConnections(); + }), + ), + ); + } + private async stop(): Promise { + this.pairing = null; + this.speech.cancelAll(); + if (this.timer) clearInterval(this.timer); + this.timer = null; + await this.closeListener(); + this.presentation.close(); + } + async close(): Promise { + this.closing = true; + this.speechSetup?.cancel(); + await this.stop(); + this.presentation.close(); + await this.saveQueue; + } + private json(res: ServerResponse, status: number, value: unknown): void { + res.writeHead(status, { + "Content-Type": "application/json; charset=utf-8", + "Cache-Control": "no-store", + }); + res.end(JSON.stringify(value)); + } + private async body( + req: IncomingMessage, + limit = 1_310_720, + ): Promise> { + if (!req.headers["content-type"]?.startsWith("application/json")) + throw new Error("json-required"); + const parts: Buffer[] = []; + let length = 0; + for await (const raw of req) { + const part = Buffer.from(raw); + length += part.length; + if (length > limit) throw new Error("too-large"); + parts.push(part); + } + const value = JSON.parse(Buffer.concat(parts).toString()); + if (!value || typeof value !== "object" || Array.isArray(value)) + throw new Error("invalid-body"); + return value; + } + private request( + data: Record, + action: CompanionRequest["action"], + ): CompanionRequest { + return { + version: 1, + id: + typeof data.requestId === "string" + ? data.requestId + : randomBytes(16).toString("hex"), + sentAt: typeof data.sentAt === "number" ? data.sentAt : Date.now(), + action, + }; + } + private async handle( + req: IncomingMessage, + res: ServerResponse, + address = this.boundAddress, + ): Promise { + const url = new URL(req.url || "/", "http://127.0.0.1"); + if (["/g2/discover", "/g2/pair-start", "/g2/pair-finish"].includes(url.pathname)) { + const active = !!this.pairing && this.pairing.expiresAt > Date.now(); + res.once("finish", () => { + const line = `${new Date().toISOString()} ${req.method} ${url.pathname} HTTP ${res.statusCode} active=${active} family=${address.includes(":") ? "IPv6" : "IPv4"}`; + this.pairingDiagnostics = [...this.pairingDiagnostics, line].slice(-64); + const text = this.pairingDiagnostics.join("\n") + "\n"; + this.diagnosticsWrite = this.diagnosticsWrite.catch(() => {}).then(() => + writeFile(join(this.file, "..", "even-g2-pairing.log"), text, { mode: 0o600 })).catch(() => {}); + }); + } + const expectedHost = new URL(addressOrigin(address, this.port)).host; + const localHost = this.localName.toLowerCase() + ":" + this.port; + if ( + req.headers.host?.toLowerCase() !== expectedHost && + (this.config.publicOrigin || + req.headers.host?.toLowerCase() !== localHost && + req.headers.host?.toLowerCase() !== `${this.discovery?.host}:${this.port}`) + ) + return this.json(res, 403, { error: "invalid-host" }); + res.setHeader("Access-Control-Allow-Origin", "*"); + res.setHeader("Referrer-Policy", "no-referrer"); + res.setHeader("X-Content-Type-Options", "nosniff"); + if (req.method === "OPTIONS") { + res.writeHead(204, { + "Access-Control-Allow-Headers": "Authorization,Content-Type", + "Access-Control-Allow-Methods": "GET,POST,OPTIONS", + }); + res.end(); + return; + } + if (!this.config.publicOrigin && url.pathname === "/g2/discover" && req.method === "GET") { + return this.json(res, 200, { type: "canvastty-local", version: 2, + pairing: !!this.pairing && this.pairing.expiresAt > Date.now() && !this.pairing.pending }); + } + if (!this.config.publicOrigin && req.method === "POST" && + ["/g2/pair-start", "/g2/pair-finish"].includes(url.pathname)) { + const pair = this.pairing; + if (!pair || pair.expiresAt <= Date.now() || pair.pending) + return this.json(res, 403, { error: "pairing-unavailable" }); + const data = await this.body(req, 8192); + if (this.pairing !== pair) return this.json(res, 403, { error: "pairing-unavailable" }); + try { + if (url.pathname === "/g2/pair-start") + return this.json(res, 200, pair.local.start(data.public)); + const session = pair.local.finish(data.id, data.proof); + const connection = this.localLink.connection(this.localOrigins()); + const packet = await sealLocal({ version: 1, computer: String(data.id), + key: session.key, origins: connection.origins }, { connection, code: pair.code }, "response"); + if (this.pairing !== pair || pair.expiresAt <= Date.now()) + return this.json(res, 403, { error: "pairing-unavailable" }); + return this.json(res, 200, { proof: session.proof, packet }); + } catch { return this.json(res, 403, { error: "pairing-unavailable" }); } + } + if ( + req.method === "POST" && + url.pathname === "/g2/link" && + !this.config.publicOrigin + ) { + const packet = await this.body(req, LOCAL_LINK_LIMIT + 1024); + const result = await this.localLink.receive( + packet as unknown as LocalPacket, + async (request: LocalRequest) => { + if (!this.config.enabled || !this.server?.listening) + throw new Error("integration-disabled"); + const response = await fetch( + addressOrigin(this.boundAddress, this.port) + request.path, + { + method: request.method, + headers: { + "Content-Type": "application/json", + Authorization: "Bearer " + request.token, + }, + body: + request.method === "POST" + ? JSON.stringify(request.body) + : undefined, + redirect: "error", + signal: AbortSignal.timeout(65_000), + }, + ); + return { status: response.status, body: await response.json() }; + }, + ); + return this.json(res, 200, result); + } + if ( + req.method === "GET" && + url.pathname.startsWith("/g2/") && + !url.pathname.startsWith("/g2/api/") + ) { + const root = resolve(this.webRoot), + path = resolve(root, url.pathname.slice(4) || "index.html"); + if (!path.startsWith(root + sep) || !existsSync(path)) + return this.json(res, 404, { error: "not-found" }); + const types: Record = { + ".html": "text/html; charset=utf-8", + ".js": "text/javascript", + ".css": "text/css", + ".png": "image/png", + ".svg": "image/svg+xml", + ".woff2": "font/woff2", + ".woff": "font/woff", + }; + res.writeHead(200, { + "Content-Type": types[extname(path)] || "application/octet-stream", + }); + res.end(readFileSync(path)); + return; + } + if (req.method === "POST" && url.pathname === "/g2/api/pair") { + const data = await this.body(req), + pair = this.pairing; + if ( + !pair || + pair.expiresAt < Date.now() || + pair.pending || + ++pair.attempts > 10 || + data.code !== pair.code + ) + return this.json(res, 403, { error: "pairing-unavailable" }); + const token = randomBytes(32).toString("hex"), + id = randomBytes(16).toString("hex"); + pair.pending = { + id, + name: + typeof data.name === "string" ? data.name.slice(0, 80) : "Even App", + tokenHash: hash(token), + grant: { + deviceId: id, + revision: 0, + sessionIds: [], + allowInput: false, + allowCreate: false, + allowClose: false, + allowBrowser: false, + }, + }; + return this.json(res, 202, { token, id, state: "pending" }); + } + const token = req.headers.authorization?.startsWith("Bearer ") + ? req.headers.authorization.slice(7) + : ""; + if (!/^[a-f0-9]{64}$/.test(token)) + return this.json(res, 401, { error: "unauthorized" }); + const tokenHash = hash(token), + peer = this.peers.find((p) => p.tokenHash === tokenHash); + if (url.pathname === "/g2/api/pair-status" && req.method === "GET") + return this.json(res, 200, { + state: peer + ? "approved" + : this.pairing && + this.pairing.expiresAt > Date.now() && + this.pairing.pending?.tokenHash === tokenHash + ? "pending" + : "rejected", + }); + if (!peer) return this.json(res, 401, { error: "unauthorized" }); + const existing = this.seen.get(peer.id); + this.seen.set(peer.id, { + lastSeen: Date.now(), + telemetry: existing?.telemetry || null, + }); + const grant = this.access.get(peer.id); + if (req.method === "GET" && url.pathname === "/g2/api/home") { + const sessions = await this.actions.dispatch( + peer.id, + this.request({}, { type: "sessions.list" }), + ); + const limits = await this.limits().catch(() => null); + this.access.assertCurrent(grant); + return this.json(res, 200, { + sessions, + limits, + localOrigins: this.localOrigins(), + speechAvailable: this.speech.available && grant.allowInput, + speechModel: this.speech.model, + computerName: hostname(), + features: { + terminalChoices: grant.allowInput, + manualInput: grant.allowInput, + sessionClose: grant.allowClose, + sessionRename: grant.allowInput, + projectBrowser: grant.allowBrowser, + sessionCreate: grant.allowCreate, + }, + }); + } + if (req.method === "GET" && url.pathname === "/g2/api/terminal") { + const id = url.searchParams.get("id") || ""; + this.access.assertSession(grant, id); + const view = await this.presentation.read(id); + this.access.assertCurrent(grant); + const session = this.terminals.listMetadata().find((s) => s.id === id); + if (!session) return this.json(res, 404, { error: "not-found" }); + return this.json(res, 200, { session: safeSession(session), ...view }); + } + if (req.method !== "POST") + return this.json(res, 404, { error: "not-found" }); + const data = await this.body(req), + id = typeof data.sessionId === "string" ? data.sessionId : ""; + if ( + !["/g2/api/device-state", "/g2/api/cancel"].includes(url.pathname) && + (typeof data.requestId !== "string" || + !/^[a-f0-9]{32}$/.test(data.requestId) || + typeof data.sentAt !== "number" || + !Number.isSafeInteger(data.sentAt) || + data.sentAt < Date.now() - 120000 || + data.sentAt > Date.now() + 30000) + ) + return this.json(res, 400, { error: "invalid-request" }); + if (url.pathname === "/g2/api/device-state") { + const telemetry: EvenG2Telemetry = { + clientVersion: String(data.clientVersion || "").slice(0, 24), + display: data.display === "confirmed" ? "confirmed" : "unknown", + microphone: ["never", "on", "off", "unknown"].includes( + String(data.microphone), + ) + ? (data.microphone as EvenG2Telemetry["microphone"]) + : "unknown", + audioBytes: + typeof data.audioBytes === "number" && + Number.isSafeInteger(data.audioBytes) + ? Math.max(0, Math.min(960000, data.audioBytes)) + : 0, + error: typeof data.error === "string" ? data.error.slice(0, 200) : "", + diagnostics: Array.isArray(data.diagnostics) + ? data.diagnostics + .filter((line): line is string => typeof line === "string") + .slice(-24) + .map((line) => line.slice(0, 180)) + : [], + }; + this.seen.set(peer.id, { lastSeen: Date.now(), telemetry }); + return this.json(res, 200, { accepted: true }); + } + if (url.pathname === "/g2/api/create") { + const existingIds = new Set( + this.terminals.listMetadata().map((session) => session.id), + ); + const remaining = grant.sessionIds.filter((sessionId) => + existingIds.has(sessionId), + ); + if (remaining.length !== grant.sessionIds.length) + this.access.share({ ...grant, sessionIds: remaining }); + const result = await this.actions.dispatch( + peer.id, + this.request(data, { + type: "session.create", + provider: data.provider as ProviderId, + }), + ); + await this.save(); + return this.json(res, 201, { session: result }); + } + if (url.pathname === "/g2/api/session-rename") + return this.json(res, 200, { + session: await this.actions.dispatch( + peer.id, + this.request(data, { + type: "session.rename", + sessionId: id, + title: data.title as string, + }), + ), + }); + if (url.pathname === "/g2/api/session-close") + return this.json( + res, + 200, + await this.actions.dispatch( + peer.id, + this.request(data, { type: "session.close", sessionId: id }), + ), + ); + if (url.pathname === "/g2/api/browser") + return this.json(res, 200, { + opened: true, + ...((await this.actions.dispatch( + peer.id, + this.request(data, { type: "browser.open", sessionId: id }), + )) as object), + }); + if (url.pathname === "/g2/api/cancel") { + if (typeof data.requestId === "string") + this.speech.cancel(hash(peer.id + ":" + data.requestId)); + return this.json(res, 200, { cancelled: true }); + } + if ( + typeof data.requestId !== "string" || + !/^[a-f0-9]{32}$/.test(data.requestId) || + typeof data.sentAt !== "number" || + !Number.isSafeInteger(data.sentAt) || + data.sentAt < Date.now() - 120000 || + data.sentAt > Date.now() + 30000 + ) + return this.json(res, 400, { error: "invalid-request" }); + this.access.assertSession(grant, id); + if (!grant.allowInput) + return this.json(res, 403, { error: "not-permitted" }); + if (url.pathname === "/g2/api/voice") { + if ( + data.purpose !== undefined && + data.purpose !== "input" && + data.purpose !== "rename" + ) + return this.json(res, 400, { error: "invalid-purpose" }); + const purpose = data.purpose === "rename" ? "rename" : "input"; + const voiceRequest = { + ...this.request(data, { type: "session.read", sessionId: id }), + action: { + type: "session.voice", + sessionId: id, + purpose, + audio: data.audio, + }, + }; + const result = await this.ledger.run(peer.id, voiceRequest, () => + this.speech.run( + { ...data, requestId: hash(peer.id + ":" + data.requestId) }, + async (text, cancelled) => { + if (purpose === "rename") { + if (cancelled()) return false; + this.access.assertCurrent(grant); + if ( + !this.terminals + .listMetadata() + .some((session) => session.id === id) + ) + throw new Error("terminal-closed"); + normalizeSessionTitle(text); + return true; // Preview only; a separate confirmed rename request changes metadata. + } + const view = await this.presentation.read(id); + if (cancelled()) return false; + if (view.interaction) throw new Error("menu-open"); + this.access.assertCurrent(grant); + if (/[\x00-\x08\x0b-\x1f\x7f-\x9f]/.test(text)) + throw new Error("invalid-transcript"); + if ( + !this.terminals.inputChecked( + id, + "\x1b[200~" + text + "\x1b[201~\r", + ) + ) + throw new Error("terminal-closed"); + this.presentation.pending(id); + return true; + }, + ), + ); + return this.json(res, 200, result); + } + if (url.pathname === "/g2/api/control") { + if ( + data.action === "text" && + (await this.presentation.read(id)).interaction + ) + return this.json(res, 409, { error: "menu-open" }); + if (data.action === "text") + return this.json(res, 200, { + accepted: true, + ...((await this.actions.dispatch( + peer.id, + this.request(data, { + type: "session.input", + sessionId: id, + text: data.text as string, + }), + )) as object), + }); + if (data.action !== "choose" && data.action !== "custom") + return this.json(res, 400, { error: "invalid-action" }); + const request = { + ...this.request(data, { type: "session.read", sessionId: id }), + action: { + type: "session.choose", + sessionId: id, + menuId: data.menuId, + index: data.index, + custom: data.action === "custom", + }, + }; + const result = await this.ledger.run(peer.id, request, async () => { + const choice = await this.presentation.choice( + id, + String(data.menuId), + Number(data.index), + data.action === "custom", + ); + this.access.assertCurrent(grant); + if (!this.terminals.inputChecked(id, choice.data)) + throw new Error("terminal-closed"); + choice.commit(); + return { accepted: true }; + }); + return this.json(res, 200, result); + } + return this.json(res, 404, { error: "not-found" }); + } +} diff --git a/src/main/services/companion/LanNetwork.ts b/src/main/services/companion/LanNetwork.ts new file mode 100644 index 000000000..3a22ada38 --- /dev/null +++ b/src/main/services/companion/LanNetwork.ts @@ -0,0 +1,66 @@ +import { networkInterfaces, type NetworkInterfaceInfo } from "node:os"; +import { isIP } from "node:net"; +import type { EvenG2Address } from "../../../shared/evenG2.ts"; + +export function isPrivateIpv4(address: string): boolean { + if (isIP(address) !== 4) return false; + const [a, b] = address.split(".").map(Number); + return ( + a === 10 || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168) + ); +} +export function isPrivateIpv6(address: string): boolean { + return isIP(address) === 6 && /^f[cd]/i.test(address); +} +export function addressOrigin(address: string, port: number): string { + return `http://${address.includes(":") ? `[${address}]` : address}:${port}`; +} + +/** Offer explicit private interfaces; never listen on all interfaces or a public IP. */ +export function lanAddresses( + interfaces: NodeJS.Dict = networkInterfaces(), +): EvenG2Address[] { + return Object.entries(interfaces) + .flatMap(([name, entries]) => + (entries || []) + .filter( + (entry) => + !entry.internal && + (isPrivateIpv4(entry.address) || isPrivateIpv6(entry.address)), + ) + .map((entry) => ({ + id: name + ":" + entry.address, + name, + address: entry.address, + })), + ) + .sort( + (a, b) => + Number(/^(utun|tun|tap|bridge|docker|veth|vmnet)/.test(a.name)) - + Number(/^(utun|tun|tap|bridge|docker|veth|vmnet)/.test(b.name)) || + a.name.localeCompare(b.name) || + Number(b.address.includes(":")) - Number(a.address.includes(":")), + ); +} + +/** Public connection endpoints must use a valid, exact HTTPS origin. */ +export function httpsOrigin(value: string): string { + if (!value) return ""; + let url: URL; + try { + url = new URL(value); + } catch { + throw new Error("invalid-public-origin"); + } + if ( + url.protocol !== "https:" || + url.username || + url.password || + url.pathname !== "/" || + url.search || + url.hash || + url.hostname.includes("*") + ) + throw new Error("invalid-public-origin"); + return url.origin; +} diff --git a/src/main/services/companion/LocalDiscovery.ts b/src/main/services/companion/LocalDiscovery.ts new file mode 100644 index 000000000..307b54e4d --- /dev/null +++ b/src/main/services/companion/LocalDiscovery.ts @@ -0,0 +1,54 @@ +import { spawn, type ChildProcess } from "node:child_process"; +import { LOCAL_DISCOVERY_HOSTS } from "../../../shared/localDiscovery.ts"; + +/** Publish a bounded local hostname through macOS Bonjour, scoped to Wi-Fi. */ +export class LocalDiscovery { + private child: ChildProcess | null = null; + private epoch = 0; + host = ""; + stop() { + this.epoch++; + this.host = ""; + this.child?.kill(); + this.child = null; + } + async start(address: string, interfaceName: string, port: number): Promise { + this.stop(); + if (process.platform !== "darwin" || port !== 3481) + throw new Error("local-discovery-unavailable"); + const epoch = this.epoch; + for (const host of LOCAL_DISCOVERY_HOSTS) { + if (this.epoch !== epoch) throw new Error("discovery-cancelled"); + const child = spawn("/usr/bin/dns-sd", ["-i", interfaceName, "-P", + `CanvasTTY ${host}`, "_canvastty._tcp", "local", String(port), host, address], + { stdio: ["ignore", "pipe", "pipe"] }); + this.child = child; + let settled = false; + const result = await new Promise<"ready" | "conflict" | "failed">(resolve => { + const timer = setTimeout(() => done("failed"), 5000); + let output = ""; + const done = (value: "ready" | "conflict" | "failed") => { + if (settled) return; + settled = true; + clearTimeout(timer); + resolve(value); + }; + child.stdout?.on("data", data => { + output = (output + data.toString()).slice(-4096); + if (output.includes("Name in use")) done("conflict"); + else if (output.includes(`record ${host}: Name now registered and active`)) done("ready"); + }); + child.on("error", () => done("failed")); + child.on("exit", () => { + if (this.child === child) { this.child = null; this.host = ""; } + done("failed"); + }); + }); + if (this.epoch !== epoch) { child.kill(); throw new Error("discovery-cancelled"); } + if (result === "ready") { this.host = host; return; } + child.kill(); + if (result !== "conflict") break; + } + throw new Error("local-discovery-unavailable"); + } +} diff --git a/src/main/services/companion/LocalLink.ts b/src/main/services/companion/LocalLink.ts new file mode 100644 index 000000000..f0dd11efa --- /dev/null +++ b/src/main/services/companion/LocalLink.ts @@ -0,0 +1,99 @@ +import { readFile, writeFile, mkdir, rename, rm } from "node:fs/promises"; +import { join } from "node:path"; +import { createHash } from "node:crypto"; +import { + randomLocalHex, + sealLocal, + unsealLocal, + validateLocalRequest, + validLocalPacket, + type LocalConnection, + type LocalPacket, + type LocalRequest, + type LocalResponse, +} from "../../../shared/localLink.ts"; + +export class LocalLink { + private identity: { computer: string; key: string } | null = null; + private readonly file: string; + private active = 0; + private receipts = new Map< + string, + { hash: string; until: number; result: Promise } + >(); + constructor(userDataPath: string) { + this.file = join(userDataPath, "even-g2-local.json"); + } + async load(): Promise { + try { + const v = JSON.parse(await readFile(this.file, "utf8")); + if (!/^[a-f0-9]{64}$/.test(v?.computer) || !/^[a-f0-9]{64}$/.test(v?.key)) + throw new Error("invalid-local-identity"); + this.identity = { computer: v.computer, key: v.key }; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + const value = { computer: randomLocalHex(), key: randomLocalHex() }; + await mkdir(join(this.file, ".."), { recursive: true, mode: 0o700 }); + const temporary = this.file + "." + randomLocalHex(8) + ".tmp"; + try { + await writeFile(temporary, JSON.stringify(value), { + mode: 0o600, + flag: "wx", + }); + await rename(temporary, this.file); + } finally { + await rm(temporary, { force: true }); + } + this.identity = value; + } + } + connection(origins: string[]): LocalConnection { + if (!this.identity) throw new Error("local-identity-unavailable"); + return { version: 1, ...this.identity, origins }; + } + async receive( + packet: LocalPacket, + forward: (request: LocalRequest) => Promise, + ): Promise { + if (!validLocalPacket(packet)) throw new Error("invalid-local-packet"); + const connection = this.connection([]), + now = Date.now(); + const hash = createHash("sha256") + .update(JSON.stringify(packet)) + .digest("hex"); + for (const [id, receipt] of this.receipts) + if (receipt.until < now) this.receipts.delete(id); + const old = this.receipts.get(packet.id); + if (old) { + if (old.hash !== hash) throw new Error("packet-id-conflict"); + return old.result; + } + if (this.active >= 8 || this.receipts.size >= 2048) + throw new Error("local-link-busy"); + this.active++; + const result = (async () => { + const request = await unsealLocal( + connection, + packet, + "request", + ); + let response: LocalResponse; + try { + validateLocalRequest(request); + response = await forward(request); + } catch { + response = { status: 409, body: { error: "request-failed" } }; + } + return sealLocal(connection, response, "response", packet.id); + })() + .catch((error) => { + this.receipts.delete(packet.id); + throw error; + }) + .finally(() => { + this.active--; + }); + this.receipts.set(packet.id, { hash, until: now + 120_000, result }); + return result; + } +} diff --git a/src/main/services/companion/LocalPairing.ts b/src/main/services/companion/LocalPairing.ts new file mode 100644 index 000000000..9dc7ce61e --- /dev/null +++ b/src/main/services/companion/LocalPairing.ts @@ -0,0 +1,37 @@ +import srpClient from "secure-remote-password/client.js"; +import srpServer from "secure-remote-password/server.js"; +import { randomBytes } from "node:crypto"; +import { PAIRING_IDENTITY, sixDigitCode } from "../../../shared/localDiscovery.ts"; + +/** Ephemeral PAKE: a short PIN is never sent or used directly as an AES key. */ +export class LocalPairing { + private readonly salt = srpClient.generateSalt(); + private readonly verifier: string; + private attempts = 0; + private sessions = new Map(); + readonly expiresAt: number; + constructor(code: string, expiresAt: number) { + this.expiresAt = expiresAt; + this.verifier = srpClient.deriveVerifier(srpClient.derivePrivateKey( + this.salt, PAIRING_IDENTITY, sixDigitCode(code), + )); + } + start(value: unknown) { + if (Date.now() >= this.expiresAt || ++this.attempts > 10 || + typeof value !== "string" || !/^[a-f0-9]{512}$/.test(value) || + /^0+$/.test(value)) throw new Error("pairing-unavailable"); + const ephemeral = srpServer.generateEphemeral(this.verifier); + const id = randomBytes(32).toString("hex"); + this.sessions.set(id, { secret: ephemeral.secret, public: value }); + return { id, salt: this.salt, public: ephemeral.public }; + } + finish(id: unknown, proof: unknown) { + const session = typeof id === "string" ? this.sessions.get(id) : null; + if (typeof id === "string") this.sessions.delete(id); + if (!session || Date.now() >= this.expiresAt || + typeof proof !== "string" || !/^[a-f0-9]{64}$/.test(proof)) + throw new Error("pairing-unavailable"); + return srpServer.deriveSession(session.secret, session.public, this.salt, + PAIRING_IDENTITY, this.verifier, proof); + } +} diff --git a/src/main/services/companion/RequestLedger.ts b/src/main/services/companion/RequestLedger.ts new file mode 100644 index 000000000..430b416db --- /dev/null +++ b/src/main/services/companion/RequestLedger.ts @@ -0,0 +1,77 @@ +import { createHash } from "node:crypto"; +import { + CompanionError, + type CompanionRequest, +} from "../../../shared/companion.ts"; + +interface Receipt { + digest: string; + expiresAt: number; + result: Promise; + pending: boolean; +} + +/** One result per device/request within its bounded validity window. */ +export class RequestLedger { + private readonly entries = new Map(); + private readonly now: () => number; + private readonly maxEntries: number; + private readonly maxAgeMs: number; + + constructor( + now: () => number = Date.now, + maxEntries = 1024, + maxAgeMs = 120_000, + ) { + this.now = now; + this.maxEntries = maxEntries; + this.maxAgeMs = maxAgeMs; + } + + run( + deviceId: string, + request: Pick & { action: unknown }, + operation: () => Promise, + ): Promise { + const now = this.now(); + for (const [key, entry] of this.entries) { + if (!entry.pending && entry.expiresAt < now) this.entries.delete(key); + } + if (request.sentAt < now - this.maxAgeMs || request.sentAt > now + 30_000) { + return Promise.reject(new CompanionError("stale-request")); + } + const key = JSON.stringify([deviceId, request.id]); + const digest = createHash("sha256") + .update(JSON.stringify(request.action)) + .digest("hex"); + const previous = this.entries.get(key); + if (previous) { + return previous.digest === digest + ? previous.result + : Promise.reject(new CompanionError("request-conflict")); + } + // Do not evict a valid receipt and accidentally allow a repeated input. + if (this.entries.size >= this.maxEntries) + return Promise.reject(new CompanionError("busy")); + const entry: Receipt = { + digest, + expiresAt: request.sentAt + this.maxAgeMs, + pending: true, + result: Promise.resolve().then(operation), + }; + this.entries.set(key, entry); + void entry.result + .finally(() => { + entry.pending = false; + }) + .catch(() => undefined); + return entry.result; + } + + forgetDevice(deviceId: string): void { + for (const key of this.entries.keys()) { + if ((JSON.parse(key) as string[])[0] === deviceId) + this.entries.delete(key); + } + } +} diff --git a/src/main/services/companion/SessionAccess.ts b/src/main/services/companion/SessionAccess.ts new file mode 100644 index 000000000..89ee41af3 --- /dev/null +++ b/src/main/services/companion/SessionAccess.ts @@ -0,0 +1,53 @@ +import { + CompanionError, + type CompanionGrant, +} from "../../../shared/companion.ts"; + +/** Grants come from desktop pairing, never from a remote request body. */ +export class SessionAccess { + private readonly grants = new Map(); + private revision = 0; + + share(grant: Omit): CompanionGrant { + if ( + !grant.deviceId || + grant.deviceId.length > 128 || + grant.sessionIds.length > 64 + ) { + throw new CompanionError("invalid-request"); + } + const next = { + ...grant, + sessionIds: [...new Set(grant.sessionIds)], + revision: ++this.revision, + }; + this.grants.set(grant.deviceId, next); + return structuredClone(next); + } + + get(deviceId: string): CompanionGrant { + const grant = this.grants.get(deviceId); + if (!grant) throw new CompanionError("not-paired"); + return structuredClone(grant); + } + + assertCurrent(grant: CompanionGrant): void { + if (this.get(grant.deviceId).revision !== grant.revision) + throw new CompanionError("not-permitted"); + } + + assertSession(grant: CompanionGrant, sessionId: string): void { + this.assertCurrent(grant); + if (!grant.sessionIds.includes(sessionId)) + throw new CompanionError("not-shared"); + } + + revoke(deviceId: string): void { + this.grants.delete(deviceId); + } + + includeCreatedSession(grant: CompanionGrant, sessionId: string): void { + this.assertCurrent(grant); + this.share({ ...grant, sessionIds: [...grant.sessionIds, sessionId] }); + } +} diff --git a/src/main/services/companion/SpeechRecognizer.ts b/src/main/services/companion/SpeechRecognizer.ts new file mode 100644 index 000000000..2aa5100d5 --- /dev/null +++ b/src/main/services/companion/SpeechRecognizer.ts @@ -0,0 +1,206 @@ +import { promisify } from "node:util"; +import { + execFile, + spawn, + type ChildProcessWithoutNullStreams, +} from "node:child_process"; +import { existsSync } from "node:fs"; +import { createHash } from "node:crypto"; +import { basename } from "node:path"; + +const PYTHON = process.platform === "darwin" ? "/usr/bin/python3" : "python3"; + +type Result = { accepted: boolean; transcript?: string; cancelled?: boolean }; +export class SpeechRecognizer { + available = false; + binary = ""; + modelId = ""; + configure(binary: string, modelId: string) { + this.binary = binary; + this.modelId = modelId; + this.model = this.bundled ? "Nemotron 3.5 · CanvasTTY" : "Handy"; + this.available = + process.platform !== "win32" && + existsSync(binary) && + (this.bundled + ? existsSync(modelId) + : existsSync(this.worker) && !!modelId); + } + model = "Handy"; + private get bundled(): boolean { + return basename(this.binary) === "canvastty-speech"; + } + async inspect() { + this.available = false; + if (this.bundled) { + if (!existsSync(this.binary) || !existsSync(this.modelId)) return; + try { + const { stdout } = await promisify(execFile)( + this.binary, + ["--version"], + { timeout: 5000, maxBuffer: 16384 }, + ); + this.available = JSON.parse(stdout).engine === "transcribe.cpp"; + this.model = "Nemotron 3.5 · CanvasTTY"; + } catch {} + return; + } + if ( + process.platform === "win32" || + !existsSync(this.binary) || + !existsSync(this.worker) + ) + return; + try { + const { stdout } = await promisify(execFile)( + this.binary, + ["--list-models", "--json"], + { timeout: 5000, maxBuffer: 262144 }, + ); + const rows = JSON.parse(stdout); + const model = Array.isArray(rows) + ? rows.find( + (item) => item.id === this.modelId && item.is_downloaded === true, + ) + : null; + if (model) { + this.model = String(model.name || "Handy").slice(0, 100); + this.available = true; + } + } catch {} + } + private readonly worker: string; + private child: ChildProcessWithoutNullStreams | null = null; + private activeId: string | null = null; + private cancelled = new Set(); + private receipts = new Map(); + constructor(worker: string) { + this.worker = worker; + } + private stopChild(signal: NodeJS.Signals = "SIGTERM", child = this.child) { + if (!child?.pid) return; + try { + process.kill(-child.pid, signal); + } catch { + child.kill(signal); + } + } + cancel(id: string) { + if (!/^[a-f0-9-]{16,80}$/.test(id)) return; + if (this.cancelled.size >= 256) + this.cancelled.delete(this.cancelled.values().next().value!); + this.cancelled.add(id); + if (this.activeId === id) this.stopChild(); + } + cancelAll() { + if (this.activeId) this.cancelled.add(this.activeId); + this.stopChild(); + } + async run( + body: Record, + accept: ( + text: string, + cancelled: () => boolean, + ) => Promise | boolean, + ): Promise { + const id = body.requestId, + audio = body.audio; + if ( + typeof id !== "string" || + !/^[a-f0-9-]{16,80}$/.test(id) || + typeof audio !== "string" || + audio.length > 1280000 || + !/^[A-Za-z0-9+/]*={0,2}$/.test(audio) + ) + throw new Error("Invalid audio request"); + const pcm = Buffer.from(audio, "base64"); + if (pcm.length < 6400 || pcm.length > 960000 || pcm.length % 2) + throw new Error("Invalid audio length"); + const hash = createHash("sha256") + .update(JSON.stringify([body.sessionId, body.purpose || "input"])) + .update(pcm) + .digest("hex"); + const old = this.receipts.get(id); + if (old) { + if (old.hash !== hash) throw new Error("Request ID conflict"); + return old.result; + } + if (this.cancelled.has(id)) return { accepted: false, cancelled: true }; + if (this.activeId) throw new Error("Speech recognizer is busy"); + if (!this.available) throw new Error("Local speech runtime unavailable"); + this.activeId = id; + const usedModel = this.modelId; + try { + const text = await new Promise((resolve, reject) => { + const child = spawn( + this.bundled ? this.binary : PYTHON, + this.bundled + ? [this.modelId] + : [this.worker, this.binary, this.modelId], + { + detached: true, + stdio: ["pipe", "pipe", "pipe"], + env: { + ...process.env, + HF_HUB_OFFLINE: "1", + HF_HUB_DISABLE_TELEMETRY: "1", + }, + }, + ); + this.child = child; + let output = ""; + const timer = setTimeout(() => { + this.stopChild("SIGKILL", child); + reject(new Error("Speech timeout")); + }, 60000); + child.on("error", () => { + clearTimeout(timer); + reject(new Error("Speech runtime failed")); + }); + child.stdout.on("data", (chunk) => { + output += chunk.toString(); + if (output.length > 16000) this.stopChild("SIGKILL", child); + }); + child.stderr.on("data", () => {}); + child.on("close", (code) => { + clearTimeout(timer); + if (code !== 0) + return reject(new Error("Speech recognition stopped")); + try { + const data = JSON.parse(output); + if ( + data.model !== usedModel || + typeof data.text !== "string" || + data.text.length > 4000 + ) + throw new Error(); + resolve(data.text.trim()); + } catch { + reject(new Error("Invalid speech result")); + } + }); + child.stdin.on("error", () => {}); + child.stdin.end(pcm); + }); + let result: Result = this.cancelled.has(id) + ? { accepted: false, cancelled: true } + : text + ? { accepted: true, transcript: text } + : { accepted: false }; + // The delivery callback rechecks cancellation and access after its awaited read. + if (result.accepted) { + const delivered = await accept(text, () => this.cancelled.has(id)); + if (!delivered) + result = { accepted: false, cancelled: this.cancelled.has(id) }; + } + this.receipts.set(id, { hash, result }); + if (this.receipts.size > 128) + this.receipts.delete(this.receipts.keys().next().value!); + return result; + } finally { + pcm.fill(0); + this.child = null; + this.activeId = null; + } + } +} diff --git a/src/main/services/companion/SpeechSetup.ts b/src/main/services/companion/SpeechSetup.ts new file mode 100644 index 000000000..fdec7c2c7 --- /dev/null +++ b/src/main/services/companion/SpeechSetup.ts @@ -0,0 +1,136 @@ +import { createHash } from "node:crypto"; +import { mkdir, open, stat, rename, rm } from "node:fs/promises"; +import { existsSync } from "node:fs"; +import { join, dirname } from "node:path"; +import type { SpeechSetupState } from "../../../shared/evenG2.ts"; + +export const NEMOTRON_MODEL = { + name: "Nemotron Streaming 3.5", + bytes: 751094240, + sha256: "b94545b313b3223fda7b2857a52681da813935c2127643d1e9ff0c23d988089c", + url: "https://huggingface.co/handy-computer/nemotron-3.5-asr-streaming-0.6b-gguf/resolve/0221a878b3f4c3efd14e976702058fe998d41573/nemotron-3.5-asr-streaming-0.6b-Q8_0.gguf", +}; +interface Options { + userDataPath: string; + binary: string; + model?: typeof NEMOTRON_MODEL; + fetcher?: typeof fetch; +} +export class SpeechSetup { + readonly binary: string; + readonly modelPath: string; + private readonly asset: typeof NEMOTRON_MODEL; + private readonly fetcher: typeof fetch; + private progress: SpeechSetupState; + private abort: AbortController | null = null; + private job: Promise | null = null; + constructor(options: Options) { + this.binary = options.binary; + this.modelPath = join( + options.userDataPath, + "companion-models", + "nemotron-3.5-q8.gguf", + ); + this.asset = options.model || NEMOTRON_MODEL; + this.fetcher = options.fetcher || fetch; + this.progress = { + supported: !!this.binary && existsSync(this.binary), + phase: "missing", + received: 0, + total: this.asset.bytes, + model: this.asset.name, + error: "", + }; + } + state(): SpeechSetupState { + return { ...this.progress }; + } + async inspect(): Promise { + if (this.job) return; + const file = await stat(this.modelPath).catch(() => null); + this.progress.phase = + this.progress.supported && file?.size === this.asset.bytes + ? "ready" + : "missing"; + this.progress.received = + this.progress.phase === "ready" ? this.asset.bytes : 0; + } + cancel(): void { + this.abort?.abort(); + } + prepare(): Promise { + if (this.job) return this.job; + if (this.progress.phase === "ready") return Promise.resolve(); + if (!this.progress.supported) + return Promise.reject(new Error("bundled-speech-unavailable")); + this.abort = new AbortController(); + const abort = this.abort; + this.progress = { + ...this.progress, + phase: "downloading", + received: 0, + error: "", + }; + this.job = this.download( + AbortSignal.any([abort.signal, AbortSignal.timeout(15 * 60_000)]), + ) + .catch((error) => { + this.progress.phase = abort.signal.aborted ? "missing" : "error"; + this.progress.error = abort.signal.aborted + ? "" + : "model-download-failed"; + throw error; + }) + .finally(() => { + this.job = null; + this.abort = null; + }); + return this.job; + } + private async download(signal: AbortSignal): Promise { + const directory = dirname(this.modelPath); + await mkdir(directory, { recursive: true, mode: 0o700 }); + const temporary = this.modelPath + ".download"; + let file; + try { + const response = await this.fetcher(this.asset.url, { + signal, + redirect: "follow", + }); + if (!response.ok || !response.body) throw new Error("download-failed"); + file = await open(temporary, "w", 0o600); + const hash = createHash("sha256"); + const reader = response.body.getReader(); + try { + while (true) { + signal.throwIfAborted(); + const { done, value } = await reader.read(); + if (done) break; + this.progress.received += value.length; + if (this.progress.received > this.asset.bytes) + throw new Error("model-too-large"); + hash.update(value); + // FileHandle.write may be partial; writeFile consumes the complete chunk. + await file.writeFile(value); + } + } finally { + await reader.cancel().catch(() => {}); + } + this.progress.phase = "verifying"; + if ( + this.progress.received !== this.asset.bytes || + hash.digest("hex") !== this.asset.sha256 + ) + throw new Error("model-checksum-mismatch"); + signal.throwIfAborted(); + await file.sync(); + await file.close(); + file = undefined; + await rename(temporary, this.modelPath); + this.progress.phase = "ready"; + } finally { + await file?.close(); + await rm(temporary, { force: true }); + } + } +} diff --git a/src/main/services/companion/TerminalPresentation.ts b/src/main/services/companion/TerminalPresentation.ts new file mode 100644 index 000000000..48046d4f3 --- /dev/null +++ b/src/main/services/companion/TerminalPresentation.ts @@ -0,0 +1,229 @@ +import { createHash, randomBytes } from "node:crypto"; +import xterm from "@xterm/headless"; +import { + IPC, + type SessionMetadata, + type TerminalDataEvent, +} from "../../../shared/contracts.ts"; +import { + cleanTerminalText, + cleanCodexChrome, + codexMenu, + presentTerminal, +} from "./presentation.ts"; + +type Port = { + listMetadata(): SessionMetadata[]; + geometry(id: string): { cols: number; rows: number }; + readBuffer(id: string): { buffer: string; outputOffset: number }; +}; +interface Screen { + terminal: InstanceType; + ready: Promise; + offset: number; + lastAnswer: string; + answerTurn: string | null; + authoritative: boolean; + sequence: number; + turnPending: boolean; + busySeen: boolean; + menuFingerprint: string | null; + menuId: string | null; + consumed: string | null; +} +export class TerminalPresentation { + private screens = new Map(); + private port: Port; + constructor(port: Port) { + this.port = port; + } + private screen(id: string): Screen { + const prior = this.screens.get(id); + if (prior) return prior; + const terminal = new xterm.Terminal({ + ...this.port.geometry(id), + allowProposedApi: true, + scrollback: 300, + }); + const buffer = this.port.readBuffer(id); + const screen: Screen = { + terminal, + ready: new Promise((resolve) => terminal.write(buffer.buffer, resolve)), + offset: buffer.outputOffset, + lastAnswer: "", + answerTurn: null, + authoritative: false, + sequence: 0, + turnPending: false, + busySeen: false, + menuFingerprint: null, + menuId: null, + consumed: null, + }; + this.screens.set(id, screen); + return screen; + } + observe(channel: string, payload: unknown): void { + if (channel === IPC.terminalRemoved) { + const id = (payload as { id: string }).id; + this.screens.get(id)?.terminal.dispose(); + this.screens.delete(id); + return; + } + if (channel === IPC.terminalSession) { + const session = (payload as { session: SessionMetadata }).session; + const screen = this.screen(session.id); + if (session.status === "working") { + screen.turnPending = true; + screen.busySeen = true; + } + return; + } + if (channel !== IPC.terminalData) return; + const event = payload as TerminalDataEvent, + screen = this.screen(event.id); + const overlap = Math.max( + 0, + screen.offset - (event.outputOffset - event.data.length), + ); + const data = event.data.slice(overlap); + screen.offset = Math.max(screen.offset, event.outputOffset); + const geometry = this.port.geometry(event.id); + if ( + geometry.cols !== screen.terminal.cols || + geometry.rows !== screen.terminal.rows + ) + screen.terminal.resize(geometry.cols, geometry.rows); + if (data) + screen.ready = screen.ready.then( + () => + new Promise((resolve) => screen.terminal.write(data, resolve)), + ); + } + answer(id: string, text: string, turnId: string | null): void { + if (!this.port.listMetadata().some((s) => s.id === id) || !text.trim()) + return; + const screen = this.screen(id); + if (turnId && screen.answerTurn === turnId) return; + screen.answerTurn = turnId; + screen.lastAnswer = text.trim(); + screen.authoritative = true; + screen.sequence++; + screen.turnPending = false; + } + pending(id: string): void { + const screen = this.screen(id); + screen.turnPending = true; + screen.busySeen = false; + } + private async text(id: string, history = false): Promise { + const screen = this.screen(id); + await screen.ready; + const buffer = screen.terminal.buffer.active, + lines: string[] = []; + for ( + let i = history ? 0 : buffer.baseY; + i < buffer.baseY + screen.terminal.rows; + i++ + ) { + const line = buffer.getLine(i), + content = line?.translateToString(!history) || ""; + if (history && line?.isWrapped && lines.length) + lines[lines.length - 1] += content; + else lines.push(content); + } + return lines + .map((line) => line.trimEnd()) + .join("\n") + .trim() + .slice(history ? -32000 : -10000); + } + async read(id: string) { + const session = this.port.listMetadata().find((s) => s.id === id); + if (!session) throw new Error("Session unavailable"); + const screen = this.screen(id), + viewport = await this.text(id), + menu = session.provider === "codex" ? codexMenu(viewport) : null; + if (menu) { + const fingerprint = createHash("sha256") + .update(JSON.stringify([menu.title, menu.options])) + .digest("hex"); + if (screen.consumed === fingerprint) + return { + body: "Selection sent. Waiting for the terminal.", + revision: "selected-" + fingerprint, + interaction: null, + }; + if (screen.menuFingerprint !== fingerprint || !screen.menuId) { + screen.menuFingerprint = fingerprint; + screen.menuId = randomBytes(16).toString("hex"); + } + return { + body: menu.title, + revision: "menu-" + screen.menuId + "-" + menu.selected, + interaction: { ...menu, id: screen.menuId }, + }; + } + screen.menuId = null; + screen.menuFingerprint = null; + screen.consumed = null; + if ( + !screen.authoritative && + session.status !== "working" && + session.status !== "needs_approval" + ) { + const reply = presentTerminal( + session.provider, + await this.text(id, true), + ); + if ( + reply && + (session.provider !== "codex" || + !screen.lastAnswer || + (screen.turnPending && screen.busySeen)) + ) + screen.lastAnswer = reply; + } + const body = + session.status === "needs_approval" + ? cleanTerminalText(viewport) + : screen.lastAnswer || + (session.provider === "codex" + ? cleanCodexChrome(viewport) + : cleanTerminalText(viewport)); + return { + body: body || "Ready for a task.", + revision: + screen.sequence + + "-" + + createHash("sha256").update(body).digest("hex").slice(0, 16), + interaction: null, + }; + } + async choice(id: string, menuId: string, index: number, custom: boolean) { + const view = await this.read(id), + menu = view.interaction; + if (!menu || menu.id !== menuId) throw new Error("Menu changed"); + const target = custom ? menu.customIndex : index; + if ( + target === null || + !Number.isInteger(target) || + target < 0 || + target >= menu.options.length + ) + throw new Error("No matching option"); + const delta = target - menu.selected; + return { + data: (delta < 0 ? "\x1b[A" : "\x1b[B").repeat(Math.abs(delta)) + "\r", + commit: () => { + const screen = this.screen(id); + screen.consumed = screen.menuFingerprint; + screen.menuId = null; + }, + }; + } + close(): void { + for (const screen of this.screens.values()) screen.terminal.dispose(); + this.screens.clear(); + } +} diff --git a/src/main/services/companion/asr_worker.py b/src/main/services/companion/asr_worker.py new file mode 100644 index 000000000..dc209f5a4 --- /dev/null +++ b/src/main/services/companion/asr_worker.py @@ -0,0 +1,63 @@ +"""Bounded G2 PCM -> installed Handy/Nemotron; temporary WAV is removed.""" +import array +import json +import math +import os +import signal +import subprocess +import sys +import tempfile +import wave +from pathlib import Path + +BINARY = sys.argv[1] +MODEL = sys.argv[2] +child = None + + +def stop(signum, _frame): + if child is not None and child.poll() is None: + child.terminate() + raise SystemExit(128 + signum) + + +signal.signal(signal.SIGTERM, stop) +signal.signal(signal.SIGINT, stop) +pcm = sys.stdin.buffer.read(960001) +if not 6400 <= len(pcm) <= 960000 or len(pcm) % 2: + raise SystemExit(2) +samples = array.array('h', pcm) +if sys.byteorder != 'little': + samples.byteswap() +rms = math.sqrt(sum(x*x for x in samples) / len(samples)) / 32768 +text = '' +if rms >= 0.002: + # Handys file mode never downloads models or writes dictation history. + # Its WAV decoder needs an ordinary pathname, not /dev/fd. + with tempfile.TemporaryDirectory(prefix='canvastty-g2-voice-') as directory: + path = Path(directory) / 'utterance.wav' + with wave.open(str(path), 'wb') as wav: + wav.setnchannels(1) + wav.setsampwidth(2) + wav.setframerate(16000) + wav.writeframes(pcm) + path.chmod(0o600) + try: + child = subprocess.Popen( + [BINARY, '--transcribe-file', str(path), '--model', MODEL, '--json'], + stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.PIPE, + env={**os.environ, 'HF_HUB_OFFLINE': '1', 'HF_HUB_DISABLE_TELEMETRY': '1'}, + ) + output, _errors = child.communicate(timeout=52) + if child.returncode != 0: + raise SystemExit(3) + result = json.loads(output) + text = result.get('text', '') + if not isinstance(text, str) or len(text) > 4000: + raise SystemExit(4) + text = text.strip() + finally: + if child is not None and child.poll() is None: + child.kill() + child.wait(timeout=2) +print(json.dumps({'text': text, 'model': MODEL, 'engine': 'Handy'}, ensure_ascii=False)) diff --git a/src/main/services/companion/presentation.ts b/src/main/services/companion/presentation.ts new file mode 100644 index 000000000..b109b385f --- /dev/null +++ b/src/main/services/companion/presentation.ts @@ -0,0 +1,201 @@ +/** Presentation only: lifecycle status comes from CanvasTTY, never from text. */ +export function cleanTerminalText(value: string): string { + return value + .split(/\r?\n/) + .filter((line) => !/^\s*[─━═\-]{6,}\s*$/.test(line)) + .filter((line) => !/^\s*[◦•●]?\s*Working\s*\(/i.test(line)) + .filter( + (line) => + !/^\s*gpt-[\w.-]+\s+(?:low|medium|high|xhigh|max|ultra|default|none)\s*·/.test( + line, + ), + ) + .filter( + (line) => + !/^\s*[›>]\s*(?:Ask Codex|Find and fix|Implement|Summarize|Explain this|Write tests|Use \/skills)/i.test( + line, + ), + ) + .join("\n") + .replace(/\n[ \t]*\n(?:[ \t]*\n)+/g, "\n\n") + .trim(); +} + +export function latestCodexReply(value: string): string { + const lines = value.split(/\r?\n/); + let start = -1; + for (let index = 0; index < lines.length; index++) { + if ( + /^[•●]\s+/.test(lines[index]) && + !/^[•●]\s+(?:Working\s*\(|Starting MCP servers|You have \d+ usage limit resets|Tip:)/i.test( + lines[index], + ) + ) + start = index; + } + if (start < 0) return ""; + const selected = []; + for (let index = start; index < lines.length; index++) { + if ( + index > start && + (/^\s*›\s/.test(lines[index]) || + /^[•●◦]\s+(?:Starting MCP servers|You have \d+ usage limit resets|Tip:)/i.test( + lines[index], + ) || + /^\s*⚠\s*Heads up/i.test(lines[index])) + ) + break; + selected.push( + index === start ? lines[index].replace(/^[•●]\s+/, "") : lines[index], + ); + } + return cleanTerminalText(selected.join("\n")).slice(-16000); +} + +export interface ChoiceMenu { + kind: "choices"; + title: string; + options: Array<{ number: number; label: string }>; + selected: number; + customIndex: number | null; +} + +export function codexMenu(value: string): ChoiceMenu | null { + const lines = value.split(/\r?\n/), + options: ChoiceMenu["options"] = []; + let first = -1, + last = -1, + selected = -1; + for (let i = 0; i < lines.length; i++) { + const match = lines[i].match(/^\s*([›❯>])?\s*(\d{1,2})[.)]\s+(.+?)\s*$/u); + if (!match) { + if ( + options.length && + /^\s{4,}\S/u.test(lines[i]) && + !/press.*(?:enter|esc)|(?:enter|esc).*to\b/iu.test(lines[i]) + ) + options.at(-1)!.label += " " + lines[i].trim(); + continue; + } + const number = Number(match[2]); + if (number !== options.length + 1) { + if (options.length) break; + else continue; + } + if (first < 0) first = i; + last = i; + if (match[1]) { + if (selected >= 0) return null; + selected = options.length; + } + options.push({ number, label: match[3].trim() }); + if (options.length > 20) return null; + } + // Numbered prose is not a menu: a live selection cursor is required. + if (options.length < 2 || selected < 0) return null; + if ( + !lines + .slice(last + 1) + .some((line) => + /(?:press\s+)?enter\s+to\s+(?:confirm|select|submit)|esc\s+to\s+(?:go back|cancel)|enter.*(?:submit|выбр|подтверд)/iu.test( + line, + ), + ) + ) + return null; + if ( + lines + .slice(last + 1) + .some((line) => /^\s*›(?:\s*$|\s+(?!\d+[.)]\s)\S)/u.test(line)) + ) + return null; + const prefix = cleanCodexChrome(lines.slice(0, first).join("\n")); + const contextLines = prefix.split("\n"); + let heading = -1; + for (let i = 0; i < contextLines.length; i++) + if ( + /^\s*(?:Select\b|Choose\b|Would you like\b|Allow\b|Approve\b|Which\b|What\b|Question\s+\d|Выберите\b|Разрешить\b)/iu.test( + contextLines[i], + ) + ) + heading = i; + const context = + heading >= 0 + ? contextLines.slice(heading).join("\n").trim() + : prefix + .split(/\n\s*\n/u) + .filter(Boolean) + .at(-1) || ""; + const lastOption = options.length - 1; + const custom = + /^type (?:something|your|an? )|^other(?:\s*\(|\s*$)|^something else|tell codex.*differently|^сво[йею].*(?:ответ|вариант)|^другое|^ввести.*(?:текст|ответ)/iu.test( + options[lastOption].label, + ) + ? lastOption + : -1; + return { + kind: "choices", + title: context || "Выберите вариант", + options, + selected, + customIndex: custom < 0 ? null : custom, + }; +} + +/** Strip only known Codex UI chrome; never run this on authoritative answers. */ +export function cleanCodexChrome(value: string): string { + const lines = value.split(/\r?\n/), + result: string[] = []; + let banner = false, + tip = false; + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + if ( + /^\s*╭[─━]/u.test(line) && + lines + .slice(i, i + 8) + .some((x) => /OpenAI Codex|\bmodel:|\bdirectory:/.test(x)) + ) { + banner = true; + continue; + } + if (banner) { + if (/^\s*╰[─━]/u.test(line)) banner = false; + continue; + } + if ( + /^\s*(?:│\s*(?:>_\s*OpenAI Codex|model:|directory:|permissions:)|[\u2500-\u257f]+\s*$)/u.test( + line, + ) + ) + continue; + if (/^\s*(?:[•●]\s*)?Tip:/iu.test(line)) { + tip = true; + continue; + } + if (tip) { + if (!line.trim()) tip = false; + else if (/^\s{2,}\S/.test(line)) continue; + else tip = false; + } + if ( + /^\s*[•●◦]?\s*(?:You have \d+ usage limit resets|Starting MCP servers)/iu.test( + line, + ) + ) + continue; + if (/^\s*\? for shortcuts|^\s*Press (?:enter|esc).*|^\s*›\s*$/iu.test(line)) + continue; + result.push(line); + } + return cleanTerminalText(result.join("\n")); +} + +export function presentTerminal(provider: string, value: string): string { + if (provider === "codex") return latestCodexReply(value); + return cleanTerminalText(value) + .split("\n") + .slice(-40) + .join("\n") + .slice(-16000); +} diff --git a/src/native/speech/main.cpp b/src/native/speech/main.cpp new file mode 100644 index 000000000..73c9789f4 --- /dev/null +++ b/src/native/speech/main.cpp @@ -0,0 +1,50 @@ +#include "vendor/transcribe.h" +#include +#include +#include +#include +#include + +static void quiet(transcribe_log_level, const char *, void *) {} +static void json(const std::string &text) { + static const char *hex = "0123456789abcdef"; + std::cout << '"'; + for (unsigned char c : text) { + if (c == '"' || c == '\\') std::cout << '\\' << c; + else if (c < 32) std::cout << "\\u00" << hex[c >> 4] << hex[c & 15]; + else std::cout << c; + } + std::cout << '"'; +} +int main(int argc, char **argv) { + if (argc != 2) return 2; + if (std::string(argv[1]) == "--version") { + std::cout << "{\"engine\":\"transcribe.cpp\",\"version\":"; + json(transcribe_version()); std::cout << "}\n"; return 0; + } + std::vector input; + char block[8192]; + while (std::cin.read(block, sizeof(block)) || std::cin.gcount()) { + if (input.size() + std::cin.gcount() > 960000) return 3; + input.insert(input.end(), block, block + std::cin.gcount()); + } + if (input.size() < 6400 || input.size() % 2) return 3; + std::vector pcm(input.size() / 2); + for (size_t i = 0; i < pcm.size(); i++) { + const int16_t sample = static_cast(static_cast(input[i*2]) | (static_cast(input[i*2+1]) << 8)); + pcm[i] = sample / 32768.0f; + } + std::fill(input.begin(), input.end(), 0); + transcribe_log_set(quiet, nullptr); + if (transcribe_init_backends_default() != TRANSCRIBE_OK) return 4; + transcribe_session *session = nullptr; + if (transcribe_open(argv[1], nullptr, nullptr, &session) != TRANSCRIBE_OK) return 5; + const auto result = transcribe_run(session, pcm.data(), static_cast(pcm.size()), nullptr); + std::fill(pcm.begin(), pcm.end(), 0); + if (result != TRANSCRIBE_OK) { transcribe_session_free(session); return 6; } + const char *text = transcribe_full_text(session); + std::cout << "{\"model\":"; json(argv[1]); + std::cout << ",\"text\":"; json(text ? text : ""); std::cout << "}\n"; + transcribe_session_free(session); + return 0; +} diff --git a/src/native/speech/vendor/LICENSE b/src/native/speech/vendor/LICENSE new file mode 100644 index 000000000..38c7ccc72 --- /dev/null +++ b/src/native/speech/vendor/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 The transcribe.cpp authors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/src/native/speech/vendor/transcribe.h b/src/native/speech/vendor/transcribe.h new file mode 100644 index 000000000..1134bee44 --- /dev/null +++ b/src/native/speech/vendor/transcribe.h @@ -0,0 +1,2653 @@ +/* + * transcribe.h - public C API for transcribe.cpp + * + * One-header public surface. Callers never need to include . + * + * Threading: + * - transcribe_model_* functions are thread-safe. + * - A loaded model may be shared across any number of threads: querying it + * (capabilities, metadata, feature probes) and creating sessions from it + * are safe concurrently. + * - KNOWN 0.x LIMITATION — concurrent COMPUTE is not yet supported: at most + * one transcribe_run / transcribe_run_batch / active stream may be in + * flight across ALL sessions of a given model at a time. Sessions share + * the model's backend instances and some per-family model state, so + * overlapping runs race (observed: corrupted decodes on CPU, command- + * buffer failures on Metal). Callers that want parallel transcription + * today should load one model per worker; a per-session backend + * architecture lifting this restriction is planned. Serialized use of + * many sessions on one model (e.g. a session pool behind a mutex) is + * fully supported. + * - A model must outlive every session created from it. + * - transcribe_model_free() is only valid after all derived contexts have + * been freed and no thread is still using the model. + * - transcribe_session_* functions are not thread-safe. + * - A session must be used by at most one thread at a time. + * - A session may be moved between threads if no two threads ever use it + * concurrently. + * - The log callback may be invoked from any thread (including ggml + * worker threads); the userdata pointer must be safe for concurrent + * access. The callback must not assume it runs on the caller thread. + * - transcribe_log_set() should be called ONCE at process startup, + * before any models are loaded, contexts are created, or worker + * threads exist. That is the only supported usage model in 0.x. The + * implementation publishes the callback with release semantics so any + * thread that later loads it observes both the callback and any state + * the installer wrote before publication. This protection covers the + * single startup-time install only. + * - Calling transcribe_log_set() after threads or models exist is + * UNSUPPORTED in 0.x. The implementation is data-race-free (the two + * atomics ensure no torn reads), but under concurrent or mid-run + * reconfiguration the API does NOT guarantee pair-atomic publication + * of (callback, userdata), nor that the previous callback/userdata + * will not still be invoked after transcribe_log_set returns (an + * in-flight emission on another thread may already hold the old pair). + * A caller that needs mid-run reconfiguration must externally + * synchronize transcribe_log_set callers AND keep every previous + * callback target and userdata alive until no thread can still emit + * with them. + * + * ABI stability (0.x): + * - This library is pre-1.0. The on-disk ABI MAY break between 0.x minor + * releases. Consumers should rebuild against matching headers and + * should not assume any layout, enum value, or symbol set is frozen. + * + * Exception safety (C ABI): + * - No C++ exception escapes a public entry point. Allocation failure maps + * to TRANSCRIBE_ERR_OOM, backend/driver failure to TRANSCRIBE_ERR_BACKEND, + * and free/teardown functions never fail. The log callback receives + * exception detail when available. + * + * Params (size-aware structs): + * - Every caller-owned public struct crossing the ABI carries `struct_size` + * as field 0, both for inputs (params, family extensions) and outputs + * (capabilities, stream updates, timings, family telemetry). The field + * is `uint64_t` for platform-invariant layout across 32/64-bit ABIs. + * - Callers MUST initialize each struct via its transcribe_*_init() + * function (or, for family extensions, the family's init function). + * The init function fills sensible defaults and stamps `struct_size`. + * `{0}` is NOT accepted as a defaults shortcut: a struct with + * `struct_size == 0` is rejected with TRANSCRIBE_ERR_BAD_STRUCT_SIZE, + * regardless of any other field's value. The only NULL-equivalent + * defaults form is a literal NULL pointer where the entry point + * accepts one (every public params pointer does). + * - For input structs the init function spells out the default values + * for every field. For output structs the init function sets + * `struct_size` and relies on zero-fill for the rest. The general + * convention is "zero means absent / unknown / false / none," but + * specific fields may use a different sentinel (e.g. a family stream + * extension's transcribe_parakeet_stream_ext::att_context_right uses + * -1 for "model default" because 0 is a real value). Each field's doc + * spells out its sentinel where it differs from the general rule. + * - sizeof(struct ...) is evaluated in the caller's translation unit, + * so struct_size captures the caller's view of the layout. In 0.x + * the library requires struct_size >= the minimum prefix needed by the + * entry point. New trailing fields can be appended without raising that + * minimum when the library can safely treat their absence as zero/default. + * A newer caller with extra trailing fields the older library doesn't + * know about is accepted, and the library only writes what it knows so + * the caller's tail bytes stay as zero-init. Layout-incompatible changes + * (reordering, retyping existing fields) are an ABI break regardless and + * are allowed in 0.x without a deprecation cycle. + * - Family-specific knobs are reached via an opaque, kind-tagged + * extension pointer (`struct transcribe_ext`). Callers probe whether + * the loaded model accepts a given extension kind in a given slot via + * transcribe_model_accepts_ext_kind(), then pass a pointer to a typed + * family extension struct (declared in include/transcribe/.h) + * whose first field embeds `struct transcribe_ext`. NULL family + * extension selects family defaults. + * + * Results: + * - Results are owned by the session and exposed via accessor functions. + * Calling transcribe_run() replaces the previous result. All accessors + * are safe to call before the first transcribe_run() and return safe + * sentinels ("", 0, NaN) when no result is present. + * - All timestamps are int64_t milliseconds, relative to the original + * input audio (never to internally padded audio). + * - All counts and indices are int. + * - Out-of-bounds indices are programmer error: in debug builds the + * library may assert; in release builds accessors return safe sentinels + * instead of reading invalid memory. Callers should bounds-check with + * the corresponding transcribe_n_*() accessor before indexing. + * + * Result text-pointer lifetime: + * - Every accessor that returns a `const char *` (transcribe_full_text, + * transcribe_detected_language, and the `text` field of segment / word + * / token rows copied out by transcribe_get_segment / _word / _token) + * returns a borrowed pointer aliasing session-owned storage. + * - Offline path: the pointer is valid until the NEXT call to + * transcribe_run / transcribe_stream_begin / transcribe_stream_reset + * / transcribe_session_free on the same session. Read it, copy bytes + * if you need them past the next mutating call, and otherwise discard. + * - Streaming path: transcribe_full_text and row `text` pointers are raw + * model snapshot views and may be replaced by every feed/finalize call. + * Use transcribe_stream_get_text() for the UI-facing committed/tentative + * text views. Its committed_text pointer remains stable until the next + * stream mutation, and its bytes are append-only for the life of the + * stream. + * - Bindings that marshal-and-copy at the FFI boundary (Python c_char_p, + * Go C.GoString, Rust CStr::to_string, etc.) are safe by construction + * regardless of the committed/tentative distinction, and remain the + * recommended pattern for any binding that surfaces results as owned + * strings in the host language. + */ + +#ifndef TRANSCRIBE_H +#define TRANSCRIBE_H + +#include +#include +#include + +/* ----------------------------------------------------------------------- */ +/* Version */ +/* ----------------------------------------------------------------------- */ +/* + * Single source of truth for the native library version. The top-level + * CMakeLists.txt parses these three macros to set the CMake project version and + * the shared-library VERSION/SOVERSION, and transcribe_version() (declared + * below) returns the same MAJOR.MINOR.PATCH string. Bump the library version + * here. Pre-1.0 the on-disk ABI MAY break between minor releases (see "ABI + * stability" above); the Python binding pins its package version to this value + * exactly and refuses to load a native provider that does not match. + */ +#define TRANSCRIBE_VERSION_MAJOR 0 +#define TRANSCRIBE_VERSION_MINOR 2 +#define TRANSCRIBE_VERSION_PATCH 3 + +#define TRANSCRIBE_VERSION_STRINGIZE_(x) #x +#define TRANSCRIBE_VERSION_STRINGIZE(x) TRANSCRIBE_VERSION_STRINGIZE_(x) +#define TRANSCRIBE_VERSION \ + TRANSCRIBE_VERSION_STRINGIZE(TRANSCRIBE_VERSION_MAJOR) \ + "." TRANSCRIBE_VERSION_STRINGIZE(TRANSCRIBE_VERSION_MINOR) "." TRANSCRIBE_VERSION_STRINGIZE( \ + TRANSCRIBE_VERSION_PATCH) + +/* Monotonic integer form (MAJOR*10000 + MINOR*100 + PATCH) for compile-time + * comparisons, e.g. `#if TRANSCRIBE_VERSION_NUMBER >= 200`. */ +#define TRANSCRIBE_VERSION_NUMBER \ + (TRANSCRIBE_VERSION_MAJOR * 10000 + TRANSCRIBE_VERSION_MINOR * 100 + TRANSCRIBE_VERSION_PATCH) + +#ifndef TRANSCRIBE_API +# if defined(_WIN32) && !defined(__GNUC__) +# if defined(TRANSCRIBE_STATIC) +/* Static archive (lib build or static consumer): no dllimport/export. + * A consumer linking the static transcribe.lib must NOT see dllimport + * or the linker chases __imp_* thunks the archive never provides + * (LNK2019). The static `transcribe` CMake target propagates this + * PUBLIC; non-CMake static consumers define it themselves. */ +# define TRANSCRIBE_API +# elif defined(TRANSCRIBE_BUILD) +# define TRANSCRIBE_API __declspec(dllexport) +# else +# define TRANSCRIBE_API __declspec(dllimport) +# endif +# else +# define TRANSCRIBE_API __attribute__((visibility("default"))) +# endif +#endif + +#ifdef __cplusplus +extern "C" { +#endif + +/* ----------------------------------------------------------------------- */ +/* Status */ +/* ----------------------------------------------------------------------- */ + +typedef enum { + TRANSCRIBE_OK = 0, + TRANSCRIBE_ERR_INVALID_ARG = 1, + TRANSCRIBE_ERR_NOT_IMPLEMENTED = 2, + TRANSCRIBE_ERR_FILE_NOT_FOUND = 3, + TRANSCRIBE_ERR_GGUF = 4, + TRANSCRIBE_ERR_UNSUPPORTED_ARCH = 5, + TRANSCRIBE_ERR_UNSUPPORTED_VARIANT = 6, + TRANSCRIBE_ERR_OOM = 7, + TRANSCRIBE_ERR_BACKEND = 8, + /* Reserved; not currently returned. */ + TRANSCRIBE_ERR_SAMPLE_RATE = 9, + TRANSCRIBE_ERR_UNSUPPORTED_LANGUAGE = 10, + TRANSCRIBE_ERR_UNSUPPORTED_TASK = 11, + /* + * Returned by transcribe_run when the caller asks for a + * timestamp granularity finer than the model can produce. + * Compared against transcribe_capabilities::max_timestamp_kind, + * which is set per family at load time. AUTO never trips this + * code: the dispatcher passes AUTO through unconditionally and + * the per-family run() handler resolves it to the model's + * maximum when it assembles the result. + */ + TRANSCRIBE_ERR_UNSUPPORTED_TIMESTAMPS = 12, + /* + * Returned by transcribe_run when the caller's abort callback + * returns true during the run. Partial results from chunks that + * completed before the abort are preserved on the session and + * readable via the normal result accessors; transcribe_was_aborted + * distinguishes partial-from-abort from complete. + */ + TRANSCRIBE_ERR_ABORTED = 13, + /* + * Returned by every entry point that takes a caller-owned size- + * aware struct (params, stream params, capabilities out, timings + * out, family extension) when the supplied struct_size is zero or + * smaller than the minimum the call path requires. Distinct from + * INVALID_ARG so bindings can match on "caller forgot to call the + * transcribe_*_init function" specifically. + */ + TRANSCRIBE_ERR_BAD_STRUCT_SIZE = 14, + /* Reserved; not currently returned. */ + TRANSCRIBE_ERR_UNSUPPORTED_PNC = 15, + /* Reserved; not currently returned. */ + TRANSCRIBE_ERR_UNSUPPORTED_ITN = 16, + /* + * Returned by transcribe_run / transcribe_run_batch when the input + * audio is longer than the loaded model can process in a single + * decode. Hard-context-cap families (LLM-style decoders: qwen3_asr, + * canary_qwen, funasr_nano, granite, granite_nar, voxtral, cohere, + * canary) reject an over-length clip UP FRONT — before the decode + * (and, where the binding limit is the encoder's positional table, + * before the encoder) — by comparing the audio's prefill token count + * against the model's context window. The usable ceiling is published + * as transcribe_capabilities::max_audio_ms, so a caller can size input + * before calling rather than discovering the limit on failure. + * + * Distinct from INVALID_ARG so a caller can tell "audio too long for + * this model" from a malformed argument. This is the "couldn't start" + * signal; the symmetric "started, couldn't finish" outcome is + * TRANSCRIBE_ERR_OUTPUT_TRUNCATED. + * + * Chunked / unbounded families normally report max_audio_ms == 0 and + * have no practical input limit. Whisper and parakeet never return this + * for length. voxtral_realtime is the exception: it still reports + * unbounded because its wall is far beyond practical clips, but + * transcribe_run / transcribe_run_batch return this if input crosses the + * decoder's absolute position cap. + * + * See docs/input-limits.md for the full contract. + */ + TRANSCRIBE_ERR_INPUT_TOO_LONG = 17, + /* + * Returned by transcribe_run when the decode stopped because it hit + * the model's context / generation budget BEFORE the model emitted + * end-of-stream — i.e. the transcript is incomplete. This is the + * "started, couldn't finish" counterpart to INPUT_TOO_LONG, and it is + * a hard non-OK status by design: a truncated transcript must not be + * mistaken for a complete one. + * + * The partial transcript IS preserved and readable through the normal + * result accessors (transcribe_full_text, segments, words, tokens), + * exactly like TRANSCRIBE_ERR_ABORTED — a caller that wants the partial + * output reads it after checking the status. transcribe_was_truncated() + * remains as supplemental state (it is true whenever this status is + * returned). Unlike INPUT_TOO_LONG this cannot be predicted from input + * length (it depends on how long the transcript runs), so it is + * reported after the fact rather than gated up front. + * + * In transcribe_run_batch this is a PER-UTTERANCE status: the whole- + * batch call still returns TRANSCRIBE_OK and the truncated utterance + * carries this code in transcribe_batch_status(session, i), the same + * way per-utterance INVALID_ARG / INPUT_TOO_LONG are reported. + * + * Streaming does NOT use this code: an active stream is incremental and + * has its own terminal-state machine (see transcribe_stream_*). + * See docs/input-limits.md for the full contract. + */ + TRANSCRIBE_ERR_OUTPUT_TRUNCATED = 18, +} transcribe_status; + +/* + * Returns a statically allocated, NUL-terminated description of a status + * code. The returned pointer must not be freed and is valid for the + * lifetime of the process. + * + * The parameter is `int` (not `transcribe_status`) for two reasons: + * 1. It matches the convention of `strerror(int)` and lets callers pass + * an arbitrary integer without forming a bogus enum value. + * 2. Casting an out-of-range integer to a C++ enum without a fixed + * underlying type is undefined behavior. Taking an int means the + * "out of range returns 'unknown status'" contract can be exercised + * portably without tripping UBSan. + * + * Any value of transcribe_status converts implicitly to int at the call + * site, so existing C and C++ callers continue to compile unchanged. + */ +TRANSCRIBE_API const char * transcribe_status_string(int status); + +/* ----------------------------------------------------------------------- */ +/* Version */ +/* ----------------------------------------------------------------------- */ +/* + * Runtime version of the loaded native library. The numeric components are + * also available at compile time as TRANSCRIBE_VERSION_MAJOR/MINOR/PATCH (and + * the composed string as TRANSCRIBE_VERSION) near the top of this header. + * + * Both return borrowed pointers into static storage: never free them, and + * treat them as valid for the life of the process. + */ +/* "MAJOR.MINOR.PATCH", e.g. "0.1.0". Equals the TRANSCRIBE_VERSION macro the + * caller compiled against; a mismatch means the header and the linked library + * disagree. */ +TRANSCRIBE_API const char * transcribe_version(void); +/* Short git commit the library was built from, or "unknown" when the build + * tree carried no git metadata (e.g. an unpacked source tarball). */ +TRANSCRIBE_API const char * transcribe_version_commit(void); + +/* ----------------------------------------------------------------------- */ +/* ABI metadata */ +/* ----------------------------------------------------------------------- */ +/* + * The native library's sizeof/alignof for the public structs that cross the + * ABI. A binding (Python ctypes, Rust, Swift, ...) declares its own view of + * each struct, then verifies that view against these values before it + * constructs any real instance. This is the safe alternative the "Params" + * section calls for: query the size up front instead of calling a *_init() + * function on a buffer that might be smaller than the library expects. + * + * `which` selects a struct by transcribe_abi_struct. An unknown id (e.g. a + * newer binding asking an older library about a struct it predates) returns 0, + * which the binding MUST treat as "cannot verify," never as "size 0." The enum + * is append-only; do not renumber existing values. + */ +typedef enum { + TRANSCRIBE_ABI_MODEL_LOAD_PARAMS = 0, + TRANSCRIBE_ABI_SESSION_PARAMS = 1, + TRANSCRIBE_ABI_RUN_PARAMS = 2, + TRANSCRIBE_ABI_STREAM_PARAMS = 3, + TRANSCRIBE_ABI_CAPABILITIES = 4, + TRANSCRIBE_ABI_TIMINGS = 5, + TRANSCRIBE_ABI_SEGMENT = 6, + TRANSCRIBE_ABI_WORD = 7, + TRANSCRIBE_ABI_TOKEN = 8, + TRANSCRIBE_ABI_STREAM_UPDATE = 9, + TRANSCRIBE_ABI_STREAM_TEXT = 10, + TRANSCRIBE_ABI_SESSION_LIMITS = 11, + TRANSCRIBE_ABI_EXT = 12, + TRANSCRIBE_ABI_DEVICE_INFO = 13, + TRANSCRIBE_ABI_SPEAKER_SEGMENT = 14, +} transcribe_abi_struct; + +/* sizeof / alignof of the selected public struct, or 0 for an unknown id. + * For every struct that carries a struct_size field, the size returned here + * equals the value its transcribe_*_init() stamps into struct_size. */ +TRANSCRIBE_API size_t transcribe_abi_struct_size(transcribe_abi_struct which); +TRANSCRIBE_API size_t transcribe_abi_struct_align(transcribe_abi_struct which); + +/* ----------------------------------------------------------------------- */ +/* Logging */ +/* ----------------------------------------------------------------------- */ + +/* + * This enum is the source of truth for callers. ggml's log levels do NOT + * share these numeric values (its DEBUG/INFO/WARN/ERROR ordering differs), + * so the internal pass-through layer maps ggml levels onto this enum + * before any message reaches the installed callback; callers only ever + * see these values. CONT marks a fragment continuing the previous message + * (ggml emits these for progress output); hosts that want one line per + * message may join CONT fragments onto the preceding text. + */ +typedef enum { + TRANSCRIBE_LOG_LEVEL_NONE = 0, + TRANSCRIBE_LOG_LEVEL_INFO = 1, + TRANSCRIBE_LOG_LEVEL_WARN = 2, + TRANSCRIBE_LOG_LEVEL_ERROR = 3, + TRANSCRIBE_LOG_LEVEL_DEBUG = 4, + TRANSCRIBE_LOG_LEVEL_CONT = 5, /* continue previous line */ +} transcribe_log_level; + +typedef void (*transcribe_log_callback)(transcribe_log_level level, const char * msg, void * userdata); + +/* + * Global log sink. Three states: + * + * never called library messages that warrant surfacing go to + * stderr (the dev/CLI default); ggml's internal + * logging keeps its own stderr default. + * cb != NULL every library message AND ggml's internal + * diagnostics (mapped onto transcribe_log_level) + * route to the callback. Caveat: ggml compiles + * its per-module load-failure chatter OUT of + * release builds, so the reliable "why am I not + * on the GPU?" signals are the one-line device + * summary transcribe_init_backends() emits + * through this sink after each fresh scan, and + * the transcribe_backend_* device accessors. + * cb == NULL logging explicitly disabled: library and ggml + * messages are dropped, nothing goes to stderr. + * + * The callback must not throw. If it does anyway, the exception is contained + * at the emission site, the message is dropped, and a note is written to + * stderr. + * + * Call once at process startup (see the threading contract above). + */ +TRANSCRIBE_API void transcribe_log_set(transcribe_log_callback cb, void * userdata); + +/* ----------------------------------------------------------------------- */ +/* Task / timestamps */ +/* ----------------------------------------------------------------------- */ + +typedef enum { + TRANSCRIBE_TASK_TRANSCRIBE = 0, + TRANSCRIBE_TASK_TRANSLATE = 1, +} transcribe_task; + +/* + * Timestamp policy: transcribe_run_params_init() requests AUTO, the richest + * supported output. AUTO requests the richest granularity compatible with the model and + * the other selected run tasks (for example, a prompt-selected diarization + * task may not compose with the model's separate timestamp task). + * The dispatcher never rejects AUTO, and the per-family run() handler + * resolves it to the finest granularity the model can actually + * produce when it assembles the result. A non-AUTO request is treated + * as a ceiling: if the request is finer than the model's max, + * transcribe_run returns TRANSCRIBE_ERR_UNSUPPORTED_TIMESTAMPS. If the + * request is coarser-or-equal, the family handler emits only that + * granularity and any finer per-run data is elided. The actual + * granularity returned by a run is reported by + * transcribe_returned_timestamp_kind(session). + */ +typedef enum { + TRANSCRIBE_TIMESTAMPS_NONE = 0, + TRANSCRIBE_TIMESTAMPS_AUTO = 1, + TRANSCRIBE_TIMESTAMPS_SEGMENT = 2, + TRANSCRIBE_TIMESTAMPS_WORD = 3, + TRANSCRIBE_TIMESTAMPS_TOKEN = 4, +} transcribe_timestamp_kind; + +/* + * KV cache type for the encoder's flash-attention path. + * + * AUTO: f16 when model weights are quantized, f32 when weights are f32. + * Best default — matches the bandwidth profile of the model. + * F32: full-precision KV. Maximum accuracy, highest bandwidth. + * F16: half-precision KV. Halves attention bandwidth with minimal + * precision loss (~3 decimal digits). Best for bandwidth-bound + * backends (integrated GPUs, CPU). + */ +typedef enum { + TRANSCRIBE_KV_TYPE_AUTO = 0, + TRANSCRIBE_KV_TYPE_F32 = 1, + TRANSCRIBE_KV_TYPE_F16 = 2, +} transcribe_kv_type; + +/* + * Punctuation + capitalization (PNC) toggle on the run params. + * + * Each family that exposes a runtime PNC toggle reads this field; families + * that do not (whisper, parakeet, ...) ignore it. The dispatcher emits a + * WARN-level log message when a non-DEFAULT value is set against a model + * for which transcribe_model_supports(model, TRANSCRIBE_FEATURE_PNC) + * returns false, then proceeds with the model's default behavior. Use + * that probe to pre-check. + * + * DEFAULT (0): the family's shipped default. Zero-init via + * transcribe_run_params_init() gives this value. The family + * picks the value its model card published WER numbers + * against (canary: pnc on; others: family-specific). + * OFF: explicitly disable runtime PNC. Supporting families + * emit lowercase, de-punctuated text. Non-supporting + * families ignore (with WARN). + * ON: explicitly enable runtime PNC. Supporting families + * emit punctuated, capitalized text. Non-supporting + * families ignore (with WARN). + */ +enum transcribe_pnc_mode { + TRANSCRIBE_PNC_MODE_DEFAULT = 0, + TRANSCRIBE_PNC_MODE_OFF = 1, + TRANSCRIBE_PNC_MODE_ON = 2, +}; + +/* + * Inverse text normalization (ITN) toggle on the run params. + * + * Symmetric semantics to transcribe_pnc_mode but for ITN — the + * transformation that renders numbers, dates, currencies, etc. in formal + * form ("twenty twenty four" → "2024", "ten dollars" → "$10"). Each + * family that exposes a runtime ITN toggle reads this field; families + * that do not ignore it. Non-DEFAULT values against a model for which + * transcribe_model_supports(model, TRANSCRIBE_FEATURE_ITN) returns false + * emit a WARN and proceed with default behavior. + * + * DEFAULT (0): family default. Zero-init gives this value. + * OFF: explicit ITN off. Supporting families emit verbatim + * spoken-form text. Non-supporting families ignore (WARN). + * ON: explicit ITN on. Supporting families apply ITN. + * Non-supporting families ignore (WARN). + * + * Some families bundle PNC and ITN (e.g. their ITN toggle also flips + * punctuation/casing as a side effect). See the family doc for the + * observed bundling. + */ +enum transcribe_itn_mode { + TRANSCRIBE_ITN_MODE_DEFAULT = 0, + TRANSCRIBE_ITN_MODE_OFF = 1, + TRANSCRIBE_ITN_MODE_ON = 2, +}; + +/* + * Speaker-diarization toggle on the run params. + * + * Symmetric semantics to transcribe_pnc_mode / transcribe_itn_mode but + * for speaker attribution. Families whose model emits speaker-attributed + * output read this field; families that do not ignore it. Non-DEFAULT + * values against a model for which + * transcribe_model_supports(model, TRANSCRIBE_FEATURE_DIARIZATION) + * returns false emit a WARN and proceed with default behavior. + * + * When ON resolves, the model's speaker markers are parsed into + * structured results: segment rows carry speaker_id and the + * speaker-segment accessors (transcribe_n_speaker_segments / + * transcribe_get_speaker_segment) are populated, with the markers + * stripped from the text. How a family produces markers is + * family-specific: moss always emits them (parsing is pure host-side + * post-processing), while granite-speech-4.1-2b-plus emits them only + * when its prompt requests the speaker-attribution task, so there ON + * changes the instruction the model is given. See each family doc. + * + * DEFAULT (0): library default: OFF for every family. Zero-init gives + * this value. + * OFF: no speaker attribution. Families where diarization is a + * requested task do not request it. Families such as moss + * whose model always emits inline metadata still strip that + * metadata so full_text remains a clean transcript. + * ON: request (if needed) and parse speaker markers into + * segment speaker_id + speaker segments. + */ +enum transcribe_diarize_mode { + TRANSCRIBE_DIARIZE_MODE_DEFAULT = 0, + TRANSCRIBE_DIARIZE_MODE_OFF = 1, + TRANSCRIBE_DIARIZE_MODE_ON = 2, +}; + +/* ----------------------------------------------------------------------- */ +/* Handles */ +/* ----------------------------------------------------------------------- */ + +struct transcribe_model; +struct transcribe_session; + +/* ----------------------------------------------------------------------- */ +/* Family extensions */ +/* ----------------------------------------------------------------------- */ + +/* + * Generic header for every typed family extension struct. The kind tag + * names the family schema (one FourCC-style 32-bit value per schema, + * allocated in docs/extension-kinds.md); the size is the caller's + * sizeof of the full extension struct, captured in the caller's + * translation unit so a newer library only reads what fits. + * + * Each family declares its extension struct in + * include/transcribe/.h with `struct transcribe_ext ext` as + * field 0 and a transcribe___ext_init() function that + * fills `ext.size`, `ext.kind`, and per-field defaults from the + * caller's perspective. `ext.size` is `uint64_t` so the family + * extension layout is platform-invariant across 32/64-bit ABIs; + * transcribe_ext_check() also takes `uint64_t` for `min_size`. + * + * Pointer-to-first-member is well-defined: because `ext` sits at + * field 0, the address of the family struct equals the address of its + * embedded transcribe_ext, and family handlers can cast the generic + * pointer back to the typed family struct after a successful + * transcribe_ext_check. + * + * Include transcribe/extensions.h to pull in every family extension + * header shipped by this install. Binding generators should usually + * point at that umbrella header; direct C/C++ callers that only need + * the generic ABI can keep including transcribe.h. See + * docs/extension-kinds.md for the registered family extension headers + * and kind values. + */ +struct transcribe_ext { + uint64_t size; + uint32_t kind; +}; + +/* + * Validate the universal shape of an extension before the family casts + * its pointer to the typed struct. + * + * ext == NULL -> TRANSCRIBE_OK (family decides + * what NULL means; usually "defaults"). + * ext->size < sizeof(transcribe_ext) + * -> TRANSCRIBE_ERR_BAD_STRUCT_SIZE. + * ext->kind != expected_kind -> TRANSCRIBE_ERR_INVALID_ARG. + * ext->size < min_size -> TRANSCRIBE_ERR_BAD_STRUCT_SIZE. + * otherwise -> TRANSCRIBE_OK. + * + * The family handler owns `expected_kind` (its own TRANSCRIBE_EXT_KIND_* + * constant) and `min_size` (typically sizeof of the kind's minimum + * supported layout). The helper exists so every family rejects malformed + * input identically. + */ +TRANSCRIBE_API transcribe_status transcribe_ext_check(const struct transcribe_ext * ext, + uint32_t expected_kind, + uint64_t min_size); + +/* + * Extension slot — the API surface a typed family extension is pointed + * at. Slot acceptance is a model-and-slot concern; the dispatcher + * validates `slot` matches the call site before delegating to the + * family. A stream-only extension pointed at transcribe_run_params is + * INVALID_ARG, not silently-ignored. Add new slots by appending to + * this enum (SESSION / MODEL_LOAD / TOKENIZE would each be one new + * value, no new probe function); do not renumber. + */ +typedef enum { + /* transcribe_run_params::family */ + TRANSCRIBE_EXT_SLOT_RUN = 0, + /* transcribe_stream_params::family */ + TRANSCRIBE_EXT_SLOT_STREAM = 1, +} transcribe_ext_slot; + +/* + * Returns true when the loaded model variant accepts the named + * extension kind in the given slot. The probe is per-loaded-model- + * variant AND per-slot — a family may ship multiple extension kinds + * that apply to different variants (e.g. parakeet streaming has + * cache-aware and chunked-attention variants whose stream knobs are + * different kinds), and the same kind may only be legal on one slot + * (a streaming extension on the RUN slot is not "ignored"; it is + * rejected by transcribe_run with INVALID_ARG). + * + * Returns false if model is NULL, if the model's family has no + * extension surface for the requested slot at all, or if the kind is + * unknown for that slot. The dispatcher's call-site validation always + * routes through this probe with the correct slot for the entry point. + * + * Callers should write intent-first probes ("does this model accept + * the parakeet stream kind on the STREAM slot?"), not discovery loops + * over every known kind. See docs/extension-kinds.md for the registered + * kinds and their slots. + */ +TRANSCRIBE_API bool transcribe_model_accepts_ext_kind(const struct transcribe_model * model, + transcribe_ext_slot slot, + uint32_t kind); + +/* ----------------------------------------------------------------------- */ +/* Params */ +/* ----------------------------------------------------------------------- */ + +/* + * Backend request. + * + * AUTO Pick the best available backend. Takes the first GPU device + * that successfully initializes, probing every discrete GPU + * before any integrated GPU; within a tier, devices are tried + * in ggml's device registry order — which is build-time + * prioritized (Metal on Apple, Vulkan / CUDA / ROCm / SYCL on + * Linux, …). An integrated GPU is selected only when no + * discrete GPU initializes. Host-memory accelerators (BLAS, + * AMX, …) are additionally layered onto the scheduler when + * present — they run on the same memory as the CPU backend + * and are orthogonal to the GPU/CPU split. Always succeeds: + * CPU is the final fallback when no GPU initializes. + * + * CPU Strict CPU only. No GPU, no IGPU, and no host-memory + * accelerators (BLAS/AMX). This is the right choice for + * numerical reference runs, cross-platform determinism, and + * for exercising the pure-CPU kernels under test. Always + * succeeds. + * + * CPU_ACCEL CPU primary plus host-memory accelerators (BLAS/AMX/… + * whatever ggml registers as ACCEL). primary_kind stays + * Cpu so any CPU-keyed policy still triggers, but mat-mul- + * shaped graph nodes that fit the accel backend's gates + * (large F32 src1, ne ≥ 32) get dispatched there. Useful + * when GPU is unavailable or undesired but you still want + * production-grade CPU throughput. Requires the build to + * include the relevant accel backend (e.g. GGML_BLAS=ON). + * Falls back to plain CPU semantics if no accel device is + * registered. Always succeeds. + * + * METAL Require the Metal backend. Returns TRANSCRIBE_ERR_BACKEND + * if Metal is not available in this build. Host-memory + * accelerators are still layered on when present. + * + * VULKAN Require the Vulkan backend. Returns TRANSCRIBE_ERR_BACKEND + * if Vulkan is not available in this build. Host-memory + * accelerators are still layered on when present. + * + * CUDA Require the NVIDIA CUDA backend. Returns TRANSCRIBE_ERR_BACKEND + * if CUDA is not available in this build. + * + * ROCM Require the AMD ROCm backend. Returns TRANSCRIBE_ERR_BACKEND + * if ROCm is not available in this build. + * + * Callers that need to know which backend they actually landed on + * can query transcribe_model_backend() after load. + */ +typedef enum { + TRANSCRIBE_BACKEND_AUTO = 0, + TRANSCRIBE_BACKEND_CPU = 1, + TRANSCRIBE_BACKEND_METAL = 2, + TRANSCRIBE_BACKEND_VULKAN = 3, + TRANSCRIBE_BACKEND_CPU_ACCEL = 4, + TRANSCRIBE_BACKEND_CUDA = 5, + TRANSCRIBE_BACKEND_ROCM = 6, +} transcribe_backend_request; + +/* ----------------------------------------------------------------------- */ +/* Backend modules and device discovery */ +/* ----------------------------------------------------------------------- */ +/* + * In dynamic-backend builds (GGML_BACKEND_DL, selected by the + * TRANSCRIBE_GGML_BACKEND_DL build option), compute backends (CPU, Vulkan, + * CUDA, ...) are separate loadable modules shipped next to the library in a + * provider artifact directory, not compiled into it. A host (a Python + * wheel, a Rust crate, an app bundle) loads them ONCE, before the first + * model load, by pointing the library at that directory. In static builds + * the compiled-in backends are already registered, and this call is a + * harmless no-op for a directory containing no modules. + * + * The search is strictly package-local: only artifact_dir is scanned — + * never the executable directory, the working directory, or any system + * path. (ggml additionally honors the GGML_BACKEND_PATH environment + * variable as an explicit user override naming one out-of-tree backend + * module; an unset environment means a fully package-local load.) + * + * A module whose system dependencies are missing — e.g. the Vulkan module + * on a machine with no Vulkan loader, or with a loader but no driver — + * fails to load quietly and is skipped; the remaining backends keep + * working. That degradation is the designed behavior: ship Vulkan by + * default, fall back to CPU on machines that cannot run it. Use the device + * accessors below to see what actually registered, and + * transcribe_backend_available() to probe one kind. + * + * Idempotent per directory: repeat calls with an already-scanned directory + * return the SAME status as the first scan without re-loading — including + * the TRANSCRIBE_ERR_BACKEND case, which is therefore NOT retryable in + * this process (e.g. installing a driver after the first call requires a + * new process to pick up). Concurrent calls to this function are + * serialized against each other; "thread-safe" extends no further — the + * load mutates the global device registry, so it must complete before any + * thread calls the device accessors below or loads a model. That ordering + * is automatic under the supported usage (call once, before the first + * model load). + * + * artifact_dir is UTF-8, like every path crossing this API; on Windows + * it is converted to a wide path internally. + * + * Returns: + * TRANSCRIBE_ERR_INVALID_ARG artifact_dir is NULL or empty. + * TRANSCRIBE_ERR_FILE_NOT_FOUND artifact_dir is not an existing directory. + * TRANSCRIBE_ERR_BACKEND after loading, the process has zero + * registered compute devices (a dynamic + * build pointed at a directory with no + * usable modules: nothing could run). + * TRANSCRIBE_OK otherwise. + */ +TRANSCRIBE_API transcribe_status transcribe_init_backends(const char * artifact_dir); + +/* + * Package-local default for dynamic-backend builds. + * + * In a dynamic-backend build, resolves the directory containing the loaded + * libtranscribe itself and delegates to transcribe_init_backends(dir). This + * keeps the search package-local: it does NOT scan the executable directory, + * current working directory, or system paths. Ship backend modules next to + * libtranscribe for this helper to find them. + * + * In non-dynamic builds the compute backends are compiled in, so this is a + * no-op returning TRANSCRIBE_OK. + */ +TRANSCRIBE_API transcribe_status transcribe_init_backends_default(void); + +/* + * Opaque process-local compute-device handle. Handles are owned by the + * runtime, remain valid for the life of the process, and must not be freed. + * They may be compared for equality but are not persistent identifiers; use + * transcribe_device_get_info() and its device_id field for persistence. + */ +struct transcribe_device; +typedef struct transcribe_device * transcribe_device_t; + +/* + * Number of compute devices currently registered with the runtime + * (compiled-in backends plus any modules loaded by + * transcribe_init_backends). A device is something a model can be placed + * on: the CPU, an Apple GPU via Metal, a Vulkan GPU, ... + */ +TRANSCRIBE_API int transcribe_device_count(void); + +/* + * Return the registered device at `index`, or NULL when index is out of + * range. The returned handle is runtime-owned and process-local. + * + * IMPORTANT: NULL is also the automatic-selection sentinel in + * transcribe_model_load_params::device. Always check this return value before + * assigning it to model-load params; assigning an unchecked out-of-range + * result would request automatic selection rather than exact selection. + */ +TRANSCRIBE_API transcribe_device_t transcribe_device_get(int index); + +/* + * Device type: ggml's vendor-agnostic classification of a device, + * orthogonal to `kind` below (which carries the vendor: metal/vulkan/cuda/ + * rocm/...). Backends report this classification themselves, so treat it as a + * runtime hint about CPU/GPU/IGPU/ACCEL placement rather than a portable + * hardware-memory taxonomy. The numeric values mirror ggml's device-type + * enum. + */ +typedef enum { + TRANSCRIBE_DEVICE_TYPE_CPU = 0, /* CPU using system memory */ + TRANSCRIBE_DEVICE_TYPE_GPU = 1, /* backend-reported GPU */ + TRANSCRIBE_DEVICE_TYPE_IGPU = 2, /* backend-reported integrated GPU */ + TRANSCRIBE_DEVICE_TYPE_ACCEL = 3, /* host-memory accelerator (BLAS/AMX) */ +} transcribe_device_type; + +/* + * One registered compute device. + * + * name / description / kind / device_id are borrowed pointers into + * runtime-owned storage: valid for the life of the process, never freed by + * the caller. + * + * kind is the library's vendor classification, one of: "cpu", "accel" (a + * host-memory accelerator such as BLAS/AMX), "metal", "vulkan", "cuda", + * "rocm", "sycl", "gpu" (an unrecognized GPU), or "unknown". device_type is + * the orthogonal CPU/GPU/IGPU/ACCEL axis. + * + * device_id is a stable hardware identifier when the backend reports one + * (for PCI devices the lower-case bus id "domain:bus:device.function", e.g. + * "0000:c1:00.0"), or NULL when unknown (e.g. Metal). + * + * memory_total is the device's reported capacity in bytes. memory_free is a + * SNAPSHOT of available bytes at the moment this struct was filled; it goes + * stale the instant anything allocates — re-call the accessor to refresh it, + * as every fill re-queries the driver live. Both numbers are backend-defined + * and NOT comparable across kinds: on Apple unified memory `total` is the + * recommended max working-set size (not system RAM) and `free` nets out only + * this process's allocations; on a discrete GPU they are device-global; on + * the CPU they are system RAM. 0 means the backend does not report it. + */ +struct transcribe_device_info { + uint64_t struct_size; /* sizeof(*this); set by _init() */ + const char * name; /* ggml device name, e.g. "Metal" */ + const char * description; /* human-readable, e.g. "Apple M4 Max" */ + const char * kind; /* vendor kind string; see above */ + const char * device_id; /* stable hw id (PCI bus id) or NULL */ + uint64_t memory_total; /* reported capacity in bytes, or 0 */ + uint64_t memory_free; /* available bytes snapshot, or 0 */ + transcribe_device_type device_type; /* CPU/GPU/IGPU/ACCEL axis */ +}; + +TRANSCRIBE_API void transcribe_device_info_init(struct transcribe_device_info * p); + +/* + * Fill *out (initialized via transcribe_device_info_init) with information + * about `device`. memory_free is live as of this call; re-invoke to refresh it + * (e.g. to poll a device's available memory over time). + * + * Returns TRANSCRIBE_ERR_INVALID_ARG if device or out is NULL or device is + * not from this runtime's registry. Returns TRANSCRIBE_ERR_BAD_STRUCT_SIZE if + * out fails the struct-size check. + */ +TRANSCRIBE_API transcribe_status transcribe_device_get_info(transcribe_device_t device, + struct transcribe_device_info * out); + +/* + * Whether a backend request can be satisfied by some registered device: + * AUTO whenever any device exists; CPU and CPU_ACCEL when a CPU device + * exists; METAL / VULKAN / CUDA / ROCM when a device of that kind exists. + * Unknown or invalid request values answer false (never an error). This is the + * probe a binding uses to turn `backend="vulkan"` on a machine without + * Vulkan into a clear exception instead of a failed model load. + */ +TRANSCRIBE_API bool transcribe_backend_available(transcribe_backend_request kind); + +/* + * Return the compute device this loaded model is running on — the device + * that owns its weights and runs most of its graph. Returns NULL if model is + * NULL or has no resolved compute device. Pass the returned handle to + * transcribe_device_get_info() for metadata and a live memory snapshot. + */ +TRANSCRIBE_API transcribe_device_t transcribe_model_device(const struct transcribe_model * model); + +/* + * Initialization of caller-owned params structs. + * + * Every params struct below is initialized by its transcribe_*_init() + * function, which fills sensible defaults and the struct_size field: + * + * struct transcribe_run_params p; + * transcribe_run_params_init(&p); // defaults + struct_size + * p.timestamps = TRANSCRIBE_TIMESTAMPS_WORD; + * + * Defaults shortcut: pass NULL wherever a `const struct transcribe_*_params *` + * is accepted to get pure defaults without declaring a struct at all. + * NULL is the only defaults form. A struct with `struct_size == 0` — + * including `struct X p = {0};` — is REJECTED with + * TRANSCRIBE_ERR_BAD_STRUCT_SIZE; this is deliberate so that + * uninitialized stack memory whose struct_size byte happens to be zero + * cannot silently masquerade as "all defaults." The init function (or + * NULL) is the only safe calling convention. + * + * The init functions are one-argument by design: they assume the caller + * and library agree on the struct layout, which holds for the supported + * distribution model (the library is built/shipped with its consumers). + */ + +/* + * Model load params. + * + * backend: which backend to request. See transcribe_backend_request + * for the semantics of each value. Default is AUTO. + * + * device: NULL (the default) applies the backend's automatic policy. AUTO + * probes every discrete GPU before integrated GPUs and finally falls + * back to CPU; an explicit GPU backend picks the first matching + * device. A non-NULL handle selects that exact registered device, + * including the device returned at index 0. + * + * Exact selection never silently falls back to another primary + * device. With backend=AUTO, the selected device determines the + * backend. With an explicit backend, the device must match it. CPU + * and CPU_ACCEL accept an exact CPU device; ACCEL devices cannot be + * selected as a primary. Invalid, foreign, or mismatched handles are + * rejected with TRANSCRIBE_ERR_INVALID_ARG. + * + * Handles are process-local. Persist device_id (when available), then + * enumerate and resolve a fresh handle in each process. + */ +struct transcribe_model_load_params { + uint64_t struct_size; + transcribe_backend_request backend; + transcribe_device_t device; +}; + +TRANSCRIBE_API void transcribe_model_load_params_init(struct transcribe_model_load_params * params); + +/* + * Session init params. + * + * n_threads: number of CPU threads for ops that run on CPU. 0 means + * "library picks a sensible default". + * + * kv_type: data type for K/V activations in flash attention. + * AUTO (default) uses f16 for quantized models, f32 for f32. + * + * n_ctx: optional cap on the decoder context window, in tokens, for + * families with a decoder KV cache. It is a memory knob, not an + * accuracy knob. + * 0 (default): use the model's true maximum, read from GGUF + * metadata. This is the right value for almost + * every caller. + * > 0: lower the ceiling to bound KV-cache memory. A + * value above the model maximum is clamped DOWN + * to it — the knob can only narrow, never extend + * past what the model was trained to support. + * For decoder-context-bound families (audio-tokens + prompt + + * generation share one window), lowering n_ctx lowers the + * effective input limit. That effective limit is a per-session + * value: read it via transcribe_session_get_limits() + * (effective_max_audio_ms), NOT transcribe_capabilities:: + * max_audio_ms, which is model-level and reflects only the + * default context. For encoder-bound families, n_ctx may lower + * decoder KV memory / output budget without lowering the input- + * audio bound. Ignored by chunked / unbounded families (whisper, + * parakeet, voxtral_realtime) — they have no lowerable ceiling, so + * a non-zero n_ctx is a no-op there. A negative value returns + * TRANSCRIBE_ERR_INVALID_ARG. + */ +struct transcribe_session_params { + uint64_t struct_size; + int n_threads; + transcribe_kv_type kv_type; + int32_t n_ctx; +}; + +TRANSCRIBE_API void transcribe_session_params_init(struct transcribe_session_params * params); + +/* + * Per-run params. + * + * v1 accepts only 16 kHz mono float32 PCM. There is no caller-supplied + * sample rate; the library does not link a resampler; the caller is + * responsible for resampling external audio (e.g. with sox or ffmpeg) + * before calling transcribe_run. A future revision will introduce a + * caller-declared input rate, at which point TRANSCRIBE_ERR_SAMPLE_RATE + * (currently reserved) will become observable. + * + * task: TRANSCRIBE or TRANSLATE. The model must declare support + * for translate via its capabilities; otherwise the run + * returns TRANSCRIBE_ERR_UNSUPPORTED_TASK. + * + * timestamps: requested granularity. Default params request AUTO, + * which selects the finest granularity compatible with + * the model and other selected run tasks. + * + * pnc: punctuation+capitalization runtime toggle. See + * transcribe_pnc_mode. DEFAULT is always safe. Non-DEFAULT + * values against models for which + * transcribe_model_supports(model, TRANSCRIBE_FEATURE_PNC) is + * false emit a WARN and proceed with the model's default + * behavior. + * + * itn: inverse text normalization runtime toggle. See + * transcribe_itn_mode. DEFAULT is always safe. Non-DEFAULT + * values against models for which + * transcribe_model_supports(model, TRANSCRIBE_FEATURE_ITN) is + * false emit a WARN and proceed with the model's default + * behavior. + * + * diarize: speaker-attribution runtime toggle. See + * transcribe_diarize_mode. DEFAULT is always safe. Non-DEFAULT + * values against models for which + * transcribe_model_supports(model, TRANSCRIBE_FEATURE_DIARIZATION) + * is false emit a WARN and proceed with the model's default + * behavior. + * + * language: source language hint as a BCP-47-ish short code, or + * NULL to autodetect (only if the model supports it). + * + * target_language: target language for translation tasks, or NULL. + * + * String-pointer lifetime (language / target_language): caller-owned, and + * the library copies what it needs before the API call returns. This holds + * for transcribe_run / transcribe_run_batch (synchronous) AND for + * transcribe_stream_begin: the dispatcher copies these strings into + * session-owned storage at begin, so the caller may free its params — + * including the strings — the moment begin returns, even though the stream + * keeps running. Bindings rely on this: ctypes/FFI-managed string buffers + * die when the begin wrapper returns. + * + * keep_special_tags: keep special vocabulary tags (e.g. <|...|>) in the + * returned text fields. Default (false) strips them + * for clean transcripts; set true to keep the tags + * inline. Honored by the families whose models emit + * such tags (canary, parakeet, sensevoice); parakeet + * additionally includes/excludes the tag tokens in + * its public token rows. Most callers should prefer + * transcribe_raw_text(), which returns the pre- + * cleanup decode for EVERY family without giving up + * the clean transcribe_full_text. + * + * family: optional family-specific extension. NULL selects family + * defaults. The pointed-to object is caller-owned; the + * library copies any values it needs out of it before + * transcribe_run returns, so the caller may free the + * extension storage immediately after the call. Each + * family declares its typed extension struct in + * include/transcribe/.h with `struct transcribe_ext + * ext` as field 0. Use transcribe_model_accepts_ext_kind + * to probe whether the loaded model accepts a given kind + * before pointing `family` at it. + */ +struct transcribe_run_params { + uint64_t struct_size; + + transcribe_task task; + transcribe_timestamp_kind timestamps; + enum transcribe_pnc_mode pnc; + enum transcribe_itn_mode itn; + enum transcribe_diarize_mode diarize; + const char * language; + const char * target_language; + bool keep_special_tags; + const struct transcribe_ext * family; + + /* + * spec_k_drafts: n-gram-lookup speculative-decode draft length for the + * offline autoregressive decode step. Family-portable strategy knob; + * the family decides how K maps to its internal verify graph. + * + * Convention: + * -1: family default (each family picks its tuned K). + * 0: spec decoding explicitly disabled — standard 1-token-per-step + * autoregression. Use this for byte-equal reproduction of + * pre-spec behavior or when measuring baseline performance. + * >0: draft K tokens per verify pass. Practical range is 1..8; + * optimal K is hardware-dependent (compute-bound hardware + * prefers small K, bandwidth-bound prefers larger K — see + * docs/models/.md for per-family guidance). + * + * Families gate this via transcribe_capabilities::supports_spec_decode. + * Setting spec_k_drafts != -1 on a family with + * supports_spec_decode == false is silently ignored (the run proceeds + * as ordinary autoregression). Probe the capability bit if you want + * to know whether the field will take effect. + */ + int32_t spec_k_drafts; +}; + +TRANSCRIBE_API void transcribe_run_params_init(struct transcribe_run_params * params); + +/* ----------------------------------------------------------------------- */ +/* Capabilities */ +/* ----------------------------------------------------------------------- */ + +/* + * Capabilities are semantic model properties read from GGUF KV (with + * architecture defaults filling in absent keys). They are immutable + * after a successful transcribe_model_load_file() and never change + * based on backend fallback. + * + * languages is an array of n_languages NUL-terminated short codes, + * owned by the model. The pointer is valid until transcribe_model_free. + * + * max_timestamp_kind is the finest granularity the family's run() + * handler can produce. NONE means the model emits text but no + * alignment data; SEGMENT / WORD / TOKEN expose progressively finer + * alignment. The dispatcher rejects any transcribe_run request + * whose timestamps field is finer than this value. AUTO is not + * validated against this field: the dispatcher passes AUTO through + * unconditionally and the per-family run() handler resolves AUTO + * to its own max_timestamp_kind when it assembles the result. + */ +/* + * Capabilities are split by character, not by size: + * + * - Values and bools-that-gate-allied-data live on + * `transcribe_capabilities`. The struct holds data (sample rate, + * language list, max timestamp kind, streaming hints) plus the + * three "gate" bools whose meaning is inseparable from neighboring + * fields (supports_streaming gates the streaming hint trio; + * supports_translate gates the task enum and target_language + * semantics on transcribe_run_params; supports_language_detect gates + * the languages array and autodetect mode). + * + * - Unallied behavioral toggles (initial prompt, temperature + * fallback, long-form chunking, cancellation, PNC, ITN, and + * anything similar in the future) live behind the + * transcribe_model_supports() probe, keyed by transcribe_feature. + * A new feature is +1 enum value with no struct_size churn. + */ +struct transcribe_capabilities { + uint64_t struct_size; + + int32_t native_sample_rate; + int n_languages; + const char * const * languages; + transcribe_timestamp_kind max_timestamp_kind; + + /* + * Gate bools with allied struct data. Kept here so reading the + * struct gives a coherent picture of "what tasks does this model + * support and what data does it expose for them." + * + * supports_language_detect: gates the languages[] array's role. + * False means the autodetect path is unavailable; the listed + * languages (if any) are still the model's supported set when + * the caller passes a hint via transcribe_run_params::language. + * + * supports_translate: gates TRANSCRIBE_TASK_TRANSLATE on + * transcribe_run_params, plus the meaning of + * transcribe_run_params::target_language. Hard-error gate; the + * dispatcher rejects TRANSLATE against models with + * supports_translate == false. + * + * supports_streaming: gates the streaming entry points + * (transcribe_stream_*). Hard-error gate at + * transcribe_stream_begin. Streaming configuration and any + * latency hints are family-specific and live on the family + * stream extension, not on this struct. + */ + bool supports_language_detect; + bool supports_translate; + bool supports_streaming; + + /* + * supports_spec_decode: gates transcribe_run_params::spec_k_drafts. + * True means the family's offline (transcribe_run / transcribe_run_batch) + * path implements n-gram-lookup speculative decoding. A non-zero + * spec_k_drafts on a model with supports_spec_decode == false is + * silently ignored — the run proceeds as ordinary autoregression. This + * is a soft gate (no error) because spec is purely a performance + * strategy; callers can probe this bit if they want to know whether + * passing K will actually do anything. + */ + bool supports_spec_decode; + + /* + * Streaming timing hints intentionally do NOT live here. Streaming + * configuration is irreducibly family-specific (cache-aware + * right-context frames, buffered (left, chunk, right) tuples, + * autoregressive decode throttles), and any single generic number + * is a lossy projection of a family menu whose authoritative, + * selectable form lives in the family stream extension + * (include/transcribe/.h) and the family doc + * (docs/models/.md). supports_streaming above is the + * generic gate ("can this model stream?"); "how, and with what + * latency tradeoffs?" is answered by the family extension. If a real + * consumer ever needs a generic latency menu, the right shape is a + * dedicated streaming-preset enumeration query, not flat fields here. + */ + + /* + * max_audio_ms: the longest audio this model can process in one + * transcribe_run, in milliseconds of 16 kHz mono input. This is the + * single number a caller checks to size input before calling. + * + * 0 = no practical limit. The family chunks long audio internally + * (whisper), is otherwise unbounded by sequence length + * (parakeet), or has only an impractically large absolute safety + * cap (voxtral_realtime). A family-specific absolute cap may still + * reject input past the model's true wall; see docs/input-limits.md. + * >0 = a usable ceiling. Its meaning depends on the family's limit + * kind, but the number is honest in both cases: + * - hard-context-cap families: the bound the library ENFORCES + * up front. It is derived from the decoder context window + * minus the fixed prompt and a minimum generation reserve. + * A longer clip returns TRANSCRIBE_ERR_INPUT_TOO_LONG before + * the decode. This is a MODEL-level value reported at the + * model's default context (transcribe_session_params::n_ctx + * == 0); a session that lowers n_ctx lowers the effective + * limit below this number, but this field is not re-derived + * per session. + * - soft-window families (gigaam, sensevoice, medasr): the + * advisory window the model was trained on. Longer input is + * accepted but emits a WARN and may be less accurate; it is + * not rejected. + * + * Zero-init via transcribe_capabilities_init() yields 0; a family that + * does not set it is therefore reported as unbounded. See + * docs/input-limits.md for the full contract. + */ + int64_t max_audio_ms; + + /* + * translate_target_languages / n_translate_target_languages: the set + * of target language codes accepted for TRANSCRIBE_TASK_TRANSLATE — + * the target-side twin of `languages` (which is the valid set for the + * transcribe-side `language` hint). supports_translate gates whether + * translation runs at all; this list narrows WHICH targets are valid. + * A TRANSLATE run whose run_params::target_language is non-NULL and + * absent from a non-empty list is rejected with + * TRANSCRIBE_ERR_UNSUPPORTED_LANGUAGE before any decode. + * + * n == 0 / NULL is "not advertised" — an information gap, not a claim + * of zero targets — exactly the convention an empty `languages` uses. + * GGUFs predating stt.translation.target_languages report 0 here even + * when supports_translate is true; the gate is then inert and any + * family-level target/pair checks (e.g. canary's pivot pairs) still + * apply on top. + */ + int n_translate_target_languages; + const char * const * translate_target_languages; +}; + +TRANSCRIBE_API void transcribe_capabilities_init(struct transcribe_capabilities * out); + +/* + * Read model capabilities into caller-owned storage. The caller + * initializes *out_caps via transcribe_capabilities_init() (zero-fill); + * the library writes only the fields that fit and leaves tail bytes + * beyond the caller's struct_size untouched. + * + * Returns: + * TRANSCRIBE_ERR_INVALID_ARG model or out_caps is NULL. + * TRANSCRIBE_ERR_BAD_STRUCT_SIZE out_caps->struct_size is 0 or + * smaller than the library's minimum. + * + * Pointer fields written by the library (e.g. `languages`) point at + * model-owned storage and remain valid until transcribe_model_free(). + * The output struct itself is caller-owned. + */ +TRANSCRIBE_API transcribe_status transcribe_model_get_capabilities(const struct transcribe_model * model, + struct transcribe_capabilities * out_caps); + +/* + * Per-feature capability probe. Pure yes/no advisories that don't + * gate any allied struct data live here instead of as boolean fields + * on transcribe_capabilities. Adding a feature is +1 enum value with + * no struct_size advance, which makes the surface easy to grow + * without ABI churn. + * + * Feature meanings: + * + * INITIAL_PROMPT The model accepts a free-text or token + * prompt to bias decoding. Today: whisper + * only; reached via transcribe_whisper_run_ext. + * + * TEMPERATURE_FALLBACK The model runs a multi-tier temperature loop + * with metric-driven fallback. Today: whisper. + * + * LONG_FORM The model exposes a long-form chunker that + * handles audio longer than its native window + * in a single transcribe_run call. Today: + * whisper. + * + * CANCELLATION transcribe_set_abort_callback fires between + * chunks / decode steps and the family hook + * honors it. + * + * PNC The model exposes a runtime toggle for + * punctuation + capitalization via + * transcribe_run_params::pnc. False does NOT mean + * the model produces no PNC — only that the + * caller cannot control whether PNC appears. + * Non-DEFAULT pnc against a model where this + * returns false emits a WARN and proceeds. + * + * ITN The model exposes a runtime toggle for + * inverse text normalization via + * transcribe_run_params::itn. Same "can-control + * vs does-emit" distinction as PNC; non-DEFAULT + * itn against an unsupported model warns. + * + * DIARIZATION The model emits speaker-attributed output and + * the runtime exposes a toggle via + * transcribe_run_params::diarize. When true, + * segment rows carry speaker_id and the + * speaker-segment accessors are populated (mode + * permitting). False does NOT mean multi-speaker + * audio is mis-transcribed — only that speaker + * attribution is unavailable. Non-DEFAULT diarize + * against a model where this returns false emits + * a WARN and proceeds. + * + * Returns false on NULL model or unknown feature enum. + */ +typedef enum { + TRANSCRIBE_FEATURE_INITIAL_PROMPT = 0, + TRANSCRIBE_FEATURE_TEMPERATURE_FALLBACK = 1, + TRANSCRIBE_FEATURE_LONG_FORM = 2, + TRANSCRIBE_FEATURE_CANCELLATION = 3, + TRANSCRIBE_FEATURE_PNC = 4, + TRANSCRIBE_FEATURE_ITN = 5, + TRANSCRIBE_FEATURE_DIARIZATION = 6, +} transcribe_feature; + +TRANSCRIBE_API bool transcribe_model_supports(const struct transcribe_model * model, transcribe_feature feature); + +/* + * Stable identifier strings read from GGUF and runtime state. + * + * transcribe_model_arch_string(): the general.architecture key, + * e.g. "parakeet". Immutable for the lifetime of the model. + * + * transcribe_model_variant_string(): the stt.variant key, e.g. + * "tdt-0.6b-v2". Immutable for the lifetime of the model. May be + * an empty string if the underlying GGUF did not carry a variant + * and the architecture handler had no default to substitute. + * + * transcribe_model_backend(): the runtime backend currently bound + * to this model, e.g. "cpu", "metal", "vulkan", "cuda", "ROCm". This is + * the mechanism for detecting CPU fallback when GPU was requested. + * + * Returns an empty string when no runtime backend is currently + * bound to the model. This covers both (a) model == NULL, the + * safe-sentinel pattern used throughout this header, and (b) a + * model that has been loaded but is not yet bound to a runtime + * backend - for example, a model whose on-disk metadata and + * vocabulary have been parsed but whose compute graph has not + * been materialized on any backend yet. Callers can distinguish + * the two by null-checking the model pointer. + * + * Backend is a runtime fact, reported separately from the + * model's semantic capabilities (see + * transcribe_model_get_capabilities), which never change based on + * backend state. + * + * All three return statically allocated or model-owned strings; the + * caller must not free them and they remain valid until the model is + * freed. + */ +TRANSCRIBE_API const char * transcribe_model_arch_string(const struct transcribe_model * model); +TRANSCRIBE_API const char * transcribe_model_variant_string(const struct transcribe_model * model); +TRANSCRIBE_API const char * transcribe_model_backend(const struct transcribe_model * model); + +/* + * Generic GGUF string-metadata getter, modeled on llama_model_meta_val_str. + * Looks up a scalar-string metadata key written by the converter and returns + * its value; this is how human-facing identity is read rather than a typed + * accessor per field. Common keys: + * + * "general.name" friendly label, e.g. "Whisper Large v3" + * "general.license" SPDX expression, e.g. "apache-2.0" (or "other") + * "general.license.name" human-friendly license name + * "general.license.link" URL to the license text + * "general.author", "general.organization", "general.repo_url", ... + * + * Returns a model-owned string (valid until the model is freed; do not free + * it) or an empty string "" when model is NULL, key is NULL, or the key is + * absent. Only scalar-string KVs are exposed (numeric hyperparameters and + * arrays such as the token list are not). There is no fallback to the variant + * slug — for that, use transcribe_model_variant_string(). + */ +TRANSCRIBE_API const char * transcribe_model_meta_val_str(const struct transcribe_model * model, const char * key); + +/* ----------------------------------------------------------------------- */ +/* Lifecycle */ +/* ----------------------------------------------------------------------- */ + +/* + * Load a GGUF model from disk. + * + * path is UTF-8, like every path crossing this API; on Windows it is + * converted to a wide path internally, so non-ASCII paths load + * correctly regardless of the process ANSI code page. + * + * params may be NULL for all library defaults. To customize, initialize + * a struct with transcribe_model_load_params_init() and set fields. A + * struct with struct_size == 0 (including `{0}`) is rejected with + * BAD_STRUCT_SIZE — defaults come from NULL, never from an uninitialized + * struct. See the "Params" section at the top of this header. + * + * On success, *out_model is set and the caller owns it. On failure, + * *out_model is set to NULL and a non-OK status is returned. + */ +TRANSCRIBE_API transcribe_status transcribe_model_load_file(const char * path, + const struct transcribe_model_load_params * params, + struct transcribe_model ** out_model); + +/* + * Free a model. Only valid after every session derived from this model + * has been freed and no thread is still using the model. Passing NULL + * is a no-op. + */ +TRANSCRIBE_API void transcribe_model_free(struct transcribe_model * model); + +/* + * Initialize a transcription session bound to a loaded model. Multiple + * sessions may be created from the same model in parallel; each session + * is single-threaded. The model must outlive every session derived from + * it (the session borrows, but does not own, the model). + * + * params may be NULL for library defaults, or initialize a struct with + * transcribe_session_params_init(). See transcribe_model_load_file. + */ +TRANSCRIBE_API transcribe_status transcribe_session_init(struct transcribe_model * model, + const struct transcribe_session_params * params, + struct transcribe_session ** out_session); + +/* + * Free a session. + * + * If the session was created via transcribe_open it owns the model it + * loaded; transcribe_session_free destroys the session and then frees + * the model in the same call. Sessions created via the two-step + * transcribe_session_init path borrow their model; this function leaves + * that model alone. + * + * The caller does not need to know which path created the session — both + * are handled correctly. Passing NULL is a no-op. + */ +TRANSCRIBE_API void transcribe_session_free(struct transcribe_session * session); + +/* + * Convenience: load a model and open a session against it in one call, + * for the common "I just want to transcribe one stream" case. Bundles + * transcribe_model_load_file + transcribe_session_init. + * + * The returned session OWNS the model it loaded; transcribe_session_free + * (or its alias transcribe_close) frees both. To share one model across + * multiple sessions (e.g. one per thread), use the two-step + * transcribe_model_load_file + transcribe_session_init API instead — + * that is the only reason to reach past this function. + * + * load_params and session_params may each be NULL for library defaults. + * + * On success *out_session is set and the caller owns it. On failure + * *out_session is set to NULL and a non-OK status is returned (the same + * status transcribe_model_load_file / transcribe_session_init would + * return; no partial state leaks). + */ +TRANSCRIBE_API transcribe_status transcribe_open(const char * path, + const struct transcribe_model_load_params * load_params, + const struct transcribe_session_params * session_params, + struct transcribe_session ** out_session); + +/* + * Deprecated alias for transcribe_session_free. Both honor owns_model and + * free the owned model when present, so a caller may use either with any + * session. New code should call transcribe_session_free directly. Passing + * NULL is a no-op. + */ +TRANSCRIBE_API void transcribe_close(struct transcribe_session * session); + +/* + * Borrow the model bound to a session. The returned pointer is owned by + * the library — do not free it — and remains valid for the session's + * lifetime. This lets convenience-path (transcribe_open) callers reach + * model introspection such as transcribe_model_backend() and the + * capabilities query without holding a separate model handle. Returns + * NULL if session is NULL. + */ +TRANSCRIBE_API const struct transcribe_model * transcribe_get_model(const struct transcribe_session * session); + +/* ----------------------------------------------------------------------- */ +/* Run */ +/* ----------------------------------------------------------------------- */ + +/* + * Run one batch transcription. + * + * pcm: mono float32 PCM samples in [-1.0, 1.0] at 16 kHz. + * n_samples: number of samples in pcm. Must be strictly positive; + * a non-positive count returns TRANSCRIBE_ERR_INVALID_ARG + * (same rule as transcribe_stream_feed). + * params: run params, or NULL for defaults. + * + * v1 supports only 16 kHz mono float32 PCM and does not link a + * resampler; the caller is responsible for resampling external audio + * before calling this function. + * + * On success, results are populated on the session and may be read via + * the accessors below. Calling transcribe_run() again replaces the + * previous result on the same session. + * + * TRANSCRIBE_ERR_BACKEND from this function (and batch/streaming run calls) + * is recoverable in-process: free the session and model, reload with + * transcribe_model_load_params::backend = TRANSCRIBE_BACKEND_CPU, and retry. + */ +TRANSCRIBE_API transcribe_status transcribe_run(struct transcribe_session * session, + const float * pcm, + int n_samples, + const struct transcribe_run_params * params); + +/* ----------------------------------------------------------------------- */ +/* Batch run (offline) */ +/* ----------------------------------------------------------------------- */ + +/* + * Run N offline transcriptions in one call. + * + * This is a throughput entry point: families with a batched compute path + * (today: see the per-family docs) process all N utterances in a single + * device dispatch, which can roughly double throughput on a GPU that a + * single utterance underutilizes. Families without a batched path still + * work — the library falls back to running each utterance in turn — so + * every model accepts this call; only the speedup is family-dependent. + * + * This is the offline counterpart to transcribe_run; it is NOT a streaming + * primitive. For a well-formed single utterance it is equivalent to + * transcribe_run with n == 1, but the two are NOT exactly equivalent for + * malformed input: transcribe_run(NULL pcm / n_samples <= 0) fails top-level + * with TRANSCRIBE_ERR_INVALID_ARG and leaves the previous result untouched, + * whereas a malformed single utterance inside a batch (with valid top-level + * args) is a per-utterance failure — the call returns TRANSCRIBE_OK, + * transcribe_batch_status(session, 0) carries the INVALID_ARG, and the + * previous result has already been replaced by the (failed) batch. + * + * session: a session in a non-ACTIVE-stream state. As with + * transcribe_run, a session is single-threaded: do not call + * this concurrently with any other call on the same session. + * pcm: array of n pointers, each to mono float32 PCM in [-1, 1] + * at 16 kHz. pcm[i] is the i-th utterance. + * n_samples: array of n sample counts; n_samples[i] is the length of + * pcm[i]. + * n: number of utterances. Must be strictly positive. + * params: run params shared by every utterance in the batch, or + * NULL for defaults. v1 applies one params (task, language + * hint, timestamps, ...) to the whole batch; per-utterance + * params is a future extension. + * + * Variable-length utterances are handled by the library: a batched family + * pads the batch to its longest utterance internally and masks the padding. + * Grouping utterances of similar length per call ("bucketing") minimizes + * wasted compute but is not required. + * + * Result model: results are read back with the transcribe_batch_* accessors + * below, indexed by utterance. transcribe_batch_n_results() returns the + * count. The legacy single-result accessors (transcribe_full_text, etc.) + * alias utterance 0 after a batch run. A subsequent transcribe_run clears + * the batch results. + * + * Return value is the WHOLE-BATCH status: + * TRANSCRIBE_OK the dispatch ran. Per-utterance success + * or failure is read via + * transcribe_batch_status(session, i); a + * malformed single utterance (pcm[i] NULL + * or n_samples[i] <= 0) fails only that + * utterance and is reported there. + * TRANSCRIBE_ERR_INVALID_ARG session / pcm / n_samples NULL, n <= 0, + * or the session is in an ACTIVE stream. + * TRANSCRIBE_ERR_BAD_STRUCT_SIZE params->struct_size below the minimum. + * TRANSCRIBE_ERR_NOT_IMPLEMENTED the model has no run path at all. + * ... plus the same shared-param rejections as transcribe_run + * (TRANSCRIBE_ERR_UNSUPPORTED_TASK / _TIMESTAMPS / _LANGUAGE), which + * apply to the whole batch and are reported before any utterance + * runs (the previous result snapshot is preserved on these). + * TRANSCRIBE_ERR_ABORTED the abort callback fired. Completed + * utterances are retained with their real + * status; any utterance that does NOT + * complete reports TRANSCRIBE_ERR_ABORTED, + * whether it was aborted mid-decode or was + * never retained as a completed result by the + * batch path. Dispatcher-synthesized slots + * specifically mean "did not complete because + * the batch was aborted" — not that the + * utterance itself reached an abort + * checkpoint. As on a successful return, + * transcribe_batch_n_results is n (one slot + * per input utterance). + */ +TRANSCRIBE_API transcribe_status transcribe_run_batch(struct transcribe_session * session, + const float * const * pcm, + const int * n_samples, + int n, + const struct transcribe_run_params * params); + +/* ----------------------------------------------------------------------- */ +/* Cancellation */ +/* ----------------------------------------------------------------------- */ + +/* + * Abort callback. Polled between chunks AND between decode steps (after + * each token). Abort latency is therefore one decode step — typically + * 10-50 ms on CPU, sub-ms on Metal. Mid-encoder abort is not supported + * today (would require ggml hooks that do not exist). + * + * Returning true aborts the in-flight run: transcribe_run returns + * TRANSCRIBE_ERR_ABORTED and result accessors (transcribe_full_text, + * transcribe_n_segments, etc.) expose the partial content from chunks + * that completed before abort. Use transcribe_was_aborted(session) to + * distinguish partial-from-abort from complete. + * + * The next successful transcribe_run() replaces the result atomically, + * as today. + * + * The callback fires on the run thread. If the caller wants to trigger + * abort from another thread, the common pattern is to store an + * std::atomic inside user_data and have the callback load it. + */ +typedef bool (*transcribe_abort_callback)(void * user_data); + +/* + * Install or clear the abort callback for a session. Passing cb=NULL + * clears any previously installed callback. Safe to call before the + * first transcribe_run. No-op if session is NULL. + * + * Not thread-safe with respect to an in-flight transcribe_run on the + * same session — the session is single-threaded-at-a-time per the + * threading contract above. Callers that need to trigger abort from + * another thread should do so by flipping state inside the callback's + * user_data, not by swapping the callback itself. + */ +TRANSCRIBE_API void transcribe_set_abort_callback(struct transcribe_session * session, + transcribe_abort_callback cb, + void * user_data); + +/* + * True if the most recent transcribe_run was aborted by the installed + * callback returning true. Reset to false at the top of each + * transcribe_run. Returns false if session is NULL. + */ +TRANSCRIBE_API bool transcribe_was_aborted(const struct transcribe_session * session); + +/* + * Supplemental flag for output truncation. True if the most recent decode + * stopped at the model's context / generation cap before end-of-stream, + * leaving the transcript incomplete. The partial transcript is preserved + * and readable through the normal result accessors. Reset to false at the + * start of each new decode — transcribe_run, transcribe_run_batch, and + * transcribe_stream_begin (the same lifecycle as transcribe_was_aborted). + * Returns false if session is NULL. + * + * Two paths set it, and they differ in whether a status also reports it: + * + * - Offline (transcribe_run / transcribe_run_batch): the flag is true + * exactly when the run returned TRANSCRIBE_ERR_OUTPUT_TRUNCATED (or, in + * a batch, when a per-utterance status is OUTPUT_TRUNCATED), so the run + * status is the authoritative signal and this accessor is a convenience + * for a caller that has lost it. + * + * - Streaming (transcribe_stream_*): OUTPUT_TRUNCATED is NOT used. An + * active stream has its own terminal-state machine, and stream_feed / + * stream_finalize return the status of that step, not a verdict on the + * whole transcript — so they return TRANSCRIBE_OK even when the stream + * reached its absolute position cap (forcing the stream to FAILED would + * discard the committed text the caller has been consuming). There, this + * flag is the ONLY signal of truncation: a streaming caller must check + * it after finalize. + * + * Distinct from the "couldn't start" rejection: input that cannot fit at + * all is rejected before the decode with TRANSCRIBE_ERR_INPUT_TOO_LONG; + * output truncation cannot be predicted from input length (it depends on + * how long the transcript runs), so it is reported after the fact. A WARN + * is also logged. See docs/input-limits.md. + */ +TRANSCRIBE_API bool transcribe_was_truncated(const struct transcribe_session * session); + +/* ----------------------------------------------------------------------- */ +/* Session limits */ +/* ----------------------------------------------------------------------- */ + +/* + * Effective limits for a specific session. This is the session-level companion + * to the model-level transcribe_capabilities::max_audio_ms: for families whose + * audio consumes decoder context, it accounts for the session's + * transcribe_session_params::n_ctx cap. For encoder-bound families, n_ctx may + * lower decoder KV memory without lowering the input-audio bound. + * + * effective_n_ctx: the decoder context cap, in tokens, in force for + * this session: model_max_ctx when n_ctx == 0, + * else min(n_ctx, model_max_ctx). 0 means the + * family has no context cap (chunked / unbounded). + * + * effective_max_audio_ms: the longest audio this session accepts before + * TRANSCRIBE_ERR_INPUT_TOO_LONG, in milliseconds + * of 16 kHz mono input. For decoder-context-bound + * families this is derived from effective_n_ctx; for + * encoder-bound families it remains the encoder + * input bound even when n_ctx lowers decoder memory. + * 0 means no practical limit. Advisory / + * representative in the same way as + * transcribe_capabilities::max_audio_ms (it assumes + * a representative prompt; the exact per-call bound + * shifts slightly with the prompt). + * + * max_kv_bytes: the worst-case decoder KV-cache bytes for ONE + * utterance at effective_n_ctx, for memory + * budgeting. It is exact for the session's kv_type + * (the families resolve AUTO and F16 to f16 KV and + * use f32 only for an explicit F32 request, so this + * reflects 2 or 4 bytes/element accordingly). It is + * the ceiling, not the amount allocated for any + * single run (the cache grows to fit each input). + * One scaling caveat the caller applies itself: it + * is per-utterance, so transcribe_run_batch + * allocates roughly batch_size x this. 0 if the + * family has no decoder KV cache. + */ +struct transcribe_session_limits { + uint64_t struct_size; + int32_t effective_n_ctx; + int64_t effective_max_audio_ms; + int64_t max_kv_bytes; +}; + +TRANSCRIBE_API void transcribe_session_limits_init(struct transcribe_session_limits * out); + +/* + * Read the effective limits for a session into caller-owned storage. The + * caller initializes *out via transcribe_session_limits_init(); the library + * writes only the prefix that fits in both the caller's struct_size and the + * library's view. + * + * Returns: + * TRANSCRIBE_ERR_INVALID_ARG session or out is NULL. + * TRANSCRIBE_ERR_BAD_STRUCT_SIZE out->struct_size is 0 or below the + * library minimum. + * TRANSCRIBE_OK otherwise. + */ +TRANSCRIBE_API transcribe_status transcribe_session_get_limits(const struct transcribe_session * session, + struct transcribe_session_limits * out); + +/* ----------------------------------------------------------------------- */ +/* Streaming */ +/* ----------------------------------------------------------------------- */ + +/* + * Streaming is a mode on transcribe_session, not a separate handle. A + * session is in exactly one of four lifecycle states at any time, and + * the result accessors (transcribe_full_text, segments, words, tokens) + * return the current raw model snapshot of the active stream. + * + * Text semantics during an active stream: + * + * full_text = raw current model hypothesis + * committed_text = append-only UI/input prefix + * tentative_text = volatile raw suffix after raw_tentative_start_bytes + * display_text = committed_text + tentative_text + * + * The committed-count accessors (transcribe_stream_n_committed_*) + * remain as low-level raw row boundary hints for consumers that inspect + * tokens/words/segments. They are monotonic commitment high-water marks + * and may exceed the current raw row counts after the model revises or + * shrinks its latest hypothesis; clamp them to transcribe_n_* before + * indexing current raw rows. UI consumers should prefer + * transcribe_stream_get_text(), because committed_text is independent from + * the raw hypothesis and may not be a prefix of full_text after the model + * revises already-committed text. + * + * Streaming timestamp note: unlike transcribe_run(), an active stream + * may keep token rows populated (transcribe_n_tokens > 0) even when the + * run was started with timestamps == TRANSCRIBE_TIMESTAMPS_NONE, because + * the committed/tentative progress boundary is token-indexed and the + * incremental-commit accessors need the token rows to exist. The + * timestamps request is validated against the model's capabilities at + * transcribe_stream_begin (a request finer than the model's max is + * rejected there), but it is NOT an instruction to elide structural + * token rows from the streaming result. Whether those rows carry real + * per-token timestamps is a separate question answered by the family: + * a family with no token-level alignment (e.g. moonshine_streaming) can + * honestly populate token rows for the progress boundary while + * transcribe_returned_timestamp_kind() still reports NONE. Always use + * transcribe_returned_timestamp_kind() to discover the finest timestamp + * fields actually populated; do not infer it from transcribe_n_tokens(). + * + * Cancellation reuses the existing session abort callback. The + * callback is polled at chunk boundaries and decode-step boundaries + * during feed/finalize; on cancellation the active call returns + * TRANSCRIBE_ERR_ABORTED, partial results remain readable, and the + * stream transitions to FAILED (transcribe_was_aborted distinguishes + * caller cancellation from other terminal statuses). + * + * Multiple concurrent streams: use the existing model/session + * threading model. Create multiple contexts from one loaded model, + * then run at most one stream per session. The model is shared and + * read-only; each session owns its own stream state. + * + * Streaming params (transcribe_stream_params) carry an optional, + * kind-tagged family extension pointer. Family-specific extension + * structs live in include/transcribe/.h and are reached + * intent-first: a caller that wants to set a parakeet streaming knob + * probes transcribe_model_accepts_ext_kind for the parakeet stream + * kind on TRANSCRIBE_EXT_SLOT_STREAM and, if accepted, points + * transcribe_stream_params::family at an extension struct initialized + * via that family's transcribe___ext_init() function. + */ + +enum transcribe_stream_state { + TRANSCRIBE_STREAM_IDLE = 0, + TRANSCRIBE_STREAM_ACTIVE = 1, + TRANSCRIBE_STREAM_FINISHED = 2, + TRANSCRIBE_STREAM_FAILED = 3, +}; + +/* + * Generic public commitment policy. Families still own model-specific + * streaming mechanics (chunk sizes, cache windows, decoder throttles); + * this enum controls when the session's UI-facing committed_text grows. + * + * AUTO: Use the dispatcher-selected stable-prefix implementation + * for the model family. Current streaming families use a + * family-provided boundary; future families fall back to + * the generic text-agreement implementation until they are + * assigned an explicit default. + * ON_FINALIZE: During feed calls committed_text stays empty and the raw + * hypothesis is exposed as tentative_text. Finalize commits + * the final raw text because no earlier bytes were committed. + * STABLE_PREFIX: + * Commit only a raw prefix accepted by the selected stable- + * prefix implementation. Known families may use token-id or + * native commit boundaries; others use generic text + * agreement. committed_text is append-only and finalize + * never rewrites previously committed bytes. + */ +typedef enum { + TRANSCRIBE_STREAM_COMMIT_AUTO = 0, + TRANSCRIBE_STREAM_COMMIT_ON_FINALIZE = 1, + TRANSCRIBE_STREAM_COMMIT_STABLE_PREFIX = 2, +} transcribe_stream_commit_policy; + +/* + * Streaming run params. + * + * struct_size: sizeof(*this) captured by the caller. + * Initialized via transcribe_stream_params_init(). + * + * family: Optional family-specific extension. NULL + * selects family defaults. The pointed-to + * object is caller-owned and the library + * copies any values it needs out of it + * before transcribe_stream_begin returns; + * the caller may free the extension storage + * immediately after begin. Each family + * declares its typed extension struct in + * include/transcribe/.h with + * `struct transcribe_ext ext` as field 0. + * Use transcribe_model_accepts_ext_kind to + * probe whether the loaded model accepts a + * given kind before pointing `family` at it. + * + * commit_policy: Generic UI-facing commitment policy. + * Zero-init/default is AUTO. + * + * stable_prefix_agreement_n: For STABLE_PREFIX and AUTO implementations + * based on repeated hypotheses, number of + * consecutive text or token-id hypotheses that + * must agree on a prefix before it is appended + * to committed_text. Families with native + * irreversible commit boundaries may ignore + * this field. 0 selects the library default + * (currently 3): a prefix must reproduce + * across three consecutive hypotheses before + * it is committed. + */ +struct transcribe_stream_params { + uint64_t struct_size; + const struct transcribe_ext * family; + transcribe_stream_commit_policy commit_policy; + uint32_t stable_prefix_agreement_n; +}; + +TRANSCRIBE_API void transcribe_stream_params_init(struct transcribe_stream_params * params); + +/* + * Optional per-call change metadata returned by feed/finalize. + * + * struct_size Caller's sizeof(*this). Initialized via + * transcribe_stream_update_init() (zero-fill). + * result_changed True when any observable property of the + * snapshot changed on this call: the text vectors + * (tokens / words / segments / full_text), the + * committed/tentative text views, committed raw + * row boundary hints, or a lifecycle transition + * that semantically closes the stream. + * Inspect the accessors after the call to read + * the new snapshot. Treat result_changed as a + * strict subset of `revision changed`: if + * `revision` advanced you can assume + * `result_changed` is also true. + * is_final True only on the finalize call's update. Set + * by the dispatcher after the family hook + * returns; family hooks cannot override. + * revision Monotonic snapshot counter. Advances whenever + * any observable property of the result changes + * under the rules described for `result_changed`, + * including finalize cases where text is unchanged + * but the committed/tentative stream view or + * lifecycle moved. Mirrors + * transcribe_stream_revision(session) after the + * call returns. UI consumers should diff against + * the previous value rather than treating each + * bump as "text changed." + * input_received_ms Total audio received by the stream since begin. + * audio_committed_ms Family-reported audio progress / drain hint. + * It is not a byte boundary into committed_text. + * ON_FINALIZE reports 0 during feed calls because + * committed_text is empty until finalize. + * buffered_ms Audio still buffered inside the family's + * streaming state (frontend carry + + * lookahead/right-context requirement). Caller + * may use this as a "drain" hint. + * committed_changed True when committed_text changed on this call. + * committed_text is append-only for the life of + * the stream; a true value means bytes were appended + * or, for ON_FINALIZE with no prior committed bytes, + * the final raw text was installed. + * tentative_changed True when tentative_text changed on this call. + * + * update is nullable. Passing NULL means the caller will inspect the + * session directly (revision + committed-count accessors). Audio + * cursor fields are only available via this struct. + * + * Zero-means-absent contract: every field on this struct is designed + * so the zero value encodes "absent / unknown / false / none." This + * is what makes the transcribe_stream_update_init() zero-fill safe for + * forward ABI: a new caller paired with an older library reads tail + * fields the older library never wrote, and those bytes must remain + * zero. + */ +struct transcribe_stream_update { + uint64_t struct_size; + bool result_changed; + bool is_final; + int32_t revision; + int64_t input_received_ms; + int64_t audio_committed_ms; + int64_t buffered_ms; + bool committed_changed; + bool tentative_changed; +}; + +TRANSCRIBE_API void transcribe_stream_update_init(struct transcribe_stream_update * out); + +/* + * UI-facing stream text snapshot. + * + * full_text is the raw current model hypothesis, matching + * transcribe_full_text(session). During ACTIVE it may rewrite anywhere. + * + * committed_text is the API-stable display/input prefix. Once bytes are + * exposed through committed_text they are never rewritten by later feed or + * finalize calls on this stream. They remain until begin/reset/free. + * + * tentative_text is the volatile raw suffix after raw_tentative_start_bytes. + * It may be replaced on every feed. UI consumers that want no committed + * flicker render committed_text + tentative_text; consumers that want the + * model's current truth render full_text. + * + * committed_text is best-effort, not a correctness guarantee. For models + * that re-attend over a growing audio context (e.g. moonshine_streaming), + * the raw hypothesis can revise a byte that was already committed. Because + * committed_text is append-only it is NOT rolled back: when full_text + * diverges before the committed boundary, committed_text + tentative_text + * no longer reconstruct full_text, so the committed/tentative seam is + * transiently incoherent mid-stream. full_text is the authoritative raw + * hypothesis at all times; committed_text trades that authority for a + * flicker-free, append-only prefix. Consumers needing exact truth should + * render full_text. Raising stable_prefix_agreement_n makes a wrong commit + * less likely at the cost of committing later. + * + * On successful stream_finalize, tentative_text is empty. For ON_FINALIZE, + * committed_text becomes final full_text. For policies that committed bytes + * before finalize, finalize only appends compatible remaining suffix bytes; + * if final full_text disagrees with already committed_text, committed_text + * is not silently rewritten and full_text remains the authoritative raw + * final transcript. + * + * All pointers are borrowed session-owned storage. They remain valid until + * the next transcribe_stream_feed / transcribe_stream_finalize / + * transcribe_stream_begin / transcribe_stream_reset / transcribe_run / + * transcribe_session_free on the same session. + */ +struct transcribe_stream_text { + uint64_t struct_size; + const char * full_text; + uint64_t full_text_bytes; + const char * committed_text; + uint64_t committed_text_bytes; + const char * tentative_text; + uint64_t tentative_text_bytes; + uint64_t raw_tentative_start_bytes; +}; + +TRANSCRIBE_API void transcribe_stream_text_init(struct transcribe_stream_text * out); + +TRANSCRIBE_API transcribe_status transcribe_stream_get_text(const struct transcribe_session * session, + struct transcribe_stream_text * out); + +/* + * Begin a streaming run on session. + * + * run_params and stream_params may be NULL for library defaults + * (transcribe task, no timestamps, no family extension). To customize, + * initialize via transcribe_run_params_init() / transcribe_stream_params_init() + * and set fields. The "session==NULL is INVALID_ARG, struct_size==0 is + * BAD_STRUCT_SIZE" rules from the top-of-header "Params" block apply. + * + * On success the session transitions IDLE/FINISHED/FAILED -> ACTIVE + * and every result-visible field is cleared: text, segments, words, + * tokens, detected language, stream revision, committed counts, + * audio cursors, timings, was_aborted, and last stream status. The + * installed abort callback, the loaded model, and any reusable + * stream buffers held by the family are preserved. + * + * Returns: + * TRANSCRIBE_ERR_INVALID_ARG session NULL, session in ACTIVE + * state, out-of-range enum in + * run_params, or an extension whose + * kind is unknown to / not accepted + * by the loaded model on the + * TRANSCRIBE_EXT_SLOT_STREAM slot. + * TRANSCRIBE_ERR_BAD_STRUCT_SIZE non-null run_params or stream_params + * has a struct_size below what this + * entry point requires (including + * struct_size == 0), or + * stream_params->family is too small + * to contain a transcribe_ext header, + * or (for a family that implements the + * preflight, see below) a family + * extension whose size is below its + * kind's minimum. All of these are + * reported before the previous result + * snapshot is cleared. + * Use the init functions to avoid. + * TRANSCRIBE_ERR_NOT_IMPLEMENTED Model has no streaming hooks or + * capabilities advertise + * supports_streaming == false. + * All three of stream_begin / + * stream_feed / stream_finalize must + * be wired for begin to succeed; + * stream_reset is optional. + * TRANSCRIBE_ERR_UNSUPPORTED_TASK TRANSCRIBE_TASK_TRANSLATE; v1 + * streaming is transcribe-only. + * TRANSCRIBE_ERR_UNSUPPORTED_TIMESTAMPS + * Requested granularity finer than + * the model's max_timestamp_kind. + * TRANSCRIBE_ERR_UNSUPPORTED_LANGUAGE + * run_params->language not in the + * model's declared language list. + * + * Failure semantics — three checkpoints, split by whether the previous + * result snapshot survives: + * + * 1. Dispatcher preflight. Top-level argument checks run first: + * NULL session, ACTIVE-state rejection, struct_size failures, + * out-of-range run_params enums, unknown / unaccepted extension + * kind for the STREAM slot, TRANSLATE, granularity finer than the + * model's max, and unsupported language. On failure the call + * returns without entering ACTIVE and WITHOUT clearing the + * previous result snapshot. Lifecycle state is untouched. + * + * 2. Family preflight (optional, via the family's stream_validate + * hook). For families that implement it, family-specific value + * checks — e.g. a streaming extension field outside the model's + * trained menu, or below its kind's minimum size — run here, still + * BEFORE the snapshot is cleared and BEFORE the lifecycle moves. + * A non-OK return is delivered to the caller with the previous + * result snapshot fully preserved and no FAILED transition, so a + * caller-side config typo cannot destroy the prior utterance's + * transcript. A family that does not implement stream_validate + * performs these checks in stream_begin. + * + * 3. Post-clear stream_begin. Once the preflight checks pass, the dispatcher + * clears the result snapshot, enters ACTIVE, and calls the + * family's stream_begin hook. Any non-OK status from this point + * (config a non-preflighting family only catches here, allocation + * failure, etc.) transitions the stream to FAILED and preserves + * the status in transcribe_stream_last_status. The result snapshot + * in this case is whatever the hook wrote before failing — + * typically empty. + * + * Params lifetime: everything the caller passes is copied out before this + * function returns. run_params strings (language / target_language) are + * copied into session-owned storage, and family extensions follow their + * documented copy-out contract — so the caller may free both params + * structs and every pointer inside them the moment begin returns, while + * the stream stays ACTIVE. Nothing handed to begin needs to outlive it. + */ +TRANSCRIBE_API transcribe_status transcribe_stream_begin(struct transcribe_session * session, + const struct transcribe_run_params * run_params, + const struct transcribe_stream_params * stream_params); + +/* + * Feed PCM into the active stream. 16 kHz mono float32, same as + * transcribe_run. + * + * pcm must be non-null and n_samples must be strictly greater than + * zero. Polling the stream without supplying audio is unsupported — + * use the stream accessors (transcribe_stream_revision, + * transcribe_stream_get_text, transcribe_stream_n_committed_*, + * transcribe_stream_last_status, transcribe_stream_get_state) to inspect + * state without progressing the stream. + * + * update is nullable; when non-null the dispatcher zero-initializes + * it before calling the family hook, so callers may rely on a clean + * struct even on early-return error paths. + * + * Returns TRANSCRIBE_ERR_INVALID_ARG when session is NULL, when state + * is not ACTIVE, or on malformed input. A terminal non-OK status + * from the family hook transitions the stream to FAILED and is + * preserved in transcribe_stream_last_status. TRANSCRIBE_ERR_ABORTED + * is a terminal status; transcribe_was_aborted distinguishes it from + * other failures. + */ +TRANSCRIBE_API transcribe_status transcribe_stream_feed(struct transcribe_session * session, + const float * pcm, + int n_samples, + struct transcribe_stream_update * update); + +/* + * Signal end of input. Flushes buffered audio, satisfies right- + * context / lookahead requirements, and emits remaining text. + * + * On success the session transitions ACTIVE -> FINISHED. On a + * terminal non-OK status the session transitions to FAILED and the + * status is preserved in transcribe_stream_last_status. + * + * Returns TRANSCRIBE_ERR_INVALID_ARG when session is NULL or state is + * not ACTIVE. + */ +TRANSCRIBE_API transcribe_status transcribe_stream_finalize(struct transcribe_session * session, + struct transcribe_stream_update * update); + +/* + * Abandon the current stream without finalizing. + * + * Always returns the session to IDLE and clears every result-visible + * field, every stream-snapshot counter, last_status, and the family's + * per-utterance streaming state. Allocated stream buffers held by the + * family are preserved for reuse — full memory release is + * transcribe_session_free. + * + * Reset from IDLE clears any stale result/snapshot from a previous + * stream or run. Reset from FINISHED or FAILED clears the surviving + * result text and snapshot counters as well. + * + * No-op if session is NULL. + */ +TRANSCRIBE_API void transcribe_stream_reset(struct transcribe_session * session); + +/* + * Current stream lifecycle state. Returns TRANSCRIBE_STREAM_IDLE if + * session is NULL. + */ +TRANSCRIBE_API enum transcribe_stream_state transcribe_stream_get_state(const struct transcribe_session * session); + +/* + * Monotonic snapshot revision counter. Advances whenever any observable + * property of the streaming result changes: text vectors (tokens / + * words / segments / full_text), committed-prefix counts, or a + * lifecycle transition that promotes the snapshot (finalize moves + * tentative output to committed even when the text is unchanged). + * Reset to 0 by begin / reset / run. Returns 0 if session is NULL. + * + * Diff against the previous value when redrawing — a bump does not + * by itself mean the visible text changed; it means "something about + * the snapshot moved, re-read the accessors." + */ +TRANSCRIBE_API int transcribe_stream_revision(const struct transcribe_session * session); + +/* + * Low-level raw row boundary hints. In older streaming APIs these were + * the primary committed-prefix interface. They remain useful for callers + * that inspect raw token/word/segment rows, but the strong API-stable + * display contract is now transcribe_stream_get_text(). A committed text + * prefix may be independent from the current raw rows after the model + * revises already-committed bytes. + * + * Return 0 if session is NULL. + */ +TRANSCRIBE_API int transcribe_stream_n_committed_segments(const struct transcribe_session * session); +TRANSCRIBE_API int transcribe_stream_n_committed_words(const struct transcribe_session * session); +TRANSCRIBE_API int transcribe_stream_n_committed_tokens(const struct transcribe_session * session); + +/* + * Last terminal status of the stream. Preserves the failing status + * after a feed/finalize call transitioned the stream to FAILED, so + * the caller can inspect it after the fact. Reset to TRANSCRIBE_OK + * by begin / reset / run. + * + * Returns TRANSCRIBE_OK if session is NULL or no terminal status has + * been recorded on the current stream. + */ +TRANSCRIBE_API transcribe_status transcribe_stream_last_status(const struct transcribe_session * session); + +/* ----------------------------------------------------------------------- */ +/* Tokenization */ +/* ----------------------------------------------------------------------- */ + +/* + * Tokenize plain UTF-8 text into the model's vocabulary, with + * add_special_tokens=False semantics (no BOS/EOS, no <|...|> markers). + * Special tokens present in the input are not recognized and will be + * BPE-encoded piece-by-piece, which is almost never what the caller + * wants — render special tokens via direct id paths and pass only the + * plain-text fragments through this function. + * + * Buffer contract (mirrors whisper.cpp's n_max convention): + * + * >= 0 Number of tokens written to `tokens[0..return-1]`. + * negative of N Buffer too small; N tokens were needed. Caller + * reallocates and retries. `tokens` content is + * unspecified in this case (may be partially + * written). + * INT_MIN Hard error: model or text is NULL, the model's + * tokenizer does not support encode for this + * vocabulary (e.g. a SentencePiece family without + * encode wired), or the vocab file is malformed. + * + * Plumbed per family: + * - Whisper (GPT-2 byte-level BPE) + * - Qwen3-ASR (Qwen2 byte-level BPE) + * - Parakeet (SentencePiece; currently returns INT_MIN) + * - Cohere ASR (SentencePiece; currently returns INT_MIN) + */ +TRANSCRIBE_API int transcribe_tokenize(const struct transcribe_model * model, + const char * text, + int32_t * tokens, + size_t n_max); + +/* ----------------------------------------------------------------------- */ +/* Timings */ +/* ----------------------------------------------------------------------- */ + +/* + * Per-call timings collected by the most recent transcribe_run on a + * session, plus the wall-clock load time of the model the session is + * bound to. All values are in milliseconds. + * + * load_ms one-time wall-clock cost of transcribe_model_load_file, + * captured on the model and surfaced via every session + * derived from it. Not affected by transcribe_reset_timings + * (it's a model-scoped fact). + * mel_ms time to compute the mel front-end on the most recent + * transcribe_run. Reset to 0 by transcribe_reset_timings. + * encode_ms time to run the encoder forward pass on the most recent + * transcribe_run. + * decode_ms time to run the decoder (predictor + joint + token + * search) on the most recent transcribe_run. + * + * Output struct: caller initializes via transcribe_timings_init() (zero- + * fill); the library writes only the fields that fit within + * struct_size. Every field is a numeric value where zero encodes + * "unknown / not measured." + */ +struct transcribe_timings { + uint64_t struct_size; + float load_ms; + float mel_ms; + float encode_ms; + float decode_ms; +}; + +TRANSCRIBE_API void transcribe_timings_init(struct transcribe_timings * out); + +/* + * Read the current timings from a session into caller-owned storage. + * Safe to call before any transcribe_run; mel_ms / encode_ms / + * decode_ms will be 0. load_ms is non-zero as soon as the underlying + * model is loaded. + * + * Returns: + * TRANSCRIBE_ERR_INVALID_ARG session or out_timings is NULL. + * TRANSCRIBE_ERR_BAD_STRUCT_SIZE out_timings->struct_size is 0 or + * smaller than the library's minimum. + */ +TRANSCRIBE_API transcribe_status transcribe_get_timings(const struct transcribe_session * session, + struct transcribe_timings * out_timings); + +/* + * Pretty-print the current timings to the registered log callback at + * INFO level (or stderr if no callback is installed). No-op if session + * is NULL. + */ +TRANSCRIBE_API void transcribe_print_timings(const struct transcribe_session * session); + +/* + * Reset the per-run timing accumulators (mel_ms, encode_ms, + * decode_ms) to 0. Does NOT touch load_ms — that's a model-scoped + * fact. No-op if session is NULL. + */ +TRANSCRIBE_API void transcribe_reset_timings(struct transcribe_session * session); + +/* ----------------------------------------------------------------------- */ +/* Result accessors - top level */ +/* ----------------------------------------------------------------------- */ + +TRANSCRIBE_API const char * transcribe_full_text(const struct transcribe_session * session); + +/* + * The model's decoded output BEFORE family post-processing — inline + * diarization markers (moss `[0.48][S01]`, granite `[Speaker N]:`), + * timestamp/special tokens (whisper), language/event/emotion tags + * (sensevoice, parakeet, canary), chat-envelope prefixes (qwen3_asr), + * and whitespace trims are all still present. transcribe_full_text is + * always the clean transcript; this is the escape hatch for callers who + * want what the model actually emitted (debugging, custom parsing). + * Equal to full_text modulo whitespace for families that emit clean + * text natively. Session-owned; same lifetime as transcribe_full_text. + * Empty string before any successful run. + */ +TRANSCRIBE_API const char * transcribe_raw_text(const struct transcribe_session * session); +TRANSCRIBE_API transcribe_timestamp_kind transcribe_returned_timestamp_kind(const struct transcribe_session * session); +TRANSCRIBE_API int transcribe_n_segments(const struct transcribe_session * session); +TRANSCRIBE_API int transcribe_n_words(const struct transcribe_session * session); +TRANSCRIBE_API int transcribe_n_tokens(const struct transcribe_session * session); + +/* + * The language the model itself predicted on the most recent run, as a + * short ISO code ("en", "zh", "yue", "ja", "ko", ...). Returns an empty + * string when: + * - no successful run has happened on this session yet, or + * - the caller passed an explicit `params->language` hint (the + * library does not echo hints back through this field; callers + * already know what they asked for), or + * - the model does not support language detection (English-only + * Whispers, families without an LID head), or + * - the family's LID head produced a non-language sentinel for this + * audio (e.g. SenseVoice's <|nospeech|>). + * + * Returned pointer is owned by the session. See "Result text-pointer + * lifetime" at the top of this header for invalidation rules; in + * streaming mode every transcribe_stream_feed / _finalize call may + * invalidate it. + */ +TRANSCRIBE_API const char * transcribe_detected_language(const struct transcribe_session * session); + +/* ----------------------------------------------------------------------- */ +/* Result accessors - per-item rows */ +/* ----------------------------------------------------------------------- */ + +/* + * Per-item results are exposed as caller-owned copy-out structs. Three + * accessors (transcribe_get_segment / _word / _token) take a row index + * and a pointer to an init-function-initialized struct; the library + * writes the row's fields into the caller's buffer. + * + * `text` pointers in these structs are session-owned. The full lifetime + * contract is in the "Result text-pointer lifetime" block at the top of + * this header; in short: + * - One-shot path: valid until the next transcribe_run() / + * transcribe_stream_begin() / transcribe_stream_reset() / + * transcribe_session_free() call on the same session. + * - Streaming path: row `text` aliases the raw model snapshot and may + * be invalidated by every transcribe_stream_feed() / + * transcribe_stream_finalize() call. Copy it before the next feed if + * you need to hold it. Use transcribe_stream_get_text() for + * API-stable committed/provisional display text. + * + * Out-of-range index (i < 0 or i >= the corresponding transcribe_n_*()) + * returns TRANSCRIBE_OK with the caller's struct left as zero-init: + * `text == NULL`, every scalar 0, and (for tokens) `p == 0.0f`. There + * is no empty-string sentinel — bindings distinguish "row not present" + * from "row present with empty text" by null-checking `text`. + * + * Forward-ABI: struct_size in each row struct is captured at the + * caller's TU; the library writes only the fields that fit and never + * touches tail bytes beyond the caller's struct_size. New fields are + * appended at the end. + */ + +struct transcribe_segment { + uint64_t struct_size; + int64_t t0_ms; + int64_t t1_ms; + int first_word; + int n_words; + int first_token; + int n_tokens; + const char * text; /* session-owned; see lifetime note above */ + int32_t speaker_id; /* 1-based; 0 = no speaker attribution */ +}; + +TRANSCRIBE_API void transcribe_segment_init(struct transcribe_segment * out); + +struct transcribe_word { + uint64_t struct_size; + int64_t t0_ms; + int64_t t1_ms; + int seg_index; + int first_token; + int n_tokens; + const char * text; /* session-owned; see lifetime note above */ +}; + +TRANSCRIBE_API void transcribe_word_init(struct transcribe_word * out); + +/* + * transcribe_token::p is the per-token probability when the + * architecture produces one, or NaN when it does not. The semantic of + * "token probability" varies by family (joint softmax for transducer, + * per-step softmax for autoregressive, per-frame argmax probability + * for CTC) and callers should treat it as a confidence hint, not a + * calibrated probability. + * + * On out-of-range index `p` follows the zero-init rule (0.0f, not + * NaN); inspect `text != NULL` to distinguish a present row. + */ +struct transcribe_token { + uint64_t struct_size; + int id; + float p; + int64_t t0_ms; + int64_t t1_ms; + int seg_index; + int word_index; + const char * text; /* session-owned; see lifetime note above */ +}; + +TRANSCRIBE_API void transcribe_token_init(struct transcribe_token * out); + +/* + * Read one segment / word / token row into caller-owned storage. + * + * Returns: + * TRANSCRIBE_ERR_INVALID_ARG out is NULL. + * TRANSCRIBE_ERR_BAD_STRUCT_SIZE out->struct_size is 0 or smaller + * than the library's minimum. + * TRANSCRIBE_OK otherwise. The caller's struct is + * written when session is non-NULL, the + * session has a result, and i is in + * range; otherwise it stays as + * zero-initialized by INIT (text NULL, + * scalars 0). + */ +TRANSCRIBE_API transcribe_status transcribe_get_segment(const struct transcribe_session * session, + int i, + struct transcribe_segment * out); + +TRANSCRIBE_API transcribe_status transcribe_get_word(const struct transcribe_session * session, + int i, + struct transcribe_word * out); + +TRANSCRIBE_API transcribe_status transcribe_get_token(const struct transcribe_session * session, + int i, + struct transcribe_token * out); + +/* ----------------------------------------------------------------------- */ +/* Speaker-segment results (diarization) */ +/* ----------------------------------------------------------------------- */ + +/* + * "Who spoke when" rows, populated when the run resolved diarization ON + * for a model with TRANSCRIBE_FEATURE_DIARIZATION (see + * transcribe_diarize_mode). Rows are ordered by emission order of the + * model's speaker turns; rows MAY overlap in time (two speakers talking + * at once are two overlapping rows). A model that attributes text but + * carries no timing information reports t0_ms == t1_ms == 0 ("absent" + * per the zero-sentinel rule). + * + * These rows are the transcript-independent view of speaker activity. + * The transcript-attached view is transcribe_segment::speaker_id. + * + * p is the attribution confidence when the model produces one, or NaN + * when it does not (same convention as transcribe_token::p). On + * out-of-range index `p` follows the zero-init rule (0.0f, not NaN); + * inspect `speaker_id != 0` to distinguish a present row. + * + * Empty (count 0) whenever diarization did not run: unsupported family, + * diarize mode OFF, or no speaker markers recognized in this result. + */ +struct transcribe_speaker_segment { + uint64_t struct_size; + int64_t t0_ms; + int64_t t1_ms; + int32_t speaker_id; /* 1-based; 0 = row not present */ + float p; /* confidence hint; NaN = not produced */ +}; + +TRANSCRIBE_API void transcribe_speaker_segment_init(struct transcribe_speaker_segment * out); + +/* 0 before any run, on NULL session, or when diarization did not run. */ +TRANSCRIBE_API int transcribe_n_speaker_segments(const struct transcribe_session * session); + +/* + * Read one speaker-segment row into caller-owned storage. Same contract + * as transcribe_get_segment: INVALID_ARG on NULL out, BAD_STRUCT_SIZE on + * a zero/short struct_size, otherwise OK with the struct written when i + * is in range and left zero-initialized when it is not. + */ +TRANSCRIBE_API transcribe_status transcribe_get_speaker_segment(const struct transcribe_session * session, + int i, + struct transcribe_speaker_segment * out); + +/* ----------------------------------------------------------------------- */ +/* Batch result accessors */ +/* ----------------------------------------------------------------------- */ + +/* + * Read back the per-utterance results produced by transcribe_run_batch. + * These mirror the single-result accessors above with a leading utterance + * index `i` in [0, transcribe_batch_n_results(session)). + * + * After a successful transcribe_run, n_results is 1 and index 0 is the + * single result, so callers may use either accessor family interchangeably + * for the n == 1 case. After transcribe_run_batch, index 0 also aliases + * the legacy single-result accessors. + * + * Lifetime and out-of-range rules match the single-result accessors: + * - Returned `const char *` and row `text` pointers are session-owned + * and valid until the next transcribe_run / transcribe_run_batch / + * transcribe_stream_begin / transcribe_stream_reset / + * transcribe_session_free on the same session. + * - An out-of-range utterance index `i`, or an out-of-range row index + * `j`, yields a safe sentinel: "" / 0 / NaN for scalar returns, and a + * zero-initialized struct (text == NULL) for the copy-out accessors, + * which still return TRANSCRIBE_OK (struct-size faults and a NULL + * `out` return their usual non-OK codes). + * - Two accessors are deliberate exceptions to the sentinel rule and + * instead return TRANSCRIBE_ERR_INVALID_ARG for an out-of-range (or + * negative) utterance index `i`: transcribe_batch_status (TRANSCRIBE_OK + * is reserved for a real per-utterance success, so it cannot also + * sentinel a missing utterance) and transcribe_batch_get_timings + * (timings are utterance-scoped, not a row accessor like + * transcribe_batch_get_segment / _word / _token, so a missing utterance + * is an error rather than an empty row). The per-function docs below are + * authoritative where they differ from this summary. + */ + +/* + * Number of per-utterance results available. 0 before any run, or if + * session is NULL. 1 after a successful transcribe_run. n after a + * transcribe_run_batch with n utterances that returns OK or + * TRANSCRIBE_ERR_ABORTED — one slot per input utterance, including + * utterances that failed individually (non-OK transcribe_batch_status, + * empty rows) and, after an abort, utterances not retained as completed + * results (present as TRANSCRIBE_ERR_ABORTED slots). After a whole-batch + * fault other than abort (OOM, backend error) the count is unspecified; + * the non-OK top-level return is the signal in that case. + */ +TRANSCRIBE_API int transcribe_batch_n_results(const struct transcribe_session * session); + +/* + * Per-utterance terminal status. TRANSCRIBE_OK when utterance i produced a + * result; otherwise the status that failed that utterance (e.g. + * TRANSCRIBE_ERR_INVALID_ARG for a malformed input, or a backend error). + * Returns TRANSCRIBE_ERR_INVALID_ARG if session is NULL or i is out of + * range. + */ +TRANSCRIBE_API transcribe_status transcribe_batch_status(const struct transcribe_session * session, int i); + +TRANSCRIBE_API const char * transcribe_batch_full_text(const struct transcribe_session * session, int i); + +/* Per-utterance raw text; same contract as transcribe_raw_text. */ +TRANSCRIBE_API const char * transcribe_batch_raw_text(const struct transcribe_session * session, int i); + +TRANSCRIBE_API transcribe_timestamp_kind +transcribe_batch_returned_timestamp_kind(const struct transcribe_session * session, int i); + +TRANSCRIBE_API const char * transcribe_batch_detected_language(const struct transcribe_session * session, int i); + +TRANSCRIBE_API int transcribe_batch_n_segments(const struct transcribe_session * session, int i); + +TRANSCRIBE_API int transcribe_batch_n_words(const struct transcribe_session * session, int i); + +TRANSCRIBE_API int transcribe_batch_n_tokens(const struct transcribe_session * session, int i); + +/* + * Copy out row `j` of utterance `i`. The struct is initialized exactly as + * for transcribe_get_segment / _word / _token (caller calls the matching + * _init first); the only difference is the utterance index `i`. + */ +TRANSCRIBE_API transcribe_status transcribe_batch_get_segment(const struct transcribe_session * session, + int i, + int j, + struct transcribe_segment * out); + +TRANSCRIBE_API transcribe_status transcribe_batch_get_word(const struct transcribe_session * session, + int i, + int j, + struct transcribe_word * out); + +TRANSCRIBE_API transcribe_status transcribe_batch_get_token(const struct transcribe_session * session, + int i, + int j, + struct transcribe_token * out); + +/* Speaker-segment batch mirrors; same contracts as the single-result pair. */ +TRANSCRIBE_API int transcribe_batch_n_speaker_segments(const struct transcribe_session * session, int i); + +TRANSCRIBE_API transcribe_status transcribe_batch_get_speaker_segment(const struct transcribe_session * session, + int i, + int j, + struct transcribe_speaker_segment * out); + +/* + * Per-utterance timings for a batched run. Mirrors transcribe_get_timings but + * indexed by utterance. load_ms is the model-scoped load time (same for every + * utterance). mel_ms / encode_ms / decode_ms are this utterance's stage times; + * for a batched encoder the encode_ms is the shared batch encode time + * amortized across the batch (so the sum over utterances equals the real batch + * encode time), while decode_ms is the genuine per-utterance host-decode cost. + * This is what lets a caller see how run time splits between the GPU encoder + * and the host decoder as batch size grows. + * + * Returns TRANSCRIBE_ERR_INVALID_ARG on NULL args or out-of-range i, + * TRANSCRIBE_ERR_BAD_STRUCT_SIZE on an uninitialized out struct. + */ +TRANSCRIBE_API transcribe_status transcribe_batch_get_timings(const struct transcribe_session * session, + int i, + struct transcribe_timings * out); + +#ifdef __cplusplus +} /* extern "C" */ +#endif + +#endif /* TRANSCRIBE_H */ diff --git a/src/preload/index.ts b/src/preload/index.ts index 8610c2a1c..507708ae7 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -34,6 +34,12 @@ function subscribe(channel: string, listener: (event: T) => void): () => void } const api: CanvasTTYApi = { + evenG2: { + state: () => ipcRenderer.invoke(IPC.evenG2State), + command: (command) => ipcRenderer.invoke(IPC.evenG2Command, command), + onOpenBrowser: (listener) => subscribe(IPC.evenG2BrowserRequest, listener), + completeOpenBrowser: (requestId, ok) => ipcRenderer.send(IPC.evenG2BrowserResponse, { requestId, ok }) + }, appVersion: () => ipcRenderer.invoke(IPC.appVersion), clipboard: { readText: () => ipcRenderer.invoke(IPC.clipboardRead), diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 6823d7d57..d6505d1f0 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -603,6 +603,13 @@ export function App(): React.JSX.Element { }); }, [openBrowser, settings.locale, showToast]); + useEffect(() => window.canvasTTY.evenG2.onOpenBrowser(requestId => { + void openBrowser().then( + () => window.canvasTTY.evenG2.completeOpenBrowser(requestId, true), + () => window.canvasTTY.evenG2.completeOpenBrowser(requestId, false) + ); + }), [openBrowser]); + const openBrowserFromUi = useCallback((position?: Point): void => { void openBrowser(undefined, position).catch((error: unknown) => { showToast(error instanceof Error ? error.message : t(settings.locale, "browserActionFailed")); diff --git a/src/renderer/src/features/settings/EvenG2Controls.tsx b/src/renderer/src/features/settings/EvenG2Controls.tsx new file mode 100644 index 000000000..db0558a65 --- /dev/null +++ b/src/renderer/src/features/settings/EvenG2Controls.tsx @@ -0,0 +1,854 @@ +import { useEffect, useRef, useState } from "react"; +import type { LocaleId } from "../../../../shared/contracts"; +import type { + EvenG2Config, + EvenG2State, + EvenG2Command, +} from "../../../../shared/evenG2"; +import { UiIcon } from "../../components/UiIcon"; +import "./evenG2Controls.css"; + +type Stage = "overview" | "transport" | "scope" | "pair"; +export function EvenG2Controls({ + locale, +}: { + locale: LocaleId; +}): React.JSX.Element { + const ru = locale === "ru"; + const text = (en: string, russian: string) => (ru ? russian : en); + const [state, setState] = useState(null); + const [draft, setDraft] = useState(null); + const [editing, setEditing] = useState(false); + const [stage, setStage] = useState("overview"); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(""); + const [revoke, setRevoke] = useState(null); + const panelRef = useRef(null); + const initialized = useRef(false); + const pairRequested = useRef(false); + const preparingSpeech = useRef(false); + useEffect(() => { panelRef.current?.scrollIntoView({ block: "start" }); }, [stage]); + useEffect(() => { + let active = true; + const read = () => + void window.canvasTTY.evenG2 + .state() + .then((next) => { + if (!active) return; + setState(next); + if ( + preparingSpeech.current && + next.speechSetup?.phase === "ready" && + next.config.speechExecutable.endsWith("canvastty-speech") + ) { + preparingSpeech.current = false; + setDraft((previous) => + previous + ? { + ...previous, + speechExecutable: next.config.speechExecutable, + speechModel: next.config.speechModel, + } + : next.config, + ); + } + if (!initialized.current) { + setDraft(next.config); + initialized.current = true; + } + }) + .catch(() => { + if (active) + setError( + text( + "Connection settings unavailable.", + "Настройки подключения недоступны.", + ), + ); + }); + read(); + const timer = setInterval(read, 1500); + return () => { + active = false; + clearInterval(timer); + }; + }, [locale]); + useEffect(() => { + if ( + stage === "pair" && + state?.config.enabled && + state.transport.ready && + !state.pairing && + !pairRequested.current && + !busy + ) { + pairRequested.current = true; + void command({ type: "begin-pairing" }); + } + }, [stage, state, busy]); + async function command(value: EvenG2Command): Promise { + setBusy(true); + setError(""); + try { + const next = await window.canvasTTY.evenG2.command(value); + setState(next); + return next; + } catch { + setError( + text( + "The action was not completed. Check the connection and settings.", + "Действие не завершено. Проверьте соединение и параметры.", + ), + ); + return null; + } finally { + setBusy(false); + } + } + async function refresh(): Promise { + const next = await command({ type: "refresh" }); + if (next) + setDraft((previous) => + previous + ? { + ...previous, + interfaceName: + previous.interfaceName || + next.transport.addresses[0]?.name || + "", + } + : next.config, + ); + } + async function saveAndPair(): Promise { + if (!draft) return; + const next = await command({ + type: "configure", + config: { ...draft, enabled: true, publicOrigin: "" }, + }); + if (next) { + setDraft(next.config); + if (!editing) pairRequested.current = false; + setStage(editing ? "overview" : "pair"); + } + } + async function cancel(): Promise { + await command({ type: "cancel-pairing" }); + setStage("overview"); + } + const patch = (value: Partial) => + setDraft((previous) => (previous ? { ...previous, ...value } : previous)); + if (!state || !draft) + return ( +
+

Even G2

+

+ {error || + text( + "Loading connection settings…", + "Загрузка настроек подключения…", + )} +

+
+ ); + const activePeers = state.peers.filter( + (peer) => Date.now() - peer.lastSeen < 12000, + ); + const selected = state.transport.addresses.find( + (address) => address.name === draft.interfaceName, + ); + const steps: Array<[Stage, string]> = [ + ["transport", text("Connection", "Связь")], + ["scope", text("Access", "Доступ")], + ["pair", text("Connect", "Подключение")], + ]; + return ( +
+
+ + + +
+ EVEN G2 +

+ {text("Your sessions. On your glasses.", "Ваши сессии — на очках.")} +

+

+ {text( + "Read responses, speak to an agent, and control shared terminals.", + "Читайте ответы, диктуйте агенту и управляйте выбранными терминалами.", + )} +

+
+ + {activePeers.length + ? text("Connected", "Подключено") + : state.config.enabled + ? text("Enabled", "Включено") + : text("Off", "Выключено")} + +
+ {error && ( +

+ {error} +

+ )} + {stage === "overview" ? ( + <> +
+

+ {text( + "Connect once. Choose what your glasses can access.", + "Подключите один раз и выберите, что будет доступно с очков.", + )} +

+ +
+ + {state.peers.map((peer) => ( +
+
+
+ {peer.name} +

+ {Date.now() - peer.lastSeen < 12000 + ? text("Even App is online", "Even App на связи") + : text("Waiting for Even App", "Ожидаю Even App")} +

+
+ +
+
+ + + {text("Display", "Дисплей")}:{" "} + {peer.telemetry?.display === "confirmed" + ? text("acknowledged", "подтверждён") + : text("not checked", "не проверен")} + + + 0 + ? "pass" + : "" + } + /> + {text("Microphone", "Микрофон")}:{" "} + {peer.telemetry?.microphone === "unknown" + ? text("unconfirmed", "не подтверждён") + : peer.telemetry?.audioBytes + ? text("audio received", "звук получен") + : text("not checked", "не проверен")} + +
+ {peer.telemetry?.error && ( +

+ {peer.telemetry.error} +

+ )} + {!!peer.telemetry?.diagnostics?.length && ( +
+ + {text("Device diagnostics", "Диагностика с телефона")} + +
+                    {peer.telemetry.diagnostics.join("\n")}
+                  
+
+ )} +

+ {text( + "In Even App, open Settings → Check microphone. It does not send audio to the agent.", + "В Even App откройте Настройки → Проверить микрофон. Этот тест не отправляет звук агенту.", + )} +

+ {revoke === peer.id ? ( +
+ + {text( + "Revoke this device’s access?", + "Отозвать доступ устройства?", + )} + + + +
+ ) : ( + + )} +
+ ))} + {state.config.enabled && ( + + )} + + ) : ( + <> + + {stage === "transport" && ( +
+

{text("Connect to CanvasTTY", "Подключение к CanvasTTY")}

+ + {text( + "Direct local connection", + "Прямое локальное подключение", + )} + +

+ {text( + "CanvasTTY chooses a local network automatically. Both devices must be on the same network.", + "CanvasTTY выбирает локальную сеть автоматически. Телефон и Mac должны быть в одной сети.", + )} +

+ {!draft.publicOrigin && ( + <> +
    +
  1. + {text( + "Connect your phone and computer to the same Wi-Fi network.", + "Подключите телефон и компьютер к одной Wi-Fi-сети.", + )} +
  2. +
  3. + {text( + "Choose this computer’s address below, then choose which sessions to share.", + "Выберите адрес этого компьютера ниже, затем сессии для доступа с очков.", + )} +
  4. +
  5. + {text( + "Enter the code in the installed CanvasTTY G2 app in Even App.", + "Введите код в установленном CanvasTTY G2 в Even App.", + )} +
  6. +
+ + + )} +
+ + {draft.publicOrigin + ? text("HTTPS address selected", "Выбран HTTPS-адрес") + : selected + ? text("Address available", "Адрес доступен") + : text( + "Connect this computer to Wi-Fi", + "Подключите компьютер к Wi-Fi", + )} + + +
+ {!draft.publicOrigin && ( +

+ {text( + "This local preview uses HTTP on the selected interface. Use a trusted Wi-Fi network. Android debugging and an external server are not required.", + "Локальный режим использует HTTP на выбранном сетевом интерфейсе. Используйте доверенную Wi-Fi-сеть. Отладка Android и внешний сервер не требуются.", + )} +

+ )} +
+ + +
+
+ )} + {stage === "scope" && ( +
+

+ {text( + "Choose the workspace and sessions", + "Выберите рабочую папку и сессии", + )} +

+ +
+ {state.availableSessions.length ? ( + state.availableSessions.map((session) => ( + + )) + ) : ( +

+ {text( + "No sessions are open. You can allow creating a new session in the selected folder.", + "Открытых сессий нет. Можно разрешить создание новой в выбранной папке.", + )} +

+ )} +
+
+ {( + [ + [ + "allowInput", + text( + "Send text/voice and rename sessions", + "Отправлять текст и голос, переименовывать сессии", + ), + ], + [ + "allowCreate", + text( + "Create sessions in this folder", + "Создавать сессии в этой папке", + ), + ], + [ + "allowClose", + text( + "Close shared sessions", + "Закрывать выбранные сессии", + ), + ], + [ + "allowBrowser", + text( + "Open the project browser", + "Открывать браузер проекта", + ), + ], + ] as const + ).map(([key, label]) => ( + + ))} +
+
+

{text("Voice on this Mac", "Голос на этом Mac")}

+

+ {state.speech?.available + ? text("Ready: ", "Готово: ") + state.speech.model + : text( + "Prepare local recognition once. Audio stays on your Mac.", + "Один раз подготовьте локальное распознавание. Звук остаётся на Mac.", + )} +

+ {state.speechSetup?.phase === "downloading" || + state.speechSetup?.phase === "verifying" ? ( + <> + +

+ {state.speechSetup.phase === "verifying" + ? text("Verifying download…", "Проверяю загрузку…") + : `${Math.round(state.speechSetup.received / 1048576)} / ${Math.round(state.speechSetup.total / 1048576)} МБ`} +

+ + + ) : ( + state.speechSetup?.supported && ( + + ) + )} + {state.speechSetup?.phase === "error" && ( +

+ {text( + "The model could not be downloaded. Check Internet access and retry.", + "Не удалось загрузить модель. Проверьте интернет и повторите.", + )} +

+ )} +
+ + {text( + "Use an existing speech application", + "Использовать установленное приложение речи", + )} + + + +
+
+

+ {text( + "These settings apply to paired devices. Sessions outside this list remain inaccessible.", + "Эти настройки применяются к подключённым устройствам. Сессии вне списка остаются недоступными.", + )} +

+
+ + +
+
+ )} + {stage === "pair" && ( +
+

+ {text( + "Open CanvasTTY in Even App", + "Откройте CanvasTTY в Even App", + )} +

+

+ {text( + "Enter these six digits in CanvasTTY G2 in Even App, then approve the connection here. Keep both devices on the same Wi-Fi.", + "Введите эти шесть цифр в CanvasTTY G2 в Even App, затем подтвердите подключение здесь. Оба устройства должны быть в одной сети Wi-Fi.", + )} +

+ {!state.transport.ready && ( +

+ {text( + "The selected network is unavailable. Check Wi-Fi and refresh the address.", + "Выбранная сеть недоступна. Проверьте Wi-Fi и обновите адрес.", + )} +

+ )} + {state.pairing ? ( + <> +
+
+

+ {text( + "CanvasTTY pairing code", + "Код подключения CanvasTTY", + )} +

+ + {state.pairing.code} + +

+ {text("Valid for", "Действует ещё")}{" "} + {Math.max( + 0, + Math.ceil( + (state.pairing.expiresAt - Date.now()) / 1000, + ), + )}{" "} + {text("seconds", "секунд")} +

+ +
+
+ {state.pairing.pending ? ( +
+ {state.pairing.pending.name} +

+ {text( + "This app entered the correct code. Allow access to the sessions selected above?", + "Приложение ввело правильный код. Разрешить доступ к выбранным выше сессиям?", + )} +

+
+ + +
+
+ ) : ( +

+ {text( + "Waiting for confirmation from Even App…", + "Ожидаю запрос из Even App…", + )} +

+ )} + + ) : ( + + )} +
+ + {state.pairing && ( + + )} +
+
+ )} + + )} +
+ ); +} diff --git a/src/renderer/src/features/settings/SettingsPanel.tsx b/src/renderer/src/features/settings/SettingsPanel.tsx index b0dbc8f08..e9329a2cd 100644 --- a/src/renderer/src/features/settings/SettingsPanel.tsx +++ b/src/renderer/src/features/settings/SettingsPanel.tsx @@ -1,3 +1,4 @@ +import { EvenG2Controls } from "./EvenG2Controls"; import { useEffect, useState } from "react"; import type { AppSettings, @@ -660,6 +661,7 @@ export function SettingsPanel({ {section === "controls" && ( <> + p, +.g2-settings__body li { + font-size: 12px; + line-height: 1.65; + color: var(--text-muted-on-dark); +} +.g2-settings__body ol { + padding-left: 19px; + margin: 14px 0 20px; +} +.g2-settings label { + display: block; + font-size: 12px; + margin: 13px 0; + line-height: 1.5; +} +.g2-settings input:not([type="checkbox"]), +.g2-settings select { + display: block; + margin-top: 8px; + width: 100%; + min-height: 38px; + border: 1px solid color-mix(in srgb, var(--text) 20%, transparent); + border-radius: 7px; + background: var(--surface); + color: var(--text); + padding: 9px 11px; +} +.g2-settings input[type="checkbox"] { + accent-color: var(--primary); + width: 15px; + height: 15px; + flex-shrink: 0; + margin: 0; +} +.g2-settings details { + border-top: 1px solid color-mix(in srgb, var(--text) 10%, transparent); + padding-top: 14px; + margin-top: 17px; +} +.g2-settings summary { + cursor: pointer; + font-size: 12px; + line-height: 1.6; +} +.g2-muted { + font-size: 11px !important; + line-height: 1.6 !important; + color: var(--text-muted-on-dark); + margin: 9px 0; +} +.g2-tag { + display: inline-block; + font: 10px var(--font-mono); + color: var(--blue); + padding: 6px 9px; + background: color-mix(in srgb, var(--blue) 10%, transparent); + border-radius: 5px; +} +.g2-actions { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 9px; + margin-top: 18px; +} +.g2-actions > span { + margin-right: auto; +} +.g2-two { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 13px; +} +.g2-path { + display: flex; + gap: 12px; + margin-top: 8px; + align-items: center; + border: 1px solid color-mix(in srgb, var(--text) 15%, transparent); + padding: 8px 8px 8px 12px; + border-radius: 9px; + background: var(--surface); +} +.g2-path span { + flex: 1; + overflow-wrap: anywhere; + font: 11px var(--font-mono); + line-height: 1.5; +} +.g2-session-list { + margin-top: 20px; +} +.g2-session-list label { + display: flex; + align-items: center; + gap: 12px; + padding: 12px; + border: 1px solid color-mix(in srgb, var(--text) 12%, transparent); + border-radius: 9px; + background: var(--surface); + margin: 7px 0; +} +.g2-session-list small { + display: block; + color: var(--text-muted-on-dark); + font: 10px var(--font-mono); + margin-top: 3px; +} +.g2-permissions { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 4px 14px; + margin-top: 15px; +} +.g2-permissions label { + display: flex; + gap: 9px; + align-items: center; + margin: 6px 0; + font-size: 11px; +} +.g2-pair-offer { + display: flex; + align-items: center; + gap: 28px; + padding: 20px; + background: var(--surface); + border-radius: 12px; + margin: 20px 0; +} +.g2-pair-offer img { + width: 240px; + height: 240px; + flex-shrink: 0; + border-radius: 8px; +} +.g2-pair-code { + display: block; + font: 24px var(--font-mono); + letter-spacing: 2px; + color: var(--primary); + white-space: nowrap; +} +.g2-approval { + padding: 16px; + border: 1px solid var(--primary); + border-radius: 10px; +} +.g2-approval p { + font-size: 12px; + line-height: 1.6; + color: var(--text-muted-on-dark); +} +.g2-waiting { + font: 11px var(--font-mono); + color: var(--blue); +} +.g2-peer { + margin: 0 20px 18px; + border-top: 1px solid color-mix(in srgb, var(--text) 13%, transparent); + padding-top: 18px; +} +.g2-peer__top { + display: flex; + justify-content: space-between; + gap: 14px; + align-items: flex-start; +} +.g2-peer__top p { + font-size: 11px; + color: var(--text-muted-on-dark); + margin: 6px 0; +} +.g2-peer__top strong { + font-size: 14px; +} +.g2-checks { + display: flex; + gap: 18px; + flex-wrap: wrap; + margin: 12px 0; +} +.g2-checks span { + font-size: 11px; + display: flex; + align-items: center; + gap: 7px; +} +.g2-checks i { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--text-muted); +} +.g2-checks i.pass { + background: var(--success); +} +.g2-settings__error { + font-size: 12px; + line-height: 1.5; + color: var(--text); + background: color-mix(in srgb, var(--danger) 20%, var(--surface)); + padding: 11px 15px; + border-radius: 8px; + margin: 0 20px 14px; +} +.g2-settings__body .g2-settings__error { + margin: 12px 0; +} +@media (max-width: 950px) { + .g2-settings__heading { + gap: 10px; + padding: 17px; + } + .g2-settings__heading h3 { + font-size: 15px; + } + .g2-settings__heading p { + font-size: 11px; + } + .g2-settings__status { + display: none; + } + .g2-settings__entry { + align-items: flex-start; + flex-direction: column; + padding: 17px; + } + .g2-settings__body { + padding: 17px; + } + .g2-steps { + padding-left: 12px; + padding-right: 12px; + } + .g2-steps button { + padding: 8px 5px; + font-size: 11px; + } + .g2-pair-offer { + gap: 17px; + padding: 15px; + } + .g2-pair-offer img { + width: 220px; + height: 220px; + } + .g2-pair-code { + font-size: 19px; + } + .g2-permissions { + grid-template-columns: 1fr; + } +} + +.g2-speech-setup { margin: 18px 0; padding: 16px; border: 1px solid var(--border); border-radius: 12px; } +.g2-speech-setup progress { width: 100%; } + +.g2-pair-code { font-size: clamp(32px, 4vw, 48px); line-height: 1.4; user-select: all; letter-spacing: .16em; font-variant-numeric: tabular-nums; } diff --git a/src/renderer/src/lib/providers.ts b/src/renderer/src/lib/providers.ts index e93158ebc..fbc63d337 100644 --- a/src/renderer/src/lib/providers.ts +++ b/src/renderer/src/lib/providers.ts @@ -1,3 +1,4 @@ +import { PROVIDER_LABELS } from "../../../shared/contracts.ts"; import type { AgentProviderId, AppSettings, LimitProviderId, ProviderId } from "../../../shared/contracts"; import type { TranslationKey } from "./i18n"; @@ -9,16 +10,16 @@ export interface ProviderDefinition { } export const PROVIDERS: Record = { - terminal: { id: "terminal", label: "Terminal" }, - codex: { id: "codex", label: "Codex", dangerKey: "dangerCodex" }, - claude: { id: "claude", label: "Claude", dangerKey: "dangerClaude" }, - qwen: { id: "qwen", label: "Qwen Code", dangerKey: "dangerQwen" }, - kimi: { id: "kimi", label: "Kimi", dangerKey: "dangerKimi" }, - opencode: { id: "opencode", label: "OpenCode", limitsLabel: "OpenCode Go", dangerKey: "dangerOpenCode" }, - hermes: { id: "hermes", label: "Hermes", dangerKey: "dangerHermes" }, - grok: { id: "grok", label: "Grok Build", dangerKey: "dangerGrok" }, - omp: { id: "omp", label: "OMP", dangerKey: "dangerOmp" }, - pi: { id: "pi", label: "Pi", dangerKey: "dangerPi" } + terminal: { id: "terminal", label: PROVIDER_LABELS.terminal }, + codex: { id: "codex", label: PROVIDER_LABELS.codex, dangerKey: "dangerCodex" }, + claude: { id: "claude", label: PROVIDER_LABELS.claude, dangerKey: "dangerClaude" }, + qwen: { id: "qwen", label: PROVIDER_LABELS.qwen, dangerKey: "dangerQwen" }, + kimi: { id: "kimi", label: PROVIDER_LABELS.kimi, dangerKey: "dangerKimi" }, + opencode: { id: "opencode", label: PROVIDER_LABELS.opencode, limitsLabel: "OpenCode Go", dangerKey: "dangerOpenCode" }, + hermes: { id: "hermes", label: PROVIDER_LABELS.hermes, dangerKey: "dangerHermes" }, + grok: { id: "grok", label: PROVIDER_LABELS.grok, dangerKey: "dangerGrok" }, + omp: { id: "omp", label: PROVIDER_LABELS.omp, dangerKey: "dangerOmp" }, + pi: { id: "pi", label: PROVIDER_LABELS.pi, dangerKey: "dangerPi" } }; export const AGENT_PROVIDERS: AgentProviderId[] = ["codex", "claude", "qwen", "kimi", "opencode", "hermes", "grok", "omp", "pi"]; diff --git a/src/shared/companion.ts b/src/shared/companion.ts new file mode 100644 index 000000000..f4efa2579 --- /dev/null +++ b/src/shared/companion.ts @@ -0,0 +1,134 @@ +import type { ProviderId, SessionStatus } from "./contracts.ts"; +import { CANVAS_LAUNCHER_ITEMS } from "./contracts.ts"; + +export const COMPANION_PROTOCOL_VERSION = 1; + +export interface CompanionSession { + id: string; + title: string; + provider: ProviderId; + status: SessionStatus; +} + +export interface CompanionGrant { + deviceId: string; + revision: number; + sessionIds: readonly string[]; + allowInput: boolean; + allowCreate: boolean; + allowClose: boolean; + allowBrowser: boolean; +} + +export type CompanionAction = + | { type: "sessions.list" } + | { type: "session.read"; sessionId: string } + | { type: "session.input"; sessionId: string; text: string } + | { type: "session.interrupt"; sessionId: string } + | { type: "session.close"; sessionId: string } + | { type: "session.rename"; sessionId: string; title: string } + | { type: "session.create"; provider: ProviderId } + | { type: "browser.open"; sessionId: string } + | { type: "limits.read" }; + +export interface CompanionRequest { + version: typeof COMPANION_PROTOCOL_VERSION; + id: string; + sentAt: number; + action: CompanionAction; +} + +export type CompanionErrorCode = + | "invalid-request" + | "not-paired" + | "not-shared" + | "not-permitted" + | "stale-request" + | "request-conflict" + | "busy" + | "unavailable"; + +export class CompanionError extends Error { + readonly code: CompanionErrorCode; + + constructor(code: CompanionErrorCode) { + super(code); + this.code = code; + this.name = "CompanionError"; + } +} + +const SESSION_ACTIONS = new Set([ + "session.read", + "session.input", + "session.interrupt", + "session.close", + "session.rename", + "browser.open", +]); + +export function normalizeSessionTitle(value: unknown): string { + if (typeof value !== "string") throw new CompanionError("invalid-request"); + const title = value.replace(/\s+/gu, " ").trim(); + if (!title || title.length > 80 || /[\x00-\x1f\x7f-\x9f]/.test(title)) + throw new CompanionError("invalid-request"); + return title; +} + +export function parseCompanionRequest(value: unknown): CompanionRequest { + if (!value || typeof value !== "object" || Array.isArray(value)) + throw new CompanionError("invalid-request"); + const request = value as Record; + if ( + Object.keys(request).some( + (key) => !["version", "id", "sentAt", "action"].includes(key), + ) || + request.version !== COMPANION_PROTOCOL_VERSION || + typeof request.id !== "string" || + !/^[a-f0-9]{32}$/.test(request.id) || + typeof request.sentAt !== "number" || + !Number.isSafeInteger(request.sentAt) || + !request.action || + typeof request.action !== "object" || + Array.isArray(request.action) + ) { + throw new CompanionError("invalid-request"); + } + const action = request.action as Record; + const type = action.type; + if (typeof type !== "string") throw new CompanionError("invalid-request"); + const keys = ["type"]; + if (SESSION_ACTIONS.has(type)) { + keys.push("sessionId"); + if ( + typeof action.sessionId !== "string" || + !action.sessionId || + action.sessionId.length > 128 + ) { + throw new CompanionError("invalid-request"); + } + if (type === "session.rename") { + keys.push("title"); + normalizeSessionTitle(action.title); + } + if (type === "session.input") { + keys.push("text"); + if ( + typeof action.text !== "string" || + !action.text.trim() || + action.text.length > 8000 || + /[\x00-\x08\x0b-\x1f\x7f]/.test(action.text) + ) + throw new CompanionError("invalid-request"); + } + } else if (type === "session.create") { + keys.push("provider"); + if (!CANVAS_LAUNCHER_ITEMS.includes(action.provider as ProviderId)) + throw new CompanionError("invalid-request"); + } else if (type !== "sessions.list" && type !== "limits.read") { + throw new CompanionError("invalid-request"); + } + if (Object.keys(action).some((key) => !keys.includes(key))) + throw new CompanionError("invalid-request"); + return request as unknown as CompanionRequest; +} diff --git a/src/shared/contracts.ts b/src/shared/contracts.ts index 46a4ba6eb..a86734fea 100644 --- a/src/shared/contracts.ts +++ b/src/shared/contracts.ts @@ -1,4 +1,9 @@ export type ProviderId = "terminal" | "codex" | "claude" | "qwen" | "kimi" | "opencode" | "hermes" | "grok" | "omp" | "pi"; +export const PROVIDER_LABELS: Record = { + terminal: "Terminal", codex: "Codex", claude: "Claude", qwen: "Qwen Code", + kimi: "Kimi", opencode: "OpenCode", hermes: "Hermes", grok: "Grok Build", + omp: "OMP", pi: "Pi", +}; export type AgentProviderId = Exclude; export type LimitProviderId = Extract; export type LaunchProfileId = "normal" | "yolo"; @@ -898,6 +903,7 @@ export interface LimitsSnapshot { } export interface CanvasTTYApi { + evenG2: import('./evenG2.ts').EvenG2Api; appVersion(): Promise; clipboard: { readText(): Promise; @@ -1072,6 +1078,10 @@ export const IPC = { pluginsBrowserOpenRequested: "plugins:browser-open-requested", pluginsBrowserOpenResponded: "plugins:browser-open-responded", pluginsStorageChanged: "plugins:storage-changed", + evenG2State: "even-g2:state", + evenG2Command: "even-g2:command", + evenG2BrowserRequest: "even-g2:browser-request", + evenG2BrowserResponse: "even-g2:browser-response", browserGetState: "browser:get-state", browserOpen: "browser:open", browserClose: "browser:close", diff --git a/src/shared/evenG2.ts b/src/shared/evenG2.ts new file mode 100644 index 000000000..0f2d01752 --- /dev/null +++ b/src/shared/evenG2.ts @@ -0,0 +1,82 @@ +import type { CompanionGrant, CompanionSession } from "./companion.ts"; + +export interface EvenG2Config { + enabled: boolean; + workspace: string; + interfaceName: string; + publicOrigin: string; + sessionIds: string[]; + allowInput: boolean; + allowCreate: boolean; + allowClose: boolean; + allowBrowser: boolean; + speechExecutable: string; + speechModel: string; +} +export interface EvenG2Address { + id: string; + name: string; + address: string; +} +export interface EvenG2Telemetry { + clientVersion: string; + display: "confirmed" | "unknown"; + microphone: "never" | "on" | "off" | "unknown"; + audioBytes: number; + error: string; + diagnostics?: string[]; +} +export interface EvenG2Peer { + id: string; + name: string; + grant: CompanionGrant; + lastSeen: number; + telemetry: EvenG2Telemetry | null; +} +export interface EvenG2State { + config: EvenG2Config; + availableSessions: CompanionSession[]; + peers: EvenG2Peer[]; + pairing: { + code: string; + expiresAt: number; + pending: { id: string; name: string } | null; + } | null; + transport: { + kind: "lan" | "https"; + addresses: EvenG2Address[]; + origin: string; + origins?: string[]; + ready: boolean; + }; + listening: boolean; + port: number; + error: string; + speechSetup?: SpeechSetupState; + speech?: { available: boolean; model: string }; +} +export type EvenG2Command = + | { type: "configure"; config: EvenG2Config } + | { type: "refresh" } + | { type: "begin-pairing" } + | { type: "cancel-pairing" } + | { type: "approve"; id: string } + | { type: "reject" } + | { type: "revoke"; id: string } + | { type: "prepare-speech" } + | { type: "cancel-speech-setup" }; +export interface EvenG2Api { + state(): Promise; + command(command: EvenG2Command): Promise; + onOpenBrowser(listener: (requestId: string) => void): () => void; + completeOpenBrowser(requestId: string, ok: boolean): void; +} + +export interface SpeechSetupState { + supported: boolean; + phase: "missing" | "downloading" | "verifying" | "ready" | "error"; + received: number; + total: number; + model: string; + error: string; +} diff --git a/src/shared/localDiscovery.ts b/src/shared/localDiscovery.ts new file mode 100644 index 000000000..7afd56e8e --- /dev/null +++ b/src/shared/localDiscovery.ts @@ -0,0 +1,22 @@ +/** Exact local origins used by both Bonjour and the Even App manifest. */ +// Keep complete URLs literal: Even Hub checks emitted bundle URLs against the +// manifest and cannot resolve a template such as http://${host}:3481. +export const LOCAL_DISCOVERY_ORIGINS = [ + "http://canvastty.local:3481", + "http://canvastty-2.local:3481", + "http://canvastty-3.local:3481", + "http://canvastty-4.local:3481", + "http://canvastty-5.local:3481", + "http://canvastty-6.local:3481", + "http://canvastty-7.local:3481", + "http://canvastty-8.local:3481", +]; +export const LOCAL_DISCOVERY_HOSTS = LOCAL_DISCOVERY_ORIGINS.map( + origin => new URL(origin).hostname, +); +export const PAIRING_IDENTITY = "CanvasTTY/local-pairing/2"; +export function sixDigitCode(value: unknown): string { + const code = String(value ?? "").trim(); + if (!/^\d{6}$/.test(code)) throw new Error("Введите 6 цифр из CanvasTTY на Mac."); + return code; +} diff --git a/src/shared/localLink.ts b/src/shared/localLink.ts new file mode 100644 index 000000000..ad1eba754 --- /dev/null +++ b/src/shared/localLink.ts @@ -0,0 +1,201 @@ +/** Local-only encrypted connection. No remote service or account. */ +export const LOCAL_LINK_LIMIT = 1_900_000; +const KEY = /^[a-f0-9]{64}$/; +export interface LocalConnection { + version: 1; + computer: string; + key: string; + origins: string[]; +} +export interface LocalPacket { + version: 1; + id: string; + iv: string; + data: string; +} +export interface LocalRequest { + path: string; + method: "GET" | "POST"; + token: string; + body?: Record; + sentAt: number; +} +export interface LocalResponse { + status: number; + body: unknown; +} +export function randomLocalHex(length = 32): string { + return Array.from(crypto.getRandomValues(new Uint8Array(length)), (b) => + b.toString(16).padStart(2, "0"), + ).join(""); +} +export function localOrigin(value: unknown, allowLoopback = false): string { + if (typeof value !== "string" || value.length > 256) + throw new Error("invalid-local-origin"); + const url = new URL(value), + host = url.hostname.toLowerCase(); + const parts = host.split("."); + const ipv4 = + parts.length === 4 && + parts.every((p) => /^\d{1,3}$/.test(p) && +p <= 255) && + (+parts[0] === 10 || + (+parts[0] === 192 && +parts[1] === 168) || + (+parts[0] === 172 && +parts[1] >= 16 && +parts[1] <= 31)); + const ipv6 = /^\[f[cd][0-9a-f:]+\]$/.test(host); // URL already validates IPv6 syntax. + const mdns = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.local$/.test(host); + const loopback = + allowLoopback && ["127.0.0.1", "localhost", "[::1]"].includes(host); + if ( + url.protocol !== "http:" || + url.username || + url.password || + url.pathname !== "/" || + url.search || + url.hash || + !url.port || + !(ipv4 || ipv6 || mdns || loopback) + ) + throw new Error("Only a local computer address is allowed"); + return url.origin; +} +export function validateLocalConnection( + value: unknown, + allowLoopback = false, +): LocalConnection { + const v = value as LocalConnection; + if ( + !v || + v.version !== 1 || + !KEY.test(v.computer) || + !KEY.test(v.key) || + !Array.isArray(v.origins) || + !v.origins.length || + v.origins.length > 8 + ) + throw new Error("invalid-local-connection"); + return { + version: 1, + computer: v.computer, + key: v.key, + origins: [ + ...new Set(v.origins.map((origin) => localOrigin(origin, allowLoopback))), + ], + }; +} +function bytes(hex: string): Uint8Array { + return Uint8Array.from(hex.match(/../g) || [], (b) => parseInt(b, 16)); +} +function base64(value: Uint8Array): string { + let result = ""; + for (let i = 0; i < value.length; i += 8192) + result += String.fromCharCode(...value.subarray(i, i + 8192)); + return btoa(result); +} +export function validLocalPacket(value: unknown): value is LocalPacket { + const p = value as LocalPacket; + return ( + !!p && + p.version === 1 && + /^[a-f0-9]{32}$/.test(p.id) && + /^[a-f0-9]{24}$/.test(p.iv) && + typeof p.data === "string" && + p.data.length >= 24 && + p.data.length <= LOCAL_LINK_LIMIT && + /^[A-Za-z0-9+/]+={0,2}$/.test(p.data) + ); +} +async function cipherKey(value: string) { + if (!KEY.test(value)) throw new Error("invalid-local-key"); + return crypto.subtle.importKey("raw", bytes(value), "AES-GCM", false, [ + "encrypt", + "decrypt", + ]); +} +function aad(computer: string, id: string, direction: "request" | "response") { + return new TextEncoder().encode( + `CanvasTTY/local/1/${computer}/${id}/${direction}`, + ); +} +export async function sealLocal( + connection: LocalConnection, + value: unknown, + direction: "request" | "response", + id = randomLocalHex(16), +): Promise { + if (!KEY.test(connection.computer) || !/^[a-f0-9]{32}$/.test(id)) + throw new Error("invalid-local-packet-id"); + const plain = new TextEncoder().encode(JSON.stringify(value)); + if (plain.length > 1_400_000) throw new Error("local-payload-too-large"); + const iv = randomLocalHex(12); + const encrypted = await crypto.subtle.encrypt( + { + name: "AES-GCM", + iv: bytes(iv), + additionalData: aad(connection.computer, id, direction), + }, + await cipherKey(connection.key), + plain, + ); + return { version: 1, id, iv, data: base64(new Uint8Array(encrypted)) }; +} +export async function unsealLocal( + connection: LocalConnection, + packet: LocalPacket, + direction: "request" | "response", +): Promise { + if (!validLocalPacket(packet)) throw new Error("invalid-local-packet"); + const encrypted = Uint8Array.from(atob(packet.data), (c) => c.charCodeAt(0)); + const plain = await crypto.subtle.decrypt( + { + name: "AES-GCM", + iv: bytes(packet.iv), + additionalData: aad(connection.computer, packet.id, direction), + }, + await cipherKey(connection.key), + encrypted, + ); + return JSON.parse(new TextDecoder().decode(plain)) as T; +} +export function validateLocalRequest(value: LocalRequest): void { + const gets = new Set([ + "/g2/api/home", + "/g2/api/terminal", + "/g2/api/pair-status", + ]); + const posts = new Set([ + "/g2/api/pair", + "/g2/api/create", + "/g2/api/session-close", + "/g2/api/session-rename", + "/g2/api/browser", + "/g2/api/control", + "/g2/api/voice", + "/g2/api/cancel", + "/g2/api/device-state", + ]); + if ( + !value || + typeof value.path !== "string" || + value.path.length > 512 || + !value.path.startsWith("/g2/api/") || + typeof value.token !== "string" || + (value.token !== "" && !KEY.test(value.token)) || + !Number.isFinite(value.sentAt) || + Math.abs(Date.now() - value.sentAt) > 90_000 + ) + throw new Error("invalid-local-request"); + const url = new URL(value.path, "http://127.0.0.1"); + if ( + url.origin !== "http://127.0.0.1" || + url.hash || + !(value.method === "GET" + ? gets.has(url.pathname) + : value.method === "POST" && posts.has(url.pathname)) + ) + throw new Error("invalid-local-route"); + if ( + value.method === "POST" && + (!value.body || typeof value.body !== "object" || Array.isArray(value.body)) + ) + throw new Error("invalid-local-body"); +} diff --git a/tests/companion-access.test.mjs b/tests/companion-access.test.mjs new file mode 100644 index 000000000..091c5a8d3 --- /dev/null +++ b/tests/companion-access.test.mjs @@ -0,0 +1,129 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { SessionAccess } from "../src/main/services/companion/SessionAccess.ts"; +import { RequestLedger } from "../src/main/services/companion/RequestLedger.ts"; +import { parseCompanionRequest } from "../src/shared/companion.ts"; + +const request = (sentAt = 100_000) => ({ + version: 1, + id: "a".repeat(32), + sentAt, + action: { type: "session.input", sessionId: "one", text: "hello" }, +}); +const grant = (deviceId = "phone") => ({ + deviceId, + sessionIds: ["one"], + allowInput: true, + allowCreate: false, + allowClose: false, + allowBrowser: false, +}); + +test("unpaired devices and unshared sessions are rejected", () => { + const access = new SessionAccess(); + assert.throws(() => access.get("unknown"), { code: "not-paired" }); + const allowed = access.share(grant()); + access.assertSession(allowed, "one"); + assert.throws(() => access.assertSession(allowed, "two"), { + code: "not-shared", + }); +}); + +test("revocation or permission changes invalidate an in-flight grant", () => { + const access = new SessionAccess(); + const old = access.share(grant()); + access.share({ ...grant(), allowInput: false }); + assert.throws(() => access.assertCurrent(old), { code: "not-permitted" }); + const current = access.get("phone"); + access.revoke("phone"); + assert.throws(() => access.assertCurrent(current), { code: "not-paired" }); +}); + +test("a caller cannot expand a stored grant by mutating returned data", () => { + const access = new SessionAccess(), + original = grant(); + const returned = access.share(original); + original.sessionIds.push("secret"); + returned.sessionIds.push("secret"); + assert.deepEqual(access.get("phone").sessionIds, ["one"]); +}); + +test("concurrent retry writes once and shares the result", async () => { + let count = 0, + finish; + const ledger = new RequestLedger(() => 100_000); + const operation = () => { + count++; + return new Promise((resolve) => { + finish = resolve; + }); + }; + const first = ledger.run("phone", request(), operation), + repeated = ledger.run("phone", request(), operation); + await Promise.resolve(); + assert.equal(count, 1); + finish({ delivered: true }); + assert.deepEqual(await first, await repeated); + await ledger.run("phone", request(), operation); + assert.equal(count, 1); +}); + +test("conflicting retry is rejected and receipt IDs are isolated per device", async () => { + let count = 0; + const ledger = new RequestLedger(() => 100_000), + operation = async () => ++count; + await ledger.run("phone", request(), operation); + await assert.rejects( + ledger.run( + "phone", + { ...request(), action: { type: "session.close", sessionId: "one" } }, + operation, + ), + { code: "request-conflict" }, + ); + await ledger.run("second-phone", request(), operation); + assert.equal(count, 2); +}); + +test("capacity never evicts a valid receipt, and old requests fail after expiry", async () => { + let now = 100_000, + count = 0; + const ledger = new RequestLedger(() => now, 1, 120_000), + operation = async () => ++count; + await ledger.run("phone", request(), operation); + await assert.rejects( + ledger.run("phone", { ...request(), id: "b".repeat(32) }, operation), + { code: "busy" }, + ); + now = 220_001; + await assert.rejects(ledger.run("phone", request(), operation), { + code: "stale-request", + }); + assert.equal(count, 1); +}); + +test("clock skew cannot expire a receipt while its request is still valid", async () => { + let now = 100_000, + count = 0; + const ledger = new RequestLedger(() => now), + future = request(129_000), + operation = async () => ++count; + await ledger.run("phone", future, operation); + now = 221_000; + await ledger.run("phone", future, operation); + assert.equal(count, 1); +}); + +test("wire requests cannot choose filesystem paths, launch profiles, or terminal escape sequences", () => { + assert.deepEqual(parseCompanionRequest(request()), request()); + for (const action of [ + { type: "session.create", provider: "codex", cwd: "/private" }, + { type: "session.create", provider: "codex", profile: "yolo" }, + { type: "session.input", sessionId: "one", text: "x\x1b[2J" }, + { type: "session.create", provider: "unexpected" }, + { type: "eval", code: "anything" }, + ]) + assert.throws(() => parseCompanionRequest({ ...request(), action }), { + code: "invalid-request", + }); +}); diff --git a/tests/companion-presentation.test.mjs b/tests/companion-presentation.test.mjs new file mode 100644 index 000000000..77ed63d8c --- /dev/null +++ b/tests/companion-presentation.test.mjs @@ -0,0 +1,99 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { + latestCodexReply, + cleanTerminalText, + cleanCodexChrome, + codexMenu, +} from "../src/main/services/companion/presentation.ts"; +const before = `• Старый ответ.\n\n› Пинг\n\n• Понг. Связь работает.\n\n› Сколько будет 120 умножить на 365?\n\n• 120 × 365 = 43 800.\n\n────────────────────────\n\n› Ask Codex to do anything\n\n gpt-6-astra xhigh · ~/project`; +test("latest answer excludes all old questions, old answers, separators and model footer", () => { + assert.equal(latestCodexReply(before), "120 × 365 = 43 800."); +}); +test("animation frames are not answers", () => { + assert.equal( + latestCodexReply( + "› Пинг\n◦ Working (0s • esc to interrupt)\n› Ask Codex to do anything", + ), + "", + ); + assert.equal( + cleanTerminalText( + "one\n─────────\n\n\n◦ Working (1s • esc to interrupt)\n\ntwo", + ), + "one\n\ntwo", + ); +}); +test("multiline final text is retained and list bullets stay readable", () => { + assert.equal( + latestCodexReply( + "• Сделано:\n - первый пункт\n - второй пункт\n\n› Ask Codex to do anything", + ), + "Сделано:\n - первый пункт\n - второй пункт", + ); +}); +test("resume notices cannot become part of the restored answer", () => { + assert.equal( + latestCodexReply( + "• 120 × 365 = 43 800.\n\n• You have 2 usage limit resets available. Run /usage to use one.\n\n⚠ Heads up, you have less than 25% of your weekly limit left.\n› Ask Codex to do anything", + ), + "120 × 365 = 43 800.", + ); +}); +test("new Codex banner, tip and startup chrome do not become four pages", () => { + const start = + "╭────────────────────╮\n│ >_ OpenAI Codex (v0.153.4) │\n│ model: gpt-6-astra xhigh │\n│ directory: ~/project │\n│ permissions: YOLO mode │\n╰────────────────────╯\n\n Tip: Welcome to the new model.\n More welcome text on another line.\n\n• You have 2 usage limit resets available. Run /usage to use one.\n\n› Ask Codex to do anything\n\n gpt-6-astra xhigh · ~/project"; + assert.equal(cleanCodexChrome(start), ""); + assert.equal( + latestCodexReply( + "• Starting MCP servers (0/3)\n› Ask Codex to do anything", + ), + "", + ); + assert.equal( + latestCodexReply( + "• Понг.\n\n• Starting MCP servers (2/4): codex_apps, docs (0s)\n› Ask Codex to do anything", + ), + "Понг.", + ); + assert.equal( + cleanCodexChrome("│ model: loading │\n╰────────────────────╯\n› "), + "", + ); + assert.match( + cleanCodexChrome("Error: authentication failed. Please sign in."), + /authentication failed/, + ); +}); +test("numbered live menus retain question, selection and the explicit custom option", () => { + const menu = codexMenu( + "Which storage?\n\n› 1. SQLite\n 2. Postgres\n 3. Type something else\n\nPress enter to confirm or esc to go back", + ); + assert.equal(menu.selected, 0); + assert.equal(menu.customIndex, 2); + assert.equal(menu.options.length, 3); + assert.equal(menu.title, "Which storage?"); + const approval = codexMenu( + "Would you like to run this command?\n$ npm test\n\n› 1. Yes, proceed (y)\n 2. No (esc)\n\nPress enter to confirm or esc to cancel", + ); + assert.equal(approval.customIndex, null); + assert.match(approval.title, /npm test/); +}); +test("prose and a stale menu in scrollback are never treated as choices", () => { + assert.equal( + codexMenu("1. Do this\n2. Do that\n› Ask Codex to do anything"), + null, + ); + assert.equal(codexMenu("› 1. Example\n 2. Example two"), null); + assert.equal( + codexMenu("› 1. Yes\n 2. No\nPress enter to confirm\n› new prompt"), + null, + ); +}); +test("old status warnings are not prepended to an active Codex menu title", () => { + const menu = codexMenu( + "⚠ Heads up, you have less than 5% left.\n breakdown.\n\n Select Model and Effort\n Access legacy models with codex -m.\n\n› 1. Current\n 2. Another\n\nPress enter to confirm or esc to go back", + ); + assert.match(menu.title, /^Select Model/); + assert.doesNotMatch(menu.title, /Heads up/); +}); diff --git a/tests/companion-sessions.test.mjs b/tests/companion-sessions.test.mjs new file mode 100644 index 000000000..83a9dbf69 --- /dev/null +++ b/tests/companion-sessions.test.mjs @@ -0,0 +1,208 @@ +import { CANVAS_LAUNCHER_ITEMS } from "../src/shared/contracts.ts"; +import test from "node:test"; +import assert from "node:assert/strict"; +import { CompanionSessions } from "../src/main/services/companion/CompanionSessions.ts"; +import { SessionAccess } from "../src/main/services/companion/SessionAccess.ts"; +import { RequestLedger } from "../src/main/services/companion/RequestLedger.ts"; + +function fixture() { + const access = new SessionAccess(), + writes = [], + closed = []; + const sessions = ["one", "private"].map((id) => ({ + id, + title: id, + provider: "codex", + status: "idle", + cwd: "/private/path", + buffer: "PRIVATE BUFFER", + })); + const host = { + list: () => sessions, + read: async () => ({ body: "An answer", revision: "v1" }), + input: (id, text) => { + writes.push({ id, text }); + return true; + }, + rename: (id, title) => { + const session = sessions.find((s) => s.id === id); + session.title = title; + return session; + }, + close: (id) => { + closed.push(id); + sessions.splice( + sessions.findIndex((s) => s.id === id), + 1, + ); + }, + create: (provider) => { + const s = { id: "created", title: "New", provider, status: "idle" }; + sessions.push(s); + return s; + }, + openBrowser: async () => ({ + title: "Project", + url: "https://example.test/app?token=PRIVATE#secret", + }), + limits: async () => ({ fetchedAt: 100_000, providers: [] }), + }; + access.share({ + deviceId: "phone", + sessionIds: ["one"], + allowInput: true, + allowCreate: true, + allowClose: true, + allowBrowser: true, + }); + const service = new CompanionSessions( + host, + access, + new RequestLedger(() => 100_000), + ); + let sequence = 0; + const request = (action) => ({ + version: 1, + id: (++sequence).toString(16).padStart(32, "0"), + sentAt: 100_000, + action, + }); + return { access, writes, closed, host, service, request }; +} + +test("only shared sessions and public metadata leave the host", async () => { + const f = fixture(); + assert.deepEqual( + await f.service.dispatch("phone", f.request({ type: "sessions.list" })), + [{ id: "one", title: "one", provider: "codex", status: "idle" }], + ); + await assert.rejects( + f.service.dispatch( + "phone", + f.request({ type: "session.read", sessionId: "private" }), + ), + { code: "not-shared" }, + ); +}); + +test("dictation is literal input; interrupt is a separate authorized action", async () => { + const f = fixture(); + await f.service.dispatch( + "phone", + f.request({ type: "session.input", sessionId: "one", text: "/g2 ctrl-c" }), + ); + assert.equal(f.writes[0].text, "\x1b[200~/g2 ctrl-c\x1b[201~\r"); + await f.service.dispatch( + "phone", + f.request({ type: "session.interrupt", sessionId: "one" }), + ); + assert.equal(f.writes[1].text, "\x03"); +}); + +test("read-only access cannot mutate a session", async () => { + const f = fixture(); + f.access.share({ + ...f.access.get("phone"), + allowInput: false, + allowClose: false, + }); + await assert.rejects( + f.service.dispatch( + "phone", + f.request({ type: "session.close", sessionId: "one" }), + ), + { code: "not-permitted" }, + ); + await assert.rejects( + f.service.dispatch( + "phone", + f.request({ type: "session.input", sessionId: "one", text: "hello" }), + ), + { code: "not-permitted" }, + ); + assert.equal(f.closed.length + f.writes.length, 0); +}); + +test("a repeated close does not close another terminal", async () => { + const f = fixture(), + request = f.request({ type: "session.close", sessionId: "one" }); + await f.service.dispatch("phone", request); + await f.service.dispatch("phone", request); + assert.deepEqual(f.closed, ["one"]); +}); + +test("revocation while a read is pending prevents returning its content", async () => { + const f = fixture(); + let finish; + f.host.read = () => + new Promise((resolve) => { + finish = resolve; + }); + const pending = f.service.dispatch( + "phone", + f.request({ type: "session.read", sessionId: "one" }), + ); + await Promise.resolve(); + f.access.revoke("phone"); + finish({ body: "must not leave", revision: "v2" }); + await assert.rejects(pending, { code: "not-paired" }); +}); + +test("browser replies omit URL queries and fragments; new sessions become shared only with their creator", async () => { + const f = fixture(); + const browser = await f.service.dispatch( + "phone", + f.request({ type: "browser.open", sessionId: "one" }), + ); + assert.deepEqual(browser, { + title: "Project", + url: "https://example.test/app", + }); + const created = await f.service.dispatch( + "phone", + f.request({ type: "session.create", provider: "codex" }), + ); + f.access.assertSession(f.access.get("phone"), created.id); + assert.throws(() => f.access.get("other-phone"), { code: "not-paired" }); +}); + +test("renaming updates shared metadata without writing to the PTY; read-only and private sessions are rejected", async () => { + const f = fixture(); + const result = await f.service.dispatch( + "phone", + f.request({ + type: "session.rename", + sessionId: "one", + title: " Бот поддержки ", + }), + ); + assert.equal(result.title, "Бот поддержки"); + assert.equal(f.writes.length, 0); + assert.equal(result.cwd, undefined); + await assert.rejects( + f.service.dispatch( + "phone", + f.request({ type: "session.rename", sessionId: "private", title: "No" }), + ), + { code: "not-shared" }, + ); + f.access.share({ ...f.access.get("phone"), allowInput: false }); + await assert.rejects( + f.service.dispatch( + "phone", + f.request({ type: "session.rename", sessionId: "one", title: "No" }), + ), + { code: "not-permitted" }, + ); +}); + +test("every hotbar provider is created with scoped access; revoking creation blocks all of them", async () => { + for (const provider of CANVAS_LAUNCHER_ITEMS) { + const f = fixture(); + const result = await f.service.dispatch("phone", f.request({ type: "session.create", provider })); + assert.equal(result.provider, provider); + f.access.assertSession(f.access.get("phone"), result.id); + f.access.share({ ...f.access.get("phone"), allowCreate: false }); + await assert.rejects(f.service.dispatch("phone", f.request({ type: "session.create", provider })), { code: "not-permitted" }); + } +}); diff --git a/tests/even-g2-controller.test.mjs b/tests/even-g2-controller.test.mjs new file mode 100644 index 000000000..218837b92 --- /dev/null +++ b/tests/even-g2-controller.test.mjs @@ -0,0 +1,723 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { + mkdtemp, + mkdir, + writeFile, + readFile, + stat, + rm, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { randomBytes } from "node:crypto"; +import { EvenG2Controller } from "../src/main/services/companion/EvenG2Controller.ts"; + +async function fixture(t, extra = {}) { + const directory = await mkdtemp(join(tmpdir(), "canvastty-even-test-")); + const webRoot = join(directory, "web"); + await mkdir(webRoot); + await writeFile(join(webRoot, "index.html"), "

Companion fixture

"); + const sessions = ["one", "private"].map((id) => ({ + id, + title: id, + provider: "terminal", + status: "idle", + cwd: "/unshared/private/path", + })); + const writes = [], + closed = [], + creates = [], + renames = []; + const terminals = { + listMetadata: () => sessions.map((s) => ({ ...s })), + geometry: () => ({ cols: 100, rows: 30 }), + readBuffer: () => ({ buffer: "Ready\r\n", outputOffset: 7 }), + inputChecked: (id, data) => { + if (!sessions.some((s) => s.id === id)) return false; + writes.push({ id, data }); + return true; + }, + rename: (id, title) => { + const session = sessions.find((s) => s.id === id); + if (!session) throw Error("missing"); + session.title = title; + renames.push({ id, title }); + return session; + }, + dispose: (id) => { + closed.push(id); + const index = sessions.findIndex((s) => s.id === id); + if (index >= 0) sessions.splice(index, 1); + }, + create: (options) => { + creates.push(options); + const session = { + id: "new-" + creates.length, + title: "Created", + provider: options.provider, + status: "idle", + cwd: options.cwd, + }; + sessions.push(session); + return session; + }, + }; + let addresses = [ + { id: "test:127.0.0.1", name: "test", address: "127.0.0.1" }, + ]; + const options = { + ...extra, + userDataPath: directory, + terminals, + addresses: () => addresses, + webRoot, + speechWorker: join(directory, "missing-worker.py"), + port: 0, + limits: async () => ({ fetchedAt: Date.now(), providers: [] }), + openBrowser: async () => ({ + title: "Project", + url: "http://localhost:5173/page?secret=omit#omit", + }), + }; + const controller = new EvenG2Controller(options); + await controller.load(); + t.after(async () => { + await controller.close(); + await rm(directory, { recursive: true, force: true }); + }); + const enable = async (overrides) => + controller.command({ + type: "configure", + config: { + ...controller.state().config, + enabled: true, + workspace: directory, + interfaceName: "test", + sessionIds: ["one"], + allowClose: true, + ...overrides, + }, + }); + const call = async (path, { token, body, raw = false } = {}) => { + const response = await fetch( + "http://127.0.0.1:" + controller.state().port + path, + { + method: body === undefined ? "GET" : "POST", + headers: { + ...(token ? { Authorization: "Bearer " + token } : {}), + "Content-Type": "application/json", + }, + body: + body === undefined + ? undefined + : JSON.stringify( + raw + ? body + : { + ...body, + requestId: + body.requestId || randomBytes(16).toString("hex"), + sentAt: body.sentAt ?? Date.now(), + }, + ), + }, + ); + return { status: response.status, data: await response.json() }; + }; + const pending = async () => { + await controller.command({ type: "begin-pairing" }); + const response = await call("/g2/api/pair", { + body: { code: controller.state().pairing.code, name: "Even test" }, + }); + assert.equal(response.status, 202); + return response.data; + }; + const pair = async () => { + const p = await pending(); + await controller.command({ type: "approve", id: p.id }); + return p; + }; + return { + controller, + setAddresses: (value) => { + addresses = value; + }, + options, + directory, + enable, + call, + pending, + pair, + writes, + closed, + creates, + renames, + }; +} + +test("integration defaults off; the short code creates no access before desktop approval", async (t) => { + const f = await fixture(t); + assert.equal(f.controller.state().listening, false); + await f.enable(); + const pending = await f.pending(); + assert.equal( + (await f.call("/g2/api/home", { token: pending.token })).status, + 401, + ); + assert.equal( + ( + await f.call("/g2/api/control", { + token: pending.token, + body: { sessionId: "one", action: "text", text: "not delivered" }, + }) + ).status, + 401, + ); + assert.equal( + (await f.call("/g2/api/pair-status", { token: pending.token })).data.state, + "pending", + ); + await f.controller.command({ type: "approve", id: pending.id }); + const home = await f.call("/g2/api/home", { token: pending.token }); + assert.equal(home.status, 200); + assert.deepEqual(home.data.sessions, [ + { id: "one", title: "one", provider: "terminal", status: "idle" }, + ]); + assert.equal(JSON.stringify(home).includes("/unshared"), false); + assert.equal(f.writes.length, 0); + assert.equal(home.data.speechAvailable, false); + assert.equal( + (await f.call("/g2/api/pair-status", { token: pending.token })).data.state, + "approved", + ); +}); + +test("text is written once; changed payload, missing freshness, stale and unshared requests never write", async (t) => { + const f = await fixture(t); + await f.enable(); + const { token } = await f.pair(); + const body = { + sessionId: "one", + action: "text", + text: "literal /g2 ctrl-c", + requestId: randomBytes(16).toString("hex"), + sentAt: Date.now(), + }; + const results = await Promise.all([ + f.call("/g2/api/control", { token, body }), + f.call("/g2/api/control", { token, body }), + ]); + assert.deepEqual( + results.map((r) => r.status), + [200, 200], + ); + assert.deepEqual(f.writes, [ + { id: "one", data: "\x1b[200~literal /g2 ctrl-c\x1b[201~\r" }, + ]); + assert.equal( + ( + await f.call("/g2/api/control", { + token, + body: { ...body, text: "changed" }, + }) + ).status, + 409, + ); + assert.equal( + ( + await f.call("/g2/api/control", { + token, + body: { ...body, sessionId: "private" }, + }) + ).status, + 409, + ); + assert.equal( + ( + await f.call("/g2/api/control", { + token, + body: { ...body, sentAt: Date.now() - 121000 }, + }) + ).status, + 400, + ); + assert.equal( + ( + await f.call("/g2/api/create", { + token, + body: { provider: "terminal" }, + raw: true, + }) + ).status, + 400, + ); + assert.equal( + (await f.call("/g2/api/terminal?id=private", { token })).status, + 409, + ); + assert.equal(f.writes.length, 1); + assert.equal(f.creates.length, 0); +}); + +test("creation is scoped to the chosen folder and device; close and browser use real acknowledgements", async (t) => { + const f = await fixture(t); + await f.enable(); + const first = await f.pair(), + second = await f.pair(); + const body = { + provider: "terminal", + requestId: randomBytes(16).toString("hex"), + sentAt: Date.now(), + }; + const created = await f.call("/g2/api/create", { token: first.token, body }); + assert.equal(created.status, 201); + await f.call("/g2/api/create", { token: first.token, body }); + assert.equal(f.creates.length, 1); + assert.equal(f.creates[0].cwd, f.directory); + assert.equal(f.creates[0].profile, "normal"); + assert.equal( + (await f.call("/g2/api/home", { token: second.token })).data.sessions + .length, + 1, + ); + const browser = await f.call("/g2/api/browser", { + token: first.token, + body: { sessionId: created.data.session.id }, + }); + assert.deepEqual(browser.data, { + opened: true, + title: "Project", + url: "http://localhost:5173/page", + }); + const close = { + sessionId: created.data.session.id, + requestId: randomBytes(16).toString("hex"), + sentAt: Date.now(), + }; + assert.equal( + (await f.call("/g2/api/session-close", { token: first.token, body: close })) + .status, + 200, + ); + assert.equal( + (await f.call("/g2/api/session-close", { token: first.token, body: close })) + .status, + 200, + ); + assert.deepEqual(f.closed, [created.data.session.id]); +}); + +test("readonly scope is enforced even when a phone bypasses disabled buttons", async (t) => { + const f = await fixture(t); + await f.enable({ + allowInput: false, + allowClose: false, + allowCreate: false, + allowBrowser: false, + }); + const { token } = await f.pair(); + for (const [path, body] of [ + ["control", { sessionId: "one", action: "text", text: "no" }], + ["session-close", { sessionId: "one" }], + ["create", { provider: "codex" }], + ["browser", { sessionId: "one" }], + ]) + assert.ok( + (await f.call("/g2/api/" + path, { token, body })).status >= 400, + path, + ); + assert.equal(f.writes.length + f.closed.length + f.creates.length, 0); +}); + +test("peer persistence contains only a private token hash and revocation survives restart", async (t) => { + const f = await fixture(t); + await f.enable(); + const { token, id } = await f.pair(); + const path = join(f.directory, "even-g2.json"), + saved = await readFile(path, "utf8"); + assert.equal(saved.includes(token), false); + assert.match(saved, /tokenHash/); + assert.equal((await stat(path)).mode & 0o777, 0o600); + await f.controller.command({ type: "revoke", id }); + assert.equal((await f.call("/g2/api/home", { token })).status, 401); + await f.controller.command({ + type: "configure", + config: { ...f.controller.state().config, enabled: false }, + }); + assert.equal(f.controller.state().listening, false); + const restored = new EvenG2Controller(f.options); + await restored.load(); + assert.equal(restored.state().listening, false); + assert.deepEqual(restored.state().peers, []); + await restored.close(); +}); + +test("wrong-code attempts are bounded and rejecting the claim invalidates its pending token", async (t) => { + const f = await fixture(t); + await f.enable(); + await f.controller.command({ type: "begin-pairing" }); + const code = f.controller.state().pairing.code; + for (let i = 0; i < 10; i++) + assert.equal( + (await f.call("/g2/api/pair", { body: { code: "invalid" } })).status, + 403, + ); + assert.equal((await f.call("/g2/api/pair", { body: { code } })).status, 403); + const pending = await f.pending(); + await f.controller.command({ type: "reject" }); + assert.equal( + (await f.call("/g2/api/pair-status", { token: pending.token })).data.state, + "rejected", + ); + assert.equal( + (await f.call("/g2/api/home", { token: pending.token })).status, + 401, + ); +}); + +test("loss of the selected network closes its listener and invalidates an outstanding code", async (t) => { + const f = await fixture(t); + await f.enable(); + await f.controller.command({ type: "begin-pairing" }); + const prior = f.controller.state().transport.origin; + f.setAddresses([]); + await f.controller.command({ type: "refresh" }); + assert.equal(f.controller.state().listening, false); + assert.equal(f.controller.state().transport.origin, ""); + assert.equal(f.controller.state().pairing, null); + await assert.rejects(fetch(prior + "/g2/api/home")); + f.setAddresses([ + { id: "test:127.0.0.1", name: "test", address: "127.0.0.1" }, + ]); + await f.controller.command({ type: "refresh" }); + const current = f.controller.state(); + assert.equal(current.listening, true); + assert.match(current.transport.origin, /127\.0\.0\.1/); + assert.equal((await fetch(current.transport.origin + "/g2/")).status, 200); + assert.equal( + (await fetch(current.transport.origin + "/g2/api/home")).status, + 401, + ); +}); + +test("an old ADB configuration never silently exposes its existing grants on a LAN", async (t) => { + const f = await fixture(t); + await writeFile( + join(f.directory, "even-g2.json"), + JSON.stringify({ + version: 1, + config: { + ...f.controller.state().config, + enabled: true, + selectedSerial: "old-phone", + adbPath: "/old/adb", + }, + peers: [ + { + id: "a".repeat(32), + tokenHash: "b".repeat(64), + name: "Old peer", + grant: { sessionIds: ["one"], allowInput: true }, + }, + ], + }), + ); + const restored = new EvenG2Controller(f.options); + await restored.load(); + assert.equal(restored.state().config.enabled, false); + assert.equal(restored.state().listening, false); + assert.deepEqual(restored.state().peers, []); + await restored.close(); +}); + +test("microphone diagnostics require pairing and remain bounded in the desktop state", async (t) => { + const f = await fixture(t); + await f.enable(); + const pending = await f.pending(); + const body = { + clientVersion: "0.4.1", + microphone: "unknown", + error: "No microphone acknowledgement", + diagnostics: [ + null, + {}, + ...Array.from( + { length: 30 }, + (_, i) => "mic:" + i + " " + ".".repeat(240), + ), + ], + }; + assert.equal( + (await f.call("/g2/api/device-state", { token: pending.token, body })) + .status, + 401, + ); + await f.controller.command({ type: "approve", id: pending.id }); + assert.equal( + (await f.call("/g2/api/device-state", { token: pending.token, body })) + .status, + 200, + ); + const telemetry = f.controller.state().peers[0].telemetry; + assert.equal(telemetry.error, body.error); + assert.equal(telemetry.microphone, "unknown"); + assert.equal(telemetry.diagnostics.length, 24); + assert.ok( + telemetry.diagnostics.every( + (line) => typeof line === "string" && line.length <= 180, + ), + ); +}); + +test("HTTPS mode serves the connector on loopback independently of LAN discovery", async (t) => { + const f = await fixture(t); + f.setAddresses([]); + await f.enable({ publicOrigin: "https://bridge.example.test/" }); + const state = f.controller.state(); + assert.equal(state.transport.kind, "https"); + assert.equal(state.transport.origin, "https://bridge.example.test"); + assert.equal(state.listening, true); + assert.equal( + (await fetch("http://127.0.0.1:" + state.port + "/g2/")).status, + 200, + ); + assert.equal((await f.call("/g2/api/home")).status, 401); + const { token } = await f.pair(); + assert.equal((await f.call("/g2/api/home", { token })).status, 200); + await f.controller.command({ type: "refresh" }); + assert.equal(f.controller.state().listening, true); + await f.controller.command({ + type: "configure", + config: { ...state.config, enabled: false }, + }); + assert.equal(f.controller.state().listening, false); +}); + +test("confirmed rename is idempotent and never becomes terminal input", async (t) => { + const f = await fixture(t); + await f.enable(); + const { token } = await f.pair(); + const body = { + sessionId: "one", + title: " Бот поддержки ", + requestId: randomBytes(16).toString("hex"), + sentAt: Date.now(), + }; + const reply = await f.call("/g2/api/session-rename", { token, body }); + assert.equal(reply.status, 200); + assert.equal(reply.data.session.title, "Бот поддержки"); + await f.call("/g2/api/session-rename", { token, body }); + assert.equal(f.renames.length, 1); + assert.equal(f.writes.length, 0); + assert.equal( + (await f.call("/g2/api/home", { token })).data.sessions[0].title, + "Бот поддержки", + ); + for (const title of ["", "x".repeat(81), "\x1b[31m"]) + assert.ok( + ( + await f.call("/g2/api/session-rename", { + token, + body: { sessionId: "one", title }, + }) + ).status >= 400, + ); + assert.ok( + ( + await f.call("/g2/api/session-rename", { + token, + body: { sessionId: "private", title: "No" }, + }) + ).status >= 400, + ); + assert.equal(f.renames.length, 1); +}); + +test("voice used for a rename is only a preview and cannot be replayed as a shell instruction", async (t) => { + const speech = { + available: true, + model: "Test recognizer", + configure() {}, + async inspect() {}, + cancel() {}, + cancelAll() {}, + async run(body, accept) { + return { + accepted: await accept("Бот поддержки", () => false), + transcript: "Бот поддержки", + }; + }, + }; + const f = await fixture(t, { speech }); + await f.enable(); + const { token } = await f.pair(); + const body = { + sessionId: "one", + purpose: "rename", + audio: Buffer.alloc(8000).toString("base64"), + requestId: randomBytes(16).toString("hex"), + sentAt: Date.now(), + }; + const result = await f.call("/g2/api/voice", { token, body }); + assert.equal(result.status, 200); + assert.equal(result.data.transcript, "Бот поддержки"); + assert.equal(f.writes.length, 0); + assert.equal(f.renames.length, 0); + assert.equal( + (await f.call("/g2/api/home", { token })).data.sessions[0].title, + "one", + ); + assert.equal( + ( + await f.call("/g2/api/voice", { + token, + body: { ...body, purpose: "input" }, + }) + ).status, + 409, + ); + assert.equal(f.writes.length, 0); +}); + +test("local encrypted pairing and session rename use the real controller with unchanged authorization", async (t) => { + const { LocalLink } = + await import("../src/main/services/companion/LocalLink.ts"); + const { localFetcher } = + await import("../integrations/even-g2/src/local-fetch.mjs"); + const f = await fixture(t); + await f.enable(); + const link = new LocalLink(f.directory); + await link.load(); + const origin = f.controller.state().transport.origin; + const connection = link.connection([origin]); + const send = localFetcher(connection, { allowLoopback: true }); + await f.controller.command({ type: "begin-pairing" }); + const pairedResponse = await send(origin + "/g2/api/pair", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + code: f.controller.state().pairing.code, + name: "Local test", + }), + }); + assert.equal(pairedResponse.status, 202); + const pair = await pairedResponse.json(); + const options = { headers: { Authorization: "Bearer " + pair.token } }; + assert.equal((await send(origin + "/g2/api/home", options)).status, 401); + await f.controller.command({ type: "approve", id: pair.id }); + const home = await (await send(origin + "/g2/api/home", options)).json(); + assert.deepEqual( + home.sessions.map((s) => s.id), + ["one"], + ); + const renamed = await send(origin + "/g2/api/session-rename", { + ...options, + method: "POST", + body: JSON.stringify({ + sessionId: "one", + title: "Локальная сессия", + requestId: randomBytes(16).toString("hex"), + sentAt: Date.now(), + }), + }); + assert.equal(renamed.status, 200); + assert.equal((await renamed.json()).session.title, "Локальная сессия"); + assert.deepEqual(f.writes, []); + await f.controller.command({ type: "revoke", id: pair.id }); + assert.equal((await send(origin + "/g2/api/home", options)).status, 401); +}); + +test("six digits establish SRP keys but terminal access still requires desktop approval", async (t) => { + const srp = (await import("secure-remote-password/client.js")).default; + const { PAIRING_IDENTITY } = await import("../src/shared/localDiscovery.ts"); + const { unsealLocal } = await import("../src/shared/localLink.ts"); + const f = await fixture(t); + await f.enable(); + await f.controller.command({ type: "begin-pairing" }); + const code = f.controller.state().pairing.code; + assert.match(code, /^\d{6}$/); + const origin = f.controller.state().transport.origin; + const post = async (path, body) => fetch(origin + path, { method: "POST", + headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }); + const ephemeral = srp.generateEphemeral(); + const response = await post("/g2/pair-start", { public: ephemeral.public }); + assert.equal(response.status, 200); + const challenge = await response.json(); + const session = srp.deriveSession(ephemeral.secret, challenge.public, challenge.salt, + PAIRING_IDENTITY, srp.derivePrivateKey(challenge.salt, PAIRING_IDENTITY, code)); + const finish = await post("/g2/pair-finish", { id: challenge.id, proof: session.proof }); + assert.equal(finish.status, 200); + const reply = await finish.json(); + srp.verifySession(ephemeral.public, session, reply.proof); + const resolved = await unsealLocal({ version: 1, computer: challenge.id, + key: session.key, origins: [origin] }, reply.packet, "response"); + assert.equal(resolved.code, code); + assert.match(resolved.connection.key, /^[a-f0-9]{64}$/); + assert.equal(f.controller.state().pairing.pending, null); + assert.equal((await fetch(origin + "/g2/api/home")).status, 401); + assert.equal((await post("/g2/pair-finish", { id: challenge.id, proof: session.proof })).status, 403); + await f.controller.command({ type: "cancel-pairing" }); + assert.equal((await post("/g2/pair-start", { public: ephemeral.public })).status, 403); +}); + +test("real HTTP client pairs with six digits, rejects wrong PIN and waits for Mac approval", async (t) => { + const { connectionFromCode, localFetcher } = await import("../integrations/even-g2/src/local-fetch.mjs"); + const { pairComputer } = await import("../integrations/even-g2/src/connect.mjs"); + const f = await fixture(t); + await f.enable(); + await f.controller.command({ type: "begin-pairing" }); + const code = f.controller.state().pairing.code; + const origin = f.controller.state().transport.origin; + const options = { origins: [origin], allowLoopback: true }; + await assert.rejects(connectionFromCode(code === "000000" ? "000001" : "000000", options), /Код не принят/); + const resolved = await connectionFromCode(code, options); + assert.equal(resolved.code, code); + const send = localFetcher(resolved.connection, { allowLoopback: true }); + let pendingObserved = false; + const paired = await pairComputer(origin, code, { fetcher: send, + onPending: () => { + pendingObserved = true; + const pending = f.controller.state().pairing.pending; + assert.ok(pending); + void f.controller.command({ type: "approve", id: pending.id }); + }, + }); + assert.ok(pendingObserved); + assert.deepEqual(paired.home.sessions.map(s => s.id), ["one"]); + const response = await send(origin + "/g2/api/control", { method: "POST", + headers: { "Content-Type": "application/json", Authorization: "Bearer " + paired.token }, + body: JSON.stringify({ sessionId: "one", action: "text", text: "six-digit-check", + requestId: randomBytes(16).toString("hex"), sentAt: Date.now() }), + }); + assert.equal(response.status, 200); + assert.equal(f.writes.length, 1); + assert.equal(f.writes[0].id, "one"); +}); + +test("six-digit handshakes enforce expiry and a bounded guess budget", async () => { + const { LocalPairing } = await import("../src/main/services/companion/LocalPairing.ts"); + const srp = (await import("secure-remote-password/client.js")).default; + const value = srp.generateEphemeral().public; + const pair = new LocalPairing("001234", Date.now() + 5000); + for (let i = 0; i < 10; i++) pair.start(value); + assert.throws(() => pair.start(value), /unavailable/); + assert.throws(() => new LocalPairing("001234", Date.now() - 1).start(value), /unavailable/); + assert.throws(() => new LocalPairing("1234567", Date.now() + 5000)); +}); + +test("HTTP creation uses the selected hotbar provider and the desktop workspace/profile", async (t) => { + const { CANVAS_LAUNCHER_ITEMS } = await import("../src/shared/contracts.ts"); + const f = await fixture(t); + await f.enable(); + const { token } = await f.pair(); + for (const provider of CANVAS_LAUNCHER_ITEMS) { + const result = await f.call("/g2/api/create", { token, body: { + provider, requestId: randomBytes(16).toString("hex"), sentAt: Date.now(), + } }); + assert.equal(result.status, 201); + assert.equal(f.creates.at(-1).provider, provider); + assert.equal(f.creates.at(-1).profile, "normal"); + assert.equal(f.creates.at(-1).cwd, f.controller.state().config.workspace); + } +}); diff --git a/tests/even-g2-network.test.mjs b/tests/even-g2-network.test.mjs new file mode 100644 index 000000000..9e1ee7620 --- /dev/null +++ b/tests/even-g2-network.test.mjs @@ -0,0 +1,49 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { + isPrivateIpv4, + lanAddresses, +} from "../src/main/services/companion/LanNetwork.ts"; +const entry = (address, internal = false) => ({ + address, + internal, + family: "IPv4", + netmask: "255.255.255.0", + mac: "00:00:00:00:00:00", + cidr: address + "/24", +}); +test("LAN discovery offers private addresses and prioritizes physical network interfaces", () => { + const result = lanAddresses({ + utun4: [entry("10.8.0.2")], + en1: [entry("192.168.2.100")], + public: [entry("203.0.113.7")], + lo0: [entry("127.0.0.1", true)], + ipv6: [{ ...entry("fe80::1"), family: "IPv6" }], + }); + assert.deepEqual( + result.map((x) => x.address), + ["192.168.2.100", "10.8.0.2"], + ); + assert.deepEqual(lanAddresses({}), []); +}); +test("the listener cannot be configured to a public, wildcard, loopback, or malformed address through discovery", () => { + for (const ip of [ + "0.0.0.0", + "127.0.0.1", + "8.8.8.8", + "100.64.0.1", + "192.169.1.1", + "172.32.0.1", + "10.1.2.999", + "192.168.1.2.attacker.test", + ]) + assert.equal(isPrivateIpv4(ip), false, ip); + for (const ip of ["10.1.2.3", "172.16.0.1", "172.31.255.254", "192.168.1.2"]) + assert.equal(isPrivateIpv4(ip), true, ip); +}); + +test('HTTPS origins are exact and never contain credentials or pairing data',async()=>{ + const {httpsOrigin}=await import('../src/main/services/companion/LanNetwork.ts'); + assert.equal(httpsOrigin(''), '');assert.equal(httpsOrigin('https://bridge.example.test/'),'https://bridge.example.test'); + for(const value of ['http://bridge.example.test','https://user:pass@bridge.example.test','https://bridge.example.test/g2/','https://bridge.example.test/?token=x','https://bridge.example.test/#pair=12345678','https://*.example.test'])assert.throws(()=>httpsOrigin(value)); +}); diff --git a/tests/local-link.test.mjs b/tests/local-link.test.mjs new file mode 100644 index 000000000..be6de6ad0 --- /dev/null +++ b/tests/local-link.test.mjs @@ -0,0 +1,149 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { + localOrigin, + randomLocalHex, + sealLocal, + unsealLocal, + validateLocalRequest, +} from "../src/shared/localLink.ts"; +import { LocalLink } from "../src/main/services/companion/LocalLink.ts"; +import { localFetcher } from "../integrations/even-g2/src/local-fetch.mjs"; +import { SpeechSetup } from "../src/main/services/companion/SpeechSetup.ts"; +import { createHash } from "node:crypto"; +const connection = () => ({ + version: 1, + computer: randomLocalHex(), + key: randomLocalHex(), + origins: [ + "http://192.168.2.3:3481", + "http://[fdea::123]:3481", + "http://example.local:3481", + ], +}); +test("local endpoints reject public, loopback and credential URLs", () => { + for (const url of [ + "https://example.com:3481", + "http://example.com:3481", + "http://8.8.8.8:3481", + "http://127.0.0.1:3481", + "http://192.168.1.1:3481/secret", + "http://u:p@192.168.1.1:3481", + "http://192.168.1.1:3481?x=1", + "http://[2001:db8::1]:3481", + ]) + assert.throws(() => localOrigin(url)); +}); +test("local encryption binds computer, packet and direction, and hides bearer and PCM", async () => { + const c = connection(); + const p = await sealLocal( + c, + { token: "secret bearer", audio: "PCM" }, + "request", + ); + assert.ok(!JSON.stringify(p).includes("secret")); + assert.equal((await unsealLocal(c, p, "request")).audio, "PCM"); + await assert.rejects( + unsealLocal({ ...c, computer: randomLocalHex() }, p, "request"), + ); + await assert.rejects(unsealLocal(c, p, "response")); + await assert.rejects( + unsealLocal(c, { ...p, id: randomLocalHex(16) }, "request"), + ); + assert.throws(() => + validateLocalRequest({ + path: "//example.com", + method: "GET", + token: "", + sentAt: Date.now(), + }), + ); +}); +test("local address fallback probes without credentials and never retries an uncertain mutation", async () => { + const c = connection(); + let actual = 0, + probes = 0; + const fetcher = async (url, options) => { + const p = JSON.parse(options.body), + r = await unsealLocal(c, p, "request"); + if (r.token === "") probes++; + if (url.startsWith(c.origins[0])) throw new Error("VPN blocks IPv4"); + if (r.token) { + actual++; + throw new Error("Reply lost after write"); + } + return new Response( + JSON.stringify( + await sealLocal(c, { status: 401, body: {} }, "response", p.id), + ), + ); + }; + const send = localFetcher(c, { fetcher }); + await assert.rejects( + send(c.origins[0] + "/g2/api/control", { + method: "POST", + headers: { Authorization: "Bearer " + "b".repeat(64) }, + body: JSON.stringify({ action: "text" }), + }), + /локальной связи/, + ); + assert.equal(actual, 1); + assert.equal(probes, 3); +}); +test("local encrypted duplicate is idempotent and identity survives restart", async (t) => { + const path = await mkdtemp(join(tmpdir(), "local-link-")); + t.after(() => rm(path, { recursive: true, force: true })); + const link = new LocalLink(path); + await link.load(); + const c = link.connection(["http://192.168.1.2:3481"]); + const next = new LocalLink(path); + await next.load(); + assert.deepEqual(next.connection(c.origins), c); + const packet = await sealLocal( + c, + { path: "/g2/api/home", method: "GET", token: "", sentAt: Date.now() }, + "request", + ); + let calls = 0; + const forward = async () => { + calls++; + return { status: 401, body: {} }; + }; + await Promise.all([ + link.receive(packet, forward), + link.receive(packet, forward), + ]); + assert.equal(calls, 1); +}); +test("speech preparation checks model bytes before accepting a download; bad download stays unavailable", async (t) => { + const path = await mkdtemp(join(tmpdir(), "speech-setup-")); + t.after(() => rm(path, { recursive: true, force: true })); + const binary = join(path, "helper"); + await writeFile(binary, "fixture"); + const data = Buffer.from("model-fixture"); + const model = { + name: "Test", + bytes: data.length, + sha256: createHash("sha256").update(data).digest("hex"), + url: "https://model.example/pinned", + }; + const setup = new SpeechSetup({ + userDataPath: path, + binary, + model, + fetcher: async () => new Response(data), + }); + await setup.prepare(); + assert.equal(setup.state().phase, "ready"); + const bad = new SpeechSetup({ + userDataPath: join(path, "bad"), + binary, + model, + fetcher: async () => new Response(Buffer.alloc(data.length)), + }); + await assert.rejects(bad.prepare()); + assert.equal(bad.state().phase, "error"); +});