From 0094e0c44a1eaed7a7e6ba59caa3df2fcc0207ac Mon Sep 17 00:00:00 2001 From: Teo | Nexcore Date: Mon, 26 Oct 2026 00:28:50 +0300 Subject: [PATCH 1/4] feat(canvaslink): add experimental Android LAN window control Integrate opt-in owned-window streaming into Settings, add the Android client, contract tests, alpha APK CI and explicit platform/acceptance limits. --- .github/workflows/canvaslink.yml | 45 ++ README.md | 4 + docs/README.md | 1 + docs/canvaslink-verification.md | 67 ++ docs/canvaslink.md | 104 +++ docs/canvaslink.ru.md | 30 + electron.vite.config.ts | 10 +- integrations/canvaslink/.gitignore | 7 + integrations/canvaslink/LICENSE | 21 + .../canvaslink/THIRD_PARTY_NOTICES.md | 17 + .../canvaslink/android/AndroidManifest.xml | 12 + .../canvaslink/android/assets/client.js | 51 ++ .../canvaslink/android/assets/delivery.js | 25 + .../canvaslink/android/assets/direct-input.js | 37 + .../canvaslink/android/assets/gestures.js | 289 ++++++++ .../canvaslink/android/assets/index.html | 14 + .../canvaslink/android/assets/onest-OFL.txt | 93 +++ .../canvaslink/android/assets/onest.ttf | Bin 0 -> 193056 bytes .../canvaslink/android/assets/style.css | 31 + .../canvaslink/android/assets/transport.js | 97 +++ .../res/drawable/ic_launcher_foreground.xml | 10 + .../res/drawable/ic_launcher_monochrome.xml | 10 + .../res/mipmap-anydpi-v26/ic_launcher.xml | 1 + .../res/mipmap-anydpi-v33/ic_launcher.xml | 7 + .../canvaslink/android/res/values/colors.xml | 1 + .../canvaslink/AcceptanceInstrumentation.java | 185 +++++ .../io/nexcore/canvaslink/DeviceUxChecks.java | 89 +++ .../io/nexcore/canvaslink/GraphicsChecks.java | 37 + .../io/nexcore/canvaslink/MainActivity.java | 220 ++++++ .../canvaslink/NativeControlChecks.java | 96 +++ .../io/nexcore/canvaslink/SessionVault.java | 44 ++ .../canvaslink/SettingsTouchChecks.java | 90 +++ .../src/io/nexcore/canvaslink/Srp.java | 61 ++ .../nexcore/canvaslink/TerminalWebView.java | 128 ++++ .../canvaslink/design/canvaslink-icon.svg | 10 + integrations/canvaslink/host/discovery.cjs | 12 + .../canvaslink/host/display-scale.cjs | 13 + integrations/canvaslink/host/embedded.cjs | 138 ++++ integrations/canvaslink/host/focus.cjs | 18 + integrations/canvaslink/host/frames.js | 11 + integrations/canvaslink/host/index.html | 14 + integrations/canvaslink/host/input-queue.cjs | 23 + integrations/canvaslink/host/native-input.cjs | 11 + integrations/canvaslink/host/owned-focus.cjs | 18 + .../canvaslink/host/owned-pointer.cjs | 19 + integrations/canvaslink/host/preload.cjs | 17 + .../canvaslink/host/remote-select.cjs | 30 + integrations/canvaslink/host/renderer.js | 162 ++++ integrations/canvaslink/host/security.cjs | 86 +++ .../canvaslink/host/settings-host.cjs | 11 + integrations/canvaslink/host/settings.css | 9 + integrations/canvaslink/host/snapshot.cjs | 20 + integrations/canvaslink/host/style.css | 1 + integrations/canvaslink/package.json | 19 + .../canvaslink/scripts/build-android.cjs | 40 + .../canvaslink/scripts/ui-test-harness.cjs | 32 + .../tests/control-delivery.test.cjs | 107 +++ .../canvaslink/tests/delivery.test.cjs | 8 + .../tests/embedded-lifecycle.test.cjs | 64 ++ .../canvaslink/tests/frontend.test.cjs | 46 ++ .../canvaslink/tests/gestures-ui.test.cjs | 322 ++++++++ .../tests/graphics-quality.test.cjs | 29 + .../canvaslink/tests/host-peer.test.cjs | 67 ++ integrations/canvaslink/tests/icon.test.cjs | 162 ++++ .../canvaslink/tests/owned-focus.test.cjs | 7 + .../canvaslink/tests/owned-pointer.test.cjs | 16 + .../canvaslink/tests/remote-select.test.cjs | 8 + .../canvaslink/tests/security.test.cjs | 55 ++ .../canvaslink/tests/settings-host.test.cjs | 26 + .../canvaslink/tests/settings-touch.test.cjs | 39 + .../tests/transport-recovery.test.cjs | 41 + .../canvaslink/verification/icon-preview.cjs | 128 ++++ package-lock.json | 699 +++++++++++++++++- package.json | 8 +- scripts/smoke-canvaslink.mjs | 68 ++ src/main/index.ts | 4 + src/main/services/canvasLink.ts | 19 + src/preload/index.ts | 4 + .../features/settings/CanvasLinkSettings.css | 9 + .../features/settings/CanvasLinkSettings.tsx | 85 +++ .../src/features/settings/SettingsPanel.tsx | 6 +- src/renderer/src/lib/i18n.ts | 2 + src/shared/canvasLink.ts | 14 + src/shared/contracts.ts | 1 + 84 files changed, 4686 insertions(+), 6 deletions(-) create mode 100644 .github/workflows/canvaslink.yml create mode 100644 docs/canvaslink-verification.md create mode 100644 docs/canvaslink.md create mode 100644 docs/canvaslink.ru.md create mode 100644 integrations/canvaslink/.gitignore create mode 100644 integrations/canvaslink/LICENSE create mode 100644 integrations/canvaslink/THIRD_PARTY_NOTICES.md create mode 100644 integrations/canvaslink/android/AndroidManifest.xml create mode 100644 integrations/canvaslink/android/assets/client.js create mode 100644 integrations/canvaslink/android/assets/delivery.js create mode 100644 integrations/canvaslink/android/assets/direct-input.js create mode 100644 integrations/canvaslink/android/assets/gestures.js create mode 100644 integrations/canvaslink/android/assets/index.html create mode 100644 integrations/canvaslink/android/assets/onest-OFL.txt create mode 100644 integrations/canvaslink/android/assets/onest.ttf create mode 100644 integrations/canvaslink/android/assets/style.css create mode 100644 integrations/canvaslink/android/assets/transport.js create mode 100644 integrations/canvaslink/android/res/drawable/ic_launcher_foreground.xml create mode 100644 integrations/canvaslink/android/res/drawable/ic_launcher_monochrome.xml create mode 100644 integrations/canvaslink/android/res/mipmap-anydpi-v26/ic_launcher.xml create mode 100644 integrations/canvaslink/android/res/mipmap-anydpi-v33/ic_launcher.xml create mode 100644 integrations/canvaslink/android/res/values/colors.xml create mode 100644 integrations/canvaslink/android/src/io/nexcore/canvaslink/AcceptanceInstrumentation.java create mode 100644 integrations/canvaslink/android/src/io/nexcore/canvaslink/DeviceUxChecks.java create mode 100644 integrations/canvaslink/android/src/io/nexcore/canvaslink/GraphicsChecks.java create mode 100644 integrations/canvaslink/android/src/io/nexcore/canvaslink/MainActivity.java create mode 100644 integrations/canvaslink/android/src/io/nexcore/canvaslink/NativeControlChecks.java create mode 100644 integrations/canvaslink/android/src/io/nexcore/canvaslink/SessionVault.java create mode 100644 integrations/canvaslink/android/src/io/nexcore/canvaslink/SettingsTouchChecks.java create mode 100644 integrations/canvaslink/android/src/io/nexcore/canvaslink/Srp.java create mode 100644 integrations/canvaslink/android/src/io/nexcore/canvaslink/TerminalWebView.java create mode 100644 integrations/canvaslink/design/canvaslink-icon.svg create mode 100644 integrations/canvaslink/host/discovery.cjs create mode 100644 integrations/canvaslink/host/display-scale.cjs create mode 100644 integrations/canvaslink/host/embedded.cjs create mode 100644 integrations/canvaslink/host/focus.cjs create mode 100644 integrations/canvaslink/host/frames.js create mode 100644 integrations/canvaslink/host/index.html create mode 100644 integrations/canvaslink/host/input-queue.cjs create mode 100644 integrations/canvaslink/host/native-input.cjs create mode 100644 integrations/canvaslink/host/owned-focus.cjs create mode 100644 integrations/canvaslink/host/owned-pointer.cjs create mode 100644 integrations/canvaslink/host/preload.cjs create mode 100644 integrations/canvaslink/host/remote-select.cjs create mode 100644 integrations/canvaslink/host/renderer.js create mode 100644 integrations/canvaslink/host/security.cjs create mode 100644 integrations/canvaslink/host/settings-host.cjs create mode 100644 integrations/canvaslink/host/settings.css create mode 100644 integrations/canvaslink/host/snapshot.cjs create mode 100644 integrations/canvaslink/host/style.css create mode 100644 integrations/canvaslink/package.json create mode 100644 integrations/canvaslink/scripts/build-android.cjs create mode 100644 integrations/canvaslink/scripts/ui-test-harness.cjs create mode 100644 integrations/canvaslink/tests/control-delivery.test.cjs create mode 100644 integrations/canvaslink/tests/delivery.test.cjs create mode 100644 integrations/canvaslink/tests/embedded-lifecycle.test.cjs create mode 100644 integrations/canvaslink/tests/frontend.test.cjs create mode 100644 integrations/canvaslink/tests/gestures-ui.test.cjs create mode 100644 integrations/canvaslink/tests/graphics-quality.test.cjs create mode 100644 integrations/canvaslink/tests/host-peer.test.cjs create mode 100644 integrations/canvaslink/tests/icon.test.cjs create mode 100644 integrations/canvaslink/tests/owned-focus.test.cjs create mode 100644 integrations/canvaslink/tests/owned-pointer.test.cjs create mode 100644 integrations/canvaslink/tests/remote-select.test.cjs create mode 100644 integrations/canvaslink/tests/security.test.cjs create mode 100644 integrations/canvaslink/tests/settings-host.test.cjs create mode 100644 integrations/canvaslink/tests/settings-touch.test.cjs create mode 100644 integrations/canvaslink/tests/transport-recovery.test.cjs create mode 100644 integrations/canvaslink/verification/icon-preview.cjs create mode 100644 scripts/smoke-canvaslink.mjs create mode 100644 src/main/services/canvasLink.ts create mode 100644 src/renderer/src/features/settings/CanvasLinkSettings.css create mode 100644 src/renderer/src/features/settings/CanvasLinkSettings.tsx create mode 100644 src/shared/canvasLink.ts diff --git a/.github/workflows/canvaslink.yml b/.github/workflows/canvaslink.yml new file mode 100644 index 000000000..bf64d4ab3 --- /dev/null +++ b/.github/workflows/canvaslink.yml @@ -0,0 +1,45 @@ +name: CanvasLink alpha + +on: + pull_request: + workflow_dispatch: + +permissions: + contents: read + +jobs: + contracts: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-node@v7 + with: + node-version: 22 + cache: npm + - run: npm ci --ignore-scripts + - run: npx playwright install --with-deps chromium + - run: npm run test:canvaslink + - run: npm run typecheck + + android-debug: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-node@v7 + with: + node-version: 22 + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '17' + - uses: android-actions/setup-android@v3 + - run: sdkmanager 'platforms;android-35' 'build-tools;35.0.0' + - run: node integrations/canvaslink/scripts/build-android.cjs + - name: Upload alpha APK (ephemeral CI development signature) + uses: actions/upload-artifact@v4 + with: + name: CanvasLink-debug + path: integrations/canvaslink/dist/CanvasLink-debug.apk + retention-days: 30 diff --git a/README.md b/README.md index 5efdb8790..cb68208d7 100644 --- a/README.md +++ b/README.md @@ -69,6 +69,10 @@ npm run dev 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. +## CanvasLink for Android (alpha) + +**Settings → CanvasLink** streams the current CanvasTTY window to an Android phone or tablet on the same LAN, with touch, direct terminal keyboard input and a shortcut bar. Access starts off; pairing uses a short-lived six-digit code. The desktop host is integrated, with no separate application. See [setup, APK builds and platform limits](docs/canvaslink.md). The prototype has macOS/Android user acceptance; this source port remains experimental, with Windows/Linux device acceptance pending. + ## Web companion The opt-in [Web companion](docs/mobile-companion.md) shares selected sessions with Android, iOS, and desktop browsers through **Tailscale Serve HTTPS**, not Funnel. CanvasTTY binds its companion endpoint to `127.0.0.1:3481` in this mode; pair with a short-lived code and approve each browser on the desktop. Open **Settings → Controls → Web companion** for setup, grants, and revocation. End-to-end Tailscale Serve deployment on Windows, Linux, and macOS remains unverified. diff --git a/docs/README.md b/docs/README.md index 3e0c19d1d..5a4c007bb 100644 --- a/docs/README.md +++ b/docs/README.md @@ -12,6 +12,7 @@ CanvasTTY is a spatial Electron desktop for real local terminals and AI-agent CL | [Built-in browser and audit log](browser.md) | Canvas controls, settings, agent access, website/file boundaries, activity, and persistent redacted audit files | | [File browser and reader](files.md) | Opening a Files card, session and folder roots, supported content, read limits, quick open, persistence, and read-only security boundaries | | [Web companion](mobile-companion.md) | Tailscale Serve HTTPS setup, browser pairing, session grants, revocation, and security limits | +| [CanvasLink for Android (alpha)](canvaslink.md) | Full-window LAN streaming, touch/keyboard input, APK build, security boundaries and platform limits | | [Installing, releases, and local data](installing-and-security.md) | Installer formats, unsigned-preview caveats, credential boundaries, and release checks | | [Widget authoring](widget-authoring.md) | Source-level extension paths, visual grammar, process boundaries, and an AI-agent brief | | [Pixel terminal themes (Russian)](pixel-skin-packs.ru.md) | Ten-PNG ZIP layout, terminal apertures, app import, and local agent CLI | diff --git a/docs/canvaslink-verification.md b/docs/canvaslink-verification.md new file mode 100644 index 000000000..3557f2630 --- /dev/null +++ b/docs/canvaslink-verification.md @@ -0,0 +1,67 @@ +# CanvasLink alpha: verification scope + +The source port is based on upstream commit +`0fa6895d3675b40ec4dc75886826dd2db234c234` (package version 1.8.1). +It does not replace the user's installed CanvasTTY 1.8.2 or its active preview. + +## Prior prototype acceptance + +The user manually exercised the macOS/Android prototype and confirmed it worked +before publication preparation. Its installed APK SHA-256 was +`48b967d7102c8e733666a630562b420e5bee5ac3bc734ad065d7a89ffd038783`. +Prior bounded device checks covered real terminal input/ACKs, Settings touch, +native selects, resume, Wi-Fi and high-density capture on Android 12. +This is prototype evidence, not device acceptance of this source-port package. + +## Source-port checks + +- General upstream suite in an isolated source snapshot with a clean environment: + 2,221 passed, 27 skipped, zero failures. No Go toolchain was present; native-helper + parity/release coverage is not claimed. +- 103 CanvasLink tests passed: pairing/authentication/replay rejection, input + ordering and uncertainty, reconnect races, direct-input/gesture fixtures, + select/touch behavior, graphics bounds, icons and IPC/lifecycle ownership. +- TypeScript node and renderer checks passed. +- Production desktop bundles built with the integrated settings section, + preload API and copied host assets. +- Android 0.3.0-alpha.1 debug APK built; apksigner verified v2/v3 signatures. + Instrumentation entry points are excluded from the normal APK. +- Even G2: 47 tests passed; its access and pairing implementation is unchanged. +- Source and built-output secret audit passed. +- npm audit found six advisories (five high, one moderate). An audit of the + unchanged upstream lockfile reports the same six dependency names; no + dependency-security remediation is claimed by this feature port. + +The general upstream suite initially exposed a path-handling bug in existing +hook tests when the checkout path contains spaces (URL `.pathname` retains +`%20`). The affected hook tests passed through a temporary path without spaces +using Node's `--preserve-symlinks` and `--preserve-symlinks-main`. The final full +run used a real source copy without spaces and removed inherited agent-capability +environment variables. This port does not change unrelated hook handling. + +The Android HTML/CSS/JS/font assets are byte-identical to the accepted prototype. +The new normal APK omits instrumentation and carries the alpha version: +SHA-256 `509e44e12d0be251db6ed323e03035a0e2d16897c519a439679e8533ff5934fb`. +It has not replaced the installed APK. + +A macOS directory package reached ASAR creation; inspection found the host assets, +bonjour-service and secure-remote-password and no acceptance instrumentation. +Final ad-hoc signing failed with a local “internal error in Code Signing +subsystem”. No signing certificate/keychain access was requested. This is not a +successfully signed distributable. The standalone hidden Electron smoke also +failed to start to completion in this environment (timeout with no script output), +including an attempt from a disposable unprotected directory. Its runtime result +is **unverified**; the installed prototype remains the only user-accepted runtime. + +## Required release acceptance + +Real packaged-build acceptance for this source port remains pending. CI and +synthetic fixtures do not prove GPU capture, Android IME, TalkBack, rotation, +host firewalls or real device input on Windows/Linux. Public release signing, +store distribution and a 30 FPS guarantee are outside this alpha. + +The `CanvasLink alpha` workflow runs the contracts and builds a downloadable +development APK. Its CI signature is ephemeral; it is not an update channel. +Run `node scripts/smoke-canvaslink.mjs` after the desktop build to exercise real +Electron preload/IPC and host enable/revoke/disable against a disposable hidden +fixture. It does not send input to user terminals or provide video acceptance. diff --git a/docs/canvaslink.md b/docs/canvaslink.md new file mode 100644 index 000000000..bfbec58ad --- /dev/null +++ b/docs/canvaslink.md @@ -0,0 +1,104 @@ +# CanvasLink for Android (experimental alpha) + +CanvasLink streams the **whole current CanvasTTY window** to an Android phone or +tablet on the same private IPv4 LAN. Touch, terminal keyboard input and shortcuts +are delivered to that window. This includes Settings and any sensitive content +visible in it. Enable access only for a device you trust. + +The host lives in **Settings → CanvasLink**. There is no separate desktop app to +install. It starts **off** on every desktop launch. The existing Web companion +and Even G2 integration retain their separate pairing and permissions. + +## Connect + +1. Install the development APK from a trusted build of this branch (Android 8+). +2. Open Settings → CanvasLink on the desktop and choose **Enable access**. +3. Open CanvasLink on Android, select the discovered computer and enter the six + digits shown on the desktop. The code expires after two minutes and is used + once. If discovery is blocked, the Android connection screen has a manual + private IPv4 address option. Both devices must be on the same non-isolated LAN. +4. Tap a terminal to open the Android keyboard. Text goes directly into the + focused terminal; Enter executes it. The shortcut bar includes paste and + interrupt. Zoom/pan can bring a terminal into view. +5. **Forget device** revokes its session and generates a new code. **Turn off + access** closes the listener, discovery, video and input. A desktop restart + requires fresh pairing. Sessions expire after twelve hours and are bound to + the client's address; a changed address can require pairing again. + +The app distinguishes processed, pending and uncertain input. A disconnect does +not automatically replay a command. Check the terminal before retrying uncertain +input. Clipboard paste uses the Android clipboard; it does not read the desktop +clipboard. + +## Transport and boundaries + +- Host listener: private IPv4 clients on TCP 3499; mDNS `_canvaslink._tcp` discovery. + Do not expose or forward this port to the Internet. Networks may require an + inbound firewall allowance. No firewall rule or startup service is installed. +- No cloud service, STUN or TURN. Pairing uses SRP-6a; the code is not sent as + plaintext. Signalling uses AES-256-GCM with authenticated sequence numbers; + WebRTC media/data use DTLS-SRTP/SCTP encryption. The HTTP endpoint is not HTTPS; + the application protocol supplies the encryption. +- Pairing attempts and request sizes are bounded. A code authorizes the **whole + window**, not a selected-session grant. Treat the Android device as a keyboard + and pointer with the same authority as the person at that window. +- Electron capture is restricted to CanvasTTY's own main frame; input uses its + `webContents`, never OS-wide keyboard or pointer injection. This integrated + path does not require Accessibility control of other applications. +- A hidden isolated renderer handles media. Its IPC and the settings IPC reject + other renderers and child frames. Private test sockets, startup automation, + instrumentation entry points and global input helpers are not shipped. + +## Build + +From the repository root: + +```sh +npm ci +npx playwright install chromium +npm run test:canvaslink +npm run typecheck +npm run build +``` + +For the APK, install JDK 17 and Android SDK platform 35/build-tools 35.0.0, set +`JAVA_HOME` and `ANDROID_SDK_ROOT` (or `ANDROID_HOME`), then run: + +```sh +npm run build:canvaslink:android +``` + +Output: `integrations/canvaslink/dist/CanvasLink-debug.apk`. No Gradle or Android +Java dependencies are downloaded by the build script. A local development key +is generated under the ignored `.runtime` directory. CI produces an APK artifact +with an ephemeral development signature. Different builds can have different +keys and may not install as an update over one another. Keep your existing app +and its data until you have planned the transition. Release signing/distribution +is not configured by this PR. + +## Quality and platform status + +The prototype was physically tested on macOS with an Android 12 tablet and +accepted by its user before this source port. That acceptance applies to the +prototype APK/preview, not automatically to a newly packaged desktop or APK. +See [the port verification record](canvaslink-verification.md). + +Windows and Linux have not received physical device acceptance. Their shared +Electron path and JavaScript discovery implementation are candidates for testing, +not a verified compatibility promise. CI tests contracts and builds an APK; it +cannot establish real IME, GPU capture, firewall or touch behavior on those hosts. + +Capture first tries the owned renderer stream and falls back to bounded raw +snapshots (up to 2560×1600). The prototype's observed fallback was around 3–6 FPS; +30 FPS is not promised. High density can improve text clarity at a cost in CPU +and bandwidth. Normal desktop density is unchanged. For an isolated developer +run, `--force-device-scale-factor=2` supplies density at process start, or +`--canvaslink-high-density` requests one relaunch with that switch. Never enable +this while preserving an active terminal session without planning the restart. + +Before promotion beyond alpha: test a packaged build with a physical Android +device on macOS, Windows and Linux; include discovery, pairing/revocation, +Settings touch, native selects, IME composition, Ctrl+C/paste, Unicode, rotation, +background/resume, network loss, minimized/closed windows and capture quality. + +Краткая инструкция по-русски: [canvaslink.ru.md](canvaslink.ru.md). diff --git a/docs/canvaslink.ru.md b/docs/canvaslink.ru.md new file mode 100644 index 000000000..08647bdd4 --- /dev/null +++ b/docs/canvaslink.ru.md @@ -0,0 +1,30 @@ +# CanvasLink: Android по локальной сети + +Экспериментальная интеграция: планшет показывает **всё текущее окно CanvasTTY** +и управляет им. Доступ включает терминалы, настройки и видимые секреты. +Подключайте только своё доверенное устройство. + +1. Установите APK доверенной сборки на Android 8 или новее. +2. На компьютере откройте **Настройки → CanvasLink → Включить доступ**. +3. Откройте CanvasLink на планшете, выберите найденный компьютер и введите код. + Оба устройства должны быть в одной локальной сети без изоляции клиентов. + Ручной адрес нужен только при недоступном обнаружении. +4. Коснитесь терминала: появится клавиатура. Ввод передаётся напрямую; + Enter выполняет команду. В панели есть вставка и прерывание. +5. «Забыть устройство» отзывает сессию. «Выключить доступ» останавливает сеть, + видео и управление. При новом запуске компьютера доступ выключен. + +Одноразовый код действует две минуты. Сессия — до двенадцати часов; смена адреса +может потребовать нового подключения. При обрыве команды не повторяются +автоматически: сначала проверьте терминал. Вставка берёт буфер Android. + +Отдельное приложение на компьютере не требуется. Очки Even G2 и Web companion +сохраняют свои отдельные коды и разрешения. Порт 3499 не открывайте в Интернет. + +Проверенная пользователем версия работала на Mac с планшетом Android 12. +Этот перенос в исходники проходит отдельные проверки. Реальная работа с +Windows и Linux пока не подтверждена; плавность 30 FPS не обещается. +Отладочные APK разных сборок могут иметь разные подписи и не устанавливаться +поверх друг друга. Рабочую установленную версию сохраняйте до проверки новой. + +Команды сборки, устройство защиты и границы проверки: [canvaslink.md](canvaslink.md). diff --git a/electron.vite.config.ts b/electron.vite.config.ts index eefc35363..c175d1931 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -1,4 +1,5 @@ import { resolve } from "node:path"; +import { cpSync, mkdirSync } from "node:fs"; import react from "@vitejs/plugin-react"; import { defineConfig, externalizeDepsPlugin } from "electron-vite"; @@ -11,7 +12,14 @@ export default defineConfig({ || "" ) }, - plugins: [externalizeDepsPlugin()], + plugins: [externalizeDepsPlugin(), { + name: "canvaslink-host-assets", + closeBundle() { + const destination = resolve("out/main/canvaslink/host"); + mkdirSync(destination, { recursive: true }); + cpSync(resolve("integrations/canvaslink/host"), destination, { recursive: true }); + } + }], build: { rollupOptions: { input: resolve("src/main/index.ts") diff --git a/integrations/canvaslink/.gitignore b/integrations/canvaslink/.gitignore new file mode 100644 index 000000000..172582f4f --- /dev/null +++ b/integrations/canvaslink/.gitignore @@ -0,0 +1,7 @@ +node_modules/ +build/ +dist/ +.runtime/ +*.log +verification/*.png +verification/icon-previews.json diff --git a/integrations/canvaslink/LICENSE b/integrations/canvaslink/LICENSE new file mode 100644 index 000000000..d57e4c69a --- /dev/null +++ b/integrations/canvaslink/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 CanvasLink contributors + +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/canvaslink/THIRD_PARTY_NOTICES.md b/integrations/canvaslink/THIRD_PARTY_NOTICES.md new file mode 100644 index 000000000..7c045f906 --- /dev/null +++ b/integrations/canvaslink/THIRD_PARTY_NOTICES.md @@ -0,0 +1,17 @@ +# Dependencies + +- Electron 43.7.7 — MIT; Chromium and other bundled notices remain inside the + Electron distribution (`LICENSE`, `LICENSES.chromium.html`). +- bonjour-service 1.3.0 — MIT. +- secure-remote-password 0.3.1 — MIT. + +Transitive dependency licenses remain in their installed package directories. +The CanvasTTY desktop bundle retains production dependency licenses and Electron's +original bundled notices. Android uses platform APIs and contains no embedded +third-party Java libraries. No RustDesk or spacedesk source or binaries are used. + +## Onest + +Bundled Android UI font, from google/fonts/ofl/onest (Onest Project Authors). +SIL Open Font License 1.1; full license: `android/assets/onest-OFL.txt`. +Source: https://github.com/google/fonts/tree/main/ofl/onest diff --git a/integrations/canvaslink/android/AndroidManifest.xml b/integrations/canvaslink/android/AndroidManifest.xml new file mode 100644 index 000000000..e4493af36 --- /dev/null +++ b/integrations/canvaslink/android/AndroidManifest.xml @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/integrations/canvaslink/android/assets/client.js b/integrations/canvaslink/android/assets/client.js new file mode 100644 index 000000000..4a53883aa --- /dev/null +++ b/integrations/canvaslink/android/assets/client.js @@ -0,0 +1,51 @@ +(()=>{ + 'use strict'; + const $=id=>document.getElementById(id),api=window.CanvasLinkClient,input=window.CanvasLinkInput; + let state=api.getState(),selected='',signature='',lastPointer=null,touchTimer,noticeTimer,viewTarget=''; + const put=(id,value)=>{if($(id)&&$(id).textContent!==value)$(id).textContent=value;}; + function targetStatus(){const kind=(state.source?.target==='terminal'||viewTarget==='terminal'&&state.source?.surface!=='settings')?'Терминал':state.source?.target==='text-field'?'Текстовое поле':state.source?.surface==='settings'?'Настройки':'Canvas';put('target-status',(input.active()?'Ввод · ':'')+kind);document.querySelector('.session-badge').dataset.state=state.connection;} + const errors={'delivery-uncertain':'Доставка не подтверждена. Проверьте терминал. Ввод остановлен, повтора не будет.','input-busy':'Канал занят. Ввод остановлен. Коснитесь терминала, чтобы продолжить.','not-connected':'Связь прервана. Ввод остановлен.','text-target-required':'Коснитесь терминала или текстового поля.','invalid-code':'Введите шесть цифр из Настройки → CanvasLink.','input-rejected':'Компьютер отклонил ввод.'}; + function notice(value){put('input-status',errors[value]||value);$('input-status').hidden=!value;clearTimeout(noticeTimer);if(value)noticeTimer=setTimeout(()=>{$('input-status').hidden=true;},7000);} + function updateConnect(){const busy=['connecting','pairing','resuming'].includes(state.connection);$('connect').disabled=busy||!/^\d{6}$/.test($('pin').value)||(!selected&&!($('manual').open&&$('address').value.trim()));$('connect').querySelector('span').textContent=busy?'Подключаем…':'Подключиться';} + function renderComputers(s){ + const list=s.computers||[];if(!list.some(c=>c.id===selected))selected=list.length===1?list[0].id:''; + const next=JSON.stringify(list.map(c=>[c.id,c.name])); + if(next!==signature){signature=next;$('computers').replaceChildren();for(const c of list){const button=document.createElement('button');button.type='button';button.dataset.computerId=c.id;button.className='computer';const name=document.createElement('span');name.textContent=String(c.name||'CanvasTTY').replace(/^CanvasLink\s+/,'');const icon=document.createElement('span');icon.className='computer-icon';icon.textContent='▣';const check=document.createElement('span');check.className='computer-check';check.textContent='✓';button.append(icon,name,check);button.onclick=()=>{selected=c.id;renderComputers(state);updateConnect();};$('computers').append(button);}} + for(const b of $('computers').querySelectorAll('button'))b.setAttribute('aria-pressed',String(b.dataset.computerId===selected)); + $('pair-status').hidden=!!list.length;put('pair-status',s.discovery==='searching'?'Ищем компьютер в вашей Wi-Fi сети…':'Включите CanvasLink в настройках CanvasTTY на компьютере.'); + } + function render(s){ + state=s;const show=s.connection==='connected'||['reconnecting','suspended'].includes(s.connection)||(!['idle','error'].includes(s.connection)&&!$('workspace').hidden); + $('workspace').hidden=!show;$('pairing').hidden=show;document.body.classList.toggle('is-workspace',show);renderComputers(s);updateConnect(); + const labels={connected:'Подключено',connecting:'Подключаем…',pairing:'Проверяем код…',resuming:'Восстанавливаем…',reconnecting:'Нет связи',suspended:'На паузе'}; + put('connection-status',labels[s.connection]||'Нет связи');$('connection-pill').dataset.state=s.connection; + for(const button of $('controls').querySelectorAll('button'))if(button.id!=='menu-toggle')button.disabled=s.connection!=='connected'; + const stats=s.stats||{},source=s.source||{}; + const wait=s.connection!=='connected'||!stats.framesDecoded; // A static frame remains usable; do not cover it with an opaque waiting card. + $('waiting').hidden=!wait;$('resume').hidden=!['reconnecting','suspended'].includes(s.connection); + put('waiting-title',s.connection==='suspended'?'Соединение на паузе':s.connection==='reconnecting'?'Восстанавливаем связь':'Открываем CanvasTTY'); + put('waiting-copy',s.connection==='reconnecting'?'Ввод остановлен. Ничего не отправляем повторно.':'Изображение появится здесь.'); + targetStatus(); + put('metrics',`Видео ${stats.width||'—'} × ${stats.height||'—'}\nКадров/с ${Number.isFinite(stats.framesPerSecond)?stats.framesPerSecond.toFixed(1):'—'}\nRTT канала ${Number.isFinite(stats.rtt)?Math.round(stats.rtt)+' мс':'—'}\nОжидают ACK ${s.pending||0}`); + const error=s.error?errors[s.error]||s.error:'';put('pair-error',show?'':error);put('stream-error',show?error:'');$('stream-error').hidden=!show||!error; + } + $('pair-form').onsubmit=e=>{e.preventDefault();try{if(api.connect({code:$('pin').value,computerId:selected||undefined,host:$('manual').open?$('address').value.trim()||undefined:undefined})){$('pin').value='';document.activeElement?.blur();api.hideKeyboard();}}catch(error){put('pair-error',errors[error.message]||error.message);}updateConnect();}; + $('pin').oninput=updateConnect;$('address').oninput=updateConnect;$('manual').ontoggle=updateConnect;$('rescan').onclick=()=>api.discover();$('resume').onclick=()=>api.resume(); + // Pointer-down never steals the native terminal IME focus. + $('controls').addEventListener('pointerdown',e=>{if(e.target.closest('button'))e.preventDefault();}); + for(const b of document.querySelectorAll('[data-key]'))b.onclick=()=>input.key(b.dataset.key,b.dataset.control?['control']:[]); + $('ctrl').onclick=()=>input.toggleControl();$('paste').onclick=()=>input.paste(); + $('keyboard-toggle').onclick=()=>{if(input.active())input.stop();else notice('Коснитесь терминала — клавиатура откроется сама.');}; + $('menu-toggle').onclick=()=>{input.stop();$('session-menu').showModal();};$('menu-close').onclick=()=>$('session-menu').close(); + $('diagnostics-toggle').onclick=()=>{$('session-menu').close();$('diagnostics').showModal();};$('diagnostics-close').onclick=()=>$('diagnostics').close(); + $('disconnect').onclick=()=>{$('session-menu').close();$('disconnect-dialog').showModal();};$('cancel-disconnect').onclick=()=>$('disconnect-dialog').close();$('confirm-disconnect').onclick=()=>{$('disconnect-dialog').close();input.stop();api.disconnect();}; + for(const id of ['fit','mode','reset-view'])$(id).addEventListener('click',()=>{$('session-menu').close();}); + window.addEventListener('canvaslink:tap',e=>{if(document.querySelector('dialog[open]')||state.connection!=='connected')return;input.open(e.detail.focus);}); + window.addEventListener('canvaslink:notice',e=>notice(e.detail)); + window.addEventListener('canvaslink:input-state',e=>{const d=e.detail;$('ctrl').setAttribute('aria-pressed',String(d.control));$('keyboard-toggle').setAttribute('aria-pressed',String(d.active));$('keyboard-toggle').setAttribute('aria-label',d.active?'Скрыть клавиатуру':'Как открыть клавиатуру');$('workspace').classList.toggle('is-typing',d.active);if(d.composition!==undefined){put('preedit',d.composition);$('preedit').hidden=!d.composition;}if(d.focus)state={...state,source:{...state.source,...d.focus,target:d.focus.kind}};targetStatus();}); + function pointerPosition(p){const video=$('video'),b=video.getBoundingClientRect(),v=$('viewport').getBoundingClientRect(),w=video.videoWidth||state.stats?.width||1280,h=video.videoHeight||state.stats?.height||800,k=Math.min(b.width/w,b.height/h);return{x:b.left-v.left+(b.width-w*k)/2+p.x*w*k,y:b.top-v.top+(b.height-h*k)/2+p.y*h*k};} + function paint(p,pulse){const q=pointerPosition(p);$('local-cursor').hidden=!p.trackpad;$('local-cursor').style.transform=`translate(${q.x-4}px,${q.y-3}px)`;if(pulse&&p.action==='down'){const f=$('touch-feedback');clearTimeout(touchTimer);f.hidden=false;f.style.transform=`translate(${q.x-16}px,${q.y-16}px)`;touchTimer=setTimeout(()=>{f.hidden=true;},280);}} + window.addEventListener('canvaslink:pointer',e=>{lastPointer=e.detail;paint(lastPointer,true);});window.addEventListener('canvaslink:view',e=>{viewTarget=e.detail.target||'';targetStatus();if(lastPointer){lastPointer.trackpad=e.detail.trackpad;paint(lastPointer,false);}}); + const resize=()=>{document.documentElement.style.setProperty('--app-height',Math.max(120,Math.round(window.visualViewport?.height||innerHeight))+'px');if(lastPointer)paint(lastPointer,false);};window.visualViewport?.addEventListener('resize',resize);window.addEventListener('resize',resize);resize(); + api.attachVideo($('video'));api.subscribe(render); +})(); diff --git a/integrations/canvaslink/android/assets/delivery.js b/integrations/canvaslink/android/assets/delivery.js new file mode 100644 index 000000000..cb1537500 --- /dev/null +++ b/integrations/canvaslink/android/assets/delivery.js @@ -0,0 +1,25 @@ +/* Transport contract v2. No DOM dependencies; shared by the client and tests. */ +(function(root,factory){if(typeof module==='object')module.exports=factory();else root.CanvasLinkDelivery=factory();})(globalThis,()=>{ + class Delivery { + constructor({channel,onChange=()=>{},timeout=65000,setTimer=(fn,ms)=>setTimeout(fn,ms),clearTimer=token=>clearTimeout(token),id=()=>crypto.randomUUID()}){Object.assign(this,{channel,onChange,timeout,setTimer,clearTimer,id});this.pending=new Map();this.sent=0;this.acked=0;this.seq=0;} + send(input){ + const dc=this.channel(); + if(!dc||dc.readyState!=='open')return Promise.reject(Error('not-connected')); + if(dc.bufferedAmount>65536||this.pending.size>=64)return Promise.reject(Error('input-busy')); + const id=this.id(); + return new Promise((resolve,reject)=>{ + const entry={resolve,reject,input,timer:this.setTimer(()=>this.finish(id,false,'delivery-uncertain'),this.timeout)}; + this.pending.set(id,entry); + try{dc.send(JSON.stringify({...input,id,seq:++this.seq}));this.sent++;this.onChange({id,status:'pending',kind:input.type,pending:this.pending.size});} + catch{this.finish(id,false,'not-sent');} + }); + } + finish(id,ok,error,focus){const entry=this.pending.get(id);if(!entry)return false;this.pending.delete(id);this.clearTimer(entry.timer);if(ok)this.acked++; + this.onChange({id,status:ok?'processed':error==='delivery-uncertain'?'uncertain':'failed',kind:entry.input.type,error:error||'',pending:this.pending.size}); + if(ok)entry.resolve({id,status:'processed',...(focus?{focus}:{})});else entry.reject(Error(error||'input-rejected'));return true; + } + receive(value){if(!['input-ok','input-error'].includes(value?.type))return false;return this.finish(value.id,value.type==='input-ok',value.error,value.focus);} + close(){for(const id of [...this.pending.keys()])this.finish(id,false,'delivery-uncertain');} + } + return {Delivery}; +}); diff --git a/integrations/canvaslink/android/assets/direct-input.js b/integrations/canvaslink/android/assets/direct-input.js new file mode 100644 index 000000000..56f1ccf15 --- /dev/null +++ b/integrations/canvaslink/android/assets/direct-input.js @@ -0,0 +1,37 @@ +/* Ordered, immediate terminal input. Only the peer owns the queue; never replay. */ +(()=>{ + const api=window.CanvasLinkClient; + let token='',control=false,inflight=0; + const notify=detail=>window.dispatchEvent(new CustomEvent('canvaslink:input-state',{detail:{active:!!token,control,inflight,...detail}})); + function stop(focus){token='';control=false;api.hideKeyboard();notify({composition:'',...(focus?{focus}:{})});} + function fail(error){stop();window.dispatchEvent(new CustomEvent('canvaslink:notice',{detail:error.message||String(error)}));} + function send(value){ + if(api.getState().connection!=='connected')return Promise.reject(Error('not-connected')); + const owner=token;inflight++;notify(); + let promise;try{promise=api.sendInput(value);}catch(error){promise=Promise.reject(error);} + return Promise.resolve(promise).then(result=>{if(result?.status!=='processed')throw Error('delivery-uncertain');return result;}).catch(error=>{if(owner===token)fail(error);throw error;}).finally(()=>{inflight--;notify();}); + } + window.CanvasLinkInput=Object.freeze({ + open(focus){if(!focus?.editable)return stop(focus);token=crypto.randomUUID();control=false;notify({focus,composition:''});if(typeof Native?.showKeyboard==='function')Native.showKeyboard(token);}, + stop, + toggleControl(){control=!control;notify();}, + key(key,modifiers=[]){ + if(control&&!modifiers.includes('control'))modifiers=[...modifiers,'control'];control=false;notify(); + if(typeof Native?.terminalKey==='function'&&token)Native.terminalKey(key,JSON.stringify(modifiers));else send({type:'key',key,modifiers}).catch(()=>{}); + }, + paste(){control=false;notify();if(token&&typeof Native?.paste==='function')Native.paste();else window.dispatchEvent(new CustomEvent('canvaslink:notice',{detail:'Сначала коснитесь терминала или текстового поля для вставки.'}));}, + receive(value){ + if(!token||value?.token!==token)return; + if(value.type==='composition'){notify({composition:value.text||''});return;} + if(value.type==='notice'){window.dispatchEvent(new CustomEvent('canvaslink:notice',{detail:value.text}));return;} + if(value.type==='paste'){control=false;notify();send({type:'text',text:value.text}).catch(()=>{});return;} + if(value.type==='text'){ + if(control&&[...value.text].length===1&&/^[a-z]$/i.test(value.text)){control=false;notify();send({type:'key',key:value.text.toLowerCase(),modifiers:['control']}).catch(()=>{});} + else send({type:'text',text:value.text}).catch(()=>{}); + } + if(value.type==='key')send({type:'key',key:value.key,modifiers:value.modifiers||[]}).catch(()=>{}); + }, + active:()=>!!token + }); + api.subscribe(state=>{if(state.connection!=='connected'&&token)stop();}); +})(); diff --git a/integrations/canvaslink/android/assets/gestures.js b/integrations/canvaslink/android/assets/gestures.js new file mode 100644 index 000000000..c31fbef60 --- /dev/null +++ b/integrations/canvaslink/android/assets/gestures.js @@ -0,0 +1,289 @@ +/* Local interaction layer. CanvasLinkClient v2 and the pointer DOM contract stay stable. */ +(() => { + 'use strict'; + const $ = id => document.getElementById(id); + const api = window.CanvasLinkClient; + const viewport = $('viewport'), video = $('video'); + const touches = new Map(); + const clamp = (value, min, max) => Math.max(min, Math.min(max, value)); + const editor = target => target instanceof Element && (target.isContentEditable || + Boolean(target.closest('input,textarea,select,[contenteditable]:not([contenteditable="false"])'))); + const control = target => target instanceof Element && + Boolean(target.closest('button,input,textarea,select,a,summary,[role="button"],[contenteditable]')); + let scale = 1, offset = {x: 0, y: 0}, trackpad = false; + let pointer = {x: .5, y: .5}, drag = false, press = null, pinch = null; + let composing = false, pairFrame = 0, holdTimer = 0; + let tapEpoch=0, focusRect=null, focusCaret=null, focusKind='',focusFont=14,focusViewportWidth=0,focusContext=null,knownTerminal=null,autoFramePending=true,focusedView=false,beforeFocus=null,remoteSurface='canvas',remoteViewportWidth=0,dragRects=[]; + const validRect=r=>r&&['x','y','width','height'].every(k=>Number.isFinite(r[k]))&&r.width>0&&r.height>0; + + const connected = () => api.getState().connection === 'connected'; + const canInput = () => connected() && !composing && !editor(document.activeElement); + // Never queue, retry, or turn a rejected/uncertain delivery into another input. + function deliver(send) { + try { return Promise.resolve(send()).catch(() => undefined); } catch (_) { return Promise.resolve(); } + } + function send(value, cleanup = false) { + if (cleanup ? connected() : canInput()) return deliver(() => api.sendInput(value)); + } + function mouse(action, at, extra = {}) { + // A held button must still be released if focus has just entered an editor. + const cleanup = action === 'up' && drag; + if (!(cleanup ? connected() : canInput())) return; + pointer = {x: at.x, y: at.y}; + window.dispatchEvent(new CustomEvent('canvaslink:pointer', { + detail: {...pointer, action, trackpad} + })); + return send({type: 'pointer', action, ...pointer, button: 0, ...extra}, cleanup); + } + + // The element fills the viewport, but object-fit:contain can leave letterboxes. + // Its transformed bounding box already includes our centre-origin scale/offset. + function contentBox() { + const rect = video.getBoundingClientRect(); + const vw = video.videoWidth || 1280, vh = video.videoHeight || 800; + const fit = Math.min(rect.width / vw, rect.height / vh); + const width = vw * fit, height = vh * fit; + return {rect, width, height, left: rect.left + (rect.width - width) / 2, + top: rect.top + (rect.height - height) / 2}; + } + function position(x, y) { + const box = contentBox(); + return {x: clamp((x - box.left) / Math.max(1, box.width), 0, 1), + y: clamp((y - box.top) / Math.max(1, box.height), 0, 1)}; + } + function transform() { + video.style.transform = `translate(${offset.x}px,${offset.y}px) scale(${scale})`; + const percentage = `${Math.round(scale * 100)}%`; + if($('view-toggle')){$('view-toggle').disabled=!connected()||!focusedView&&scale===1&&(!knownTerminal||remoteSurface==='settings');$('view-toggle').setAttribute('aria-label',focusedView||scale>1?'Показать весь Canvas':'Приблизить активный терминал');$('view-toggle-label').textContent=focusedView||scale>1?'Весь Canvas':'Терминал';} + if ($('zoom-label')) $('zoom-label').textContent = percentage; + $('fit').setAttribute('aria-label', `${percentage} — ${scale === 1 ? 'увеличить окно' : 'вписать окно целиком'}`); + if ($('mode-label')) $('mode-label').textContent = trackpad ? 'Тачпад' : 'Касание'; + $('mode').setAttribute('aria-pressed', String(trackpad)); + if ($('gesture-hint')) $('gesture-hint').textContent = trackpad + ? 'Палец — курсор · касание — клик · удержание — перетаскивание · два пальца — прокрутка · щипок — масштаб' + : 'Касание — клик · движение — перетаскивание · два пальца — масштаб и обзор'; + window.dispatchEvent(new CustomEvent('canvaslink:view', { + detail: {scale, offset: {...offset}, trackpad,...(focusedView?{target:focusKind}:{})} + })); + } + function zoomView(nextScale, anchor, nextAnchor = anchor, base = {scale, offset: {...offset}}) { + autoFramePending=false;focusedView=false; + const box = contentBox(), bounds = viewport.getBoundingClientRect(); + const centre = {x: box.rect.left + box.rect.width / 2 - offset.x, + y: box.rect.top + box.rect.height / 2 - offset.y}; + const width = box.width / scale, height = box.height / scale; + scale = clamp(nextScale, 1, 8); + const ratio = scale / base.scale; + const maxX = Math.max(0, (width * scale - bounds.width) / 2); + const maxY = Math.max(0, (height * scale - bounds.height) / 2); + offset = { + x: clamp(nextAnchor.x - centre.x - (anchor.x - centre.x - base.offset.x) * ratio, -maxX, maxX), + y: clamp(nextAnchor.y - centre.y - (anchor.y - centre.y - base.offset.y) * ratio, -maxY, maxY) + }; + transform(); + } + + function frameFocus(){ + if(!focusedView||!focusRect)return; + const r=viewport.getBoundingClientRect(),vw=video.videoWidth||1280,vh=video.videoHeight||800; + const fit=Math.min(r.width/vw,r.height/vh),width=vw*fit,height=vh*fit; + const field=focusKind==='text-field',typing=!!window.CanvasLinkInput?.active(); + const remoteWidth=focusViewportWidth||vw; + scale=field?clamp(Math.min(r.width*.92/(Math.max(.08,focusRect.width)*width),14/(width/remoteWidth*Math.max(10,focusFont))),1,4):clamp(Math.min(r.width*.96/(Math.max(.08,focusRect.width)*width),typing?8:r.height*.92/(Math.max(.08,focusRect.height)*height)),1,8); + const maxX=Math.max(0,(width*scale-r.width)/2),maxY=Math.max(0,(height*scale-r.height)/2); + const context=field&&validRect(focusContext)&&focusContext.width*width*scale{ + const d=e.detail; + if(d.focus){remoteSurface=d.focus.surface||'canvas';dragRects=d.focus.dragRects||[];} + if(['terminal','text-field'].includes(d.focus?.kind)&&d.focus.editable&&validRect(d.focus.rect)){ + if(!focusedView)beforeFocus={scale,offset:{...offset}}; + focusRect=d.focus.rect;focusCaret=d.focus.caret;focusKind=d.focus.kind;focusFont=d.focus.fontSize||14;focusViewportWidth=d.focus.viewport?.width||0;focusContext=d.focus.contextRect;focusedView=true;frameFocus(); + }else if(d.focus){ + // Non-text controls keep the surrounding Settings context. + if(focusedView&&beforeFocus){scale=beforeFocus.scale;offset=beforeFocus.offset;transform();} + focusedView=false;beforeFocus=null;focusRect=null;focusCaret=null; + }else if(!d.active){if(focusedView&&focusKind==='text-field'&&beforeFocus){scale=beforeFocus.scale;offset=beforeFocus.offset;transform();beforeFocus=null;focusedView=false;}else if(focusedView&&focusKind==='terminal')frameFocus();} + }); + new ResizeObserver(()=>{if(focusedView)frameFocus();else transform();}).observe(viewport); + video.addEventListener('resize',()=>{if(focusedView)frameFocus();}); + $('view-toggle')?.addEventListener('click',()=>{autoFramePending=false;if(focusedView||scale>1){window.CanvasLinkInput?.stop();focusedView=false;scale=1;offset={x:0,y:0};transform();}else if(knownTerminal&&remoteSurface!=='settings'){focusKind='terminal';focusRect=knownTerminal.rect;focusCaret=knownTerminal.caret;focusViewportWidth=knownTerminal.viewport?.width||0;focusedView=true;frameFocus();}}); + + const stopHold = () => { clearTimeout(holdTimer); holdTimer = 0; }; + function updatePair() { + pairFrame = 0; + if (touches.size !== 2 || !pinch || !canInput()) return; + const [a, b] = [...touches.values()]; + const x = (a.x + b.x) / 2, y = (a.y + b.y) / 2; + const distance = Math.hypot(a.x - b.x, a.y - b.y); + if (trackpad && !pinch.kind) { + if (Math.abs(distance - pinch.distance) > 8 && Math.abs(distance / pinch.distance - 1) > .06) pinch.kind = 'zoom'; + else if (Math.hypot(x - pinch.x, y - pinch.y) > 6) pinch.kind = 'scroll'; + else return; + } + if (trackpad && pinch.kind === 'scroll') { + focusedView=false; + const dx = clamp(pinch.lastX - x, -2000, 2000), dy = clamp(pinch.lastY - y, -2000, 2000); + if (dx || dy) send({type: 'scroll', ...pointer, dx, dy}); + } else { + zoomView(pinch.scale * distance / pinch.distance, {x: pinch.x, y: pinch.y}, {x, y}, pinch); + } + pinch.lastX = x; pinch.lastY = y; + } + function flushPair() { + if (pairFrame) { cancelAnimationFrame(pairFrame); updatePair(); } + } + function cancelGesture() { + stopHold(); + if (pairFrame) { cancelAnimationFrame(pairFrame); pairFrame = 0; } + if (drag && connected()) mouse('up', pointer); + drag = false; press = null; pinch = null; + const ids = [...touches.keys()]; + touches.clear(); // Clear before releasing capture: loss can arrive synchronously. + for (const id of ids) { + try { if (viewport.hasPointerCapture(id)) viewport.releasePointerCapture(id); } catch (_) {} + } + } + + $('fit').onclick = () => { + cancelGesture(); autoFramePending=false;focusedView=false;scale = scale === 1 ? 2 : 1; offset = {x: 0, y: 0}; transform(); + }; + $('mode').onclick = () => { cancelGesture(); trackpad = !trackpad; transform(); }; + // Optional hook: also works while the parent UI is being upgraded in place. + document.addEventListener('click', e => { + if (e.target instanceof Element && e.target.closest('#reset-view')) { + cancelGesture(); autoFramePending=false;focusedView=false;scale = 1; offset = {x: 0, y: 0}; transform(); + } + }); + if (!viewport.hasAttribute('tabindex')) viewport.tabIndex = 0; + viewport.addEventListener('pointerdown', e => { + if (!canInput()) { e.preventDefault(); return; } + if (e.button !== 0 || control(e.target) || e.target.closest?.('#waiting')) return; + e.preventDefault();++tapEpoch;viewport.focus({preventScroll: true}); + try { viewport.setPointerCapture(e.pointerId); } catch (_) { cancelGesture(); return; } + touches.set(e.pointerId, {x: e.clientX, y: e.clientY}); + if (touches.size === 1) { + const at=position(e.clientX,e.clientY); + const onSlider=dragRects.some(r=>validRect(r)&&at.x>=r.x&&at.x<=r.x+r.width&&at.y>=r.y&&at.y<=r.y+r.height); + press = {x: e.clientX, y: e.clientY, travel: 0,scroll:!trackpad&&remoteSurface==='settings'&&!onSlider,at}; + if (trackpad && e.pointerType !== 'mouse') { + const held = press; + holdTimer = setTimeout(() => { + holdTimer = 0; + if (press === held && touches.size === 1 && held.travel < 8 && canInput()) { + drag = true; mouse('down', pointer); + } + }, 450); + } + } else if (touches.size === 2) { + stopHold(); + if (drag) mouse('up', pointer); + drag = false; press = null; + const [a, b] = [...touches.values()]; + pinch = {distance: Math.max(1, Math.hypot(a.x - b.x, a.y - b.y)), scale, + x: (a.x + b.x) / 2, y: (a.y + b.y) / 2, lastX: (a.x + b.x) / 2, + lastY: (a.y + b.y) / 2, offset: {...offset}, kind: null}; + } + }); + viewport.addEventListener('pointermove', e => { + const previous = touches.get(e.pointerId); + if (!previous) return; + touches.set(e.pointerId, {x: e.clientX, y: e.clientY}); + if (touches.size === 2 && pinch) { + // Classify the settled frame, not half of a two-pointer update. Scroll uses + // centroid deltas, so moving both fingers never doubles the remote scroll. + if (!pairFrame) pairFrame = requestAnimationFrame(updatePair); + return; + } + if (!press) return; + press.travel = Math.max(press.travel, Math.hypot(e.clientX - press.x, e.clientY - press.y)); + if (press.travel >= 8) stopHold(); + if (trackpad) { + const r = viewport.getBoundingClientRect(); + pointer = {x: clamp(pointer.x + (e.clientX - previous.x) / Math.max(1, r.width), 0, 1), + y: clamp(pointer.y + (e.clientY - previous.y) / Math.max(1, r.height), 0, 1)}; + mouse('move', pointer, drag ? {drag: true} : {}); + } else if(press.scroll&&press.travel>8){ + focusedView=false; + const k=(remoteViewportWidth||video.videoWidth)/Math.max(1,contentBox().width); + send({type:'scroll',...press.at,dx:clamp((previous.x-e.clientX)*k,-2000,2000),dy:clamp((previous.y-e.clientY)*k,-2000,2000)}); + } else if (press.travel > 8 || drag) { + if (!drag) { mouse('down', position(press.x, press.y)); drag = true; } + mouse('move', position(e.clientX, e.clientY), {drag: true}); + } + }); + function release(e) { + if (!touches.has(e.pointerId)) return; + stopHold(); + if (e.type === 'pointerup') flushPair(); + else if (pairFrame) { cancelAnimationFrame(pairFrame); pairFrame = 0; } + if (drag && e.type === 'pointercancel') mouse('up', pointer); + if (touches.size === 1 && press && e.type !== 'pointercancel') { + const at = trackpad ? pointer : position(e.clientX, e.clientY); + if (drag) mouse('up', at); + else if (press.travel < 8 && Math.hypot(e.clientX - press.x, e.clientY - press.y) < 8) { + const epoch=tapEpoch; + mouse('down', at);Promise.resolve(mouse('up', at)).then(receipt=>{ + if(epoch===tapEpoch&&connected()&&receipt?.status==='processed')window.dispatchEvent(new CustomEvent('canvaslink:tap',{detail:{focus:receipt.focus,at}})); + }); + } + } + touches.delete(e.pointerId); + if (touches.size < 2) pinch = null; + // No remaining finger may turn a pair gesture into a tap or resumed drag. + press = null; drag = false; + } + viewport.addEventListener('pointerup', release); + viewport.addEventListener('pointercancel', release); + viewport.addEventListener('lostpointercapture', e => { + // The video's implicit capture is transferred to the viewport. Its own loss + // bubbles here but must NOT cancel the viewport's newly captured gesture. + if (e.target === viewport) release({pointerId: e.pointerId, type: 'pointercancel'}); + }); + window.addEventListener('blur', cancelGesture); + document.addEventListener('visibilitychange', () => { if (document.hidden) cancelGesture(); }); + api.subscribe(state => { + if(state.connection!=='connected'){++tapEpoch;cancelGesture();focusedView=false;focusCaret=null;return;} + if(state.source?.viewport?.width>0)remoteViewportWidth=state.source.viewport.width; + if(state.source?.surface){remoteSurface=state.source.surface;dragRects=state.source.dragRects||[];} + const terminal=state.source?.target==='terminal'&&validRect(state.source.rect)?state.source:state.source?.terminalView; + if(terminal&&validRect(terminal.rect)){knownTerminal=terminal;if(autoFramePending&&remoteSurface!=='settings'){autoFramePending=false;focusKind='terminal';focusRect=knownTerminal.rect;focusCaret=knownTerminal.caret;focusViewportWidth=knownTerminal.viewport?.width||0;focusedView=true;frameFocus();}} + if(remoteSurface==='settings'&&focusedView&&focusKind==='terminal'){focusedView=false;scale=1;offset={x:0,y:0};transform();} + // Health may follow an already selected cursor, but can never open an IME. + // Manual pan/zoom/scroll disables following until another confirmed tap. + if(focusedView&&focusKind==='terminal'&&state.source?.target==='terminal'&&validRect(state.source.rect)&&validRect(state.source.caret)){focusRect=state.source.rect;focusCaret=state.source.caret;frameFocus();} + }); + document.addEventListener('focusin', e => { if (editor(e.target)) cancelGesture(); }); + window.addEventListener('compositionstart', () => { composing = true; cancelGesture(); }); + window.addEventListener('compositionend', () => { composing = false; }); + + viewport.addEventListener('wheel', e => { + if (!canInput() || control(e.target) || e.target.closest?.('#waiting')) return; + e.preventDefault(); + const dx = e.deltaX * (e.deltaMode === 1 ? 16 : e.deltaMode === 2 ? viewport.clientWidth : 1); + const dy = e.deltaY * (e.deltaMode === 1 ? 16 : e.deltaMode === 2 ? viewport.clientHeight : 1); + // Chromium exposes physical trackpad pinch as Ctrl+wheel, not touch pointers. + if (e.ctrlKey) { cancelGesture(); zoomView(scale * Math.exp(-dy * .002), {x: e.clientX, y: e.clientY}); return; } + focusedView=false;send({type: 'scroll', ...(trackpad ? pointer : position(e.clientX, e.clientY)), + dx: clamp(dx, -2000, 2000), dy: clamp(dy, -2000, 2000)}); + }, {passive: false}); + const terminalKeys = new Set(['Enter', 'Tab', 'Escape', 'Backspace', 'Delete', + 'ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Home', 'End', 'PageUp', 'PageDown']); + window.addEventListener('keydown', e => { + if (document.querySelector('dialog[open]') || !canInput() || e.defaultPrevented || e.isComposing || e.keyCode === 229 || editor(e.target)) return; + if (!(e.target instanceof Element) || control(e.target)) return; + if (e.target !== $('workspace') && !viewport.contains(e.target)) return; + if (e.key === 'Enter' && e.repeat) { e.preventDefault(); return; } + const modifiers = [e.ctrlKey ? 'control' : null, e.shiftKey ? 'shift' : null, + e.altKey ? 'alt' : null, e.metaKey ? 'meta' : null].filter(Boolean); + let sendKey; + if (terminalKeys.has(e.key)) sendKey = () => api.sendKey(e.key, modifiers); + else if ((e.ctrlKey || e.metaKey) && 'cavzdl'.includes(e.key.toLowerCase())) sendKey = () => api.sendKey(e.key.toLowerCase(), modifiers); + else if ([...e.key].length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) sendKey = () => api.sendText(e.key); + if (sendKey) { e.preventDefault(); deliver(sendKey); } + }); +})(); diff --git a/integrations/canvaslink/android/assets/index.html b/integrations/canvaslink/android/assets/index.html new file mode 100644 index 000000000..4c54abe9d --- /dev/null +++ b/integrations/canvaslink/android/assets/index.html @@ -0,0 +1,14 @@ +CanvasLink +
+
CanvasLinkЛокальное подключение
+
CANVASTTY · ANDROID

Ваш Canvas.
Под рукой.

Терминалы, проекты и привычная работа.
Теперь — на экране планшета.

+
НАЧНЁМ

Подключить компьютер

Оба устройства должны быть в одной Wi-Fi сети.

Ищем компьютер…

CanvasTTY → Настройки → CanvasLink

Компьютер не находится?

Проверьте Wi-Fi или введите адрес из настроек компьютера.

Напрямую. Без облака и аккаунта.
+
+

Рабочая область CanvasTTY

+

Открываем CanvasTTY

Изображение появится здесь.

+ +
+
Подключаем…

Коснитесь терминала, чтобы печатать. Два пальца — масштаб и обзор.

+

Диагностика

RTT — обмен данными. Это не полная задержка изображения.

+

Завершить сеанс?

Терминалы и проекты останутся открыты на компьютере.

+ diff --git a/integrations/canvaslink/android/assets/onest-OFL.txt b/integrations/canvaslink/android/assets/onest-OFL.txt new file mode 100644 index 000000000..f480c31d2 --- /dev/null +++ b/integrations/canvaslink/android/assets/onest-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2021 The Onest Project Authors (https://github.com/googlefonts/onest) + +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: +https://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/canvaslink/android/assets/onest.ttf b/integrations/canvaslink/android/assets/onest.ttf new file mode 100644 index 0000000000000000000000000000000000000000..477cb31027450d80e486a53119641bf87dde3d4c GIT binary patch literal 193056 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�W?sIp7!h9sJ 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

jZ$9%lzy@T5I8Fa7JxId2GGu@9-e&y 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

_X z4DtB^QMyJgzN?KgLD=h+!YVmjU2S3s&Ya^u?B{MncJ1AlUV7=>^{nCeGX|P?_!fjz z(W-q6*vWhN7-oLjwVBCev4nIg%Ot_OSzYBW51%s$OM~)rHW%^kWs>PcijZGo^Y8fh zOd=|>0w15p(|z*M&O>c&@b2wzYCU@5WPiVoSMc#^D~XS2F2$KS@op=;@ z17?QgwlWP~B;X^<o5m#HGm%s<@@%a6LSR$Il(QtGL z=w#UEIor2SEf)Gl;bFM?!HvlyB!%P`|I>- zmbaHrIg_u&*^x#&*v*iNn)_r5kyP+j<#p<0z7j_W|LKGBCR=!J7YF@CbN(sjM14Bd z9Z54X@xqeF2gR3-I+vDLf}jV(N}$hD;ZVSdbPkM&nbhXS${bGEG{PxI^V`5@*(VIZqI|%vsX3!T1M>i8Z$SGmc%~@kn-wK|cUvX~+fjghy zwYheGAMWcr*=7;JODfYD z*_a>}_1t~$)gM7AeP?oE$fDME6^?c~Ip)e0OE)49#nV@ZHyuOB7TG5FA`#sDSK$Y3{`Kf{C!X`-4|%&k z!YT3U@;G_0E?ypokBifym+5^^lc=;NmjWOg_;W;#KS%65Rgyz4nq-{!WAqgHF>l=P z)KgqxN9FaR;%*B}(|n40wuibx-`W}7+KZtGY5AB;;`_EnwKW!v(1kGNy;P&I$u82@_^0<&jmSyyP_~V zXDMA6+cSD2D2zpRqBlFw`3hsPy~Z+yG4Wnw;=-6@udyPWdp=j3bJrS*`2iFwXT}1m zxqH+K!u%uOV#mT4)g${KL*e5Pb*ShWBJq4;FDrPWdmH>wFaP#k)_aG<^P$C1BM$1s zup+p5zC3(PF@NNcTi1;e+@T(7h{In8^(TADlW`(XbafBQ6Az{Zwsn}|A$hV<7}M@G z7A%Zm?{7UvtbCF`g3ExUlL6_>^o%S#MJ>UvpX1>DBI-rWusrAqU8;#a`@-cw_0!-K z4(iA-b*Bh1iMCz93^g>uugNr>)m2tjT4c~65i{UpZ%!|6CZ%Pl0fU)|Fq=cZvEiI~ z{Y~Tzj(V#UxgnlNBIH;x2)P?=)^Zu1p*8=JZkw_O$#j*3@<9ejK zsa&;*iQC)Tj~+BqHclQ62R07%!Yy4}w(O1%@%Td2fhlaAk3RkM(`PCXt^NAv+hySb zcOu><65aUbc4$Lo+rsdcCu$I-EWob{a~JWuGm;}C%DqNJWF)gQvXfl`hJybXrc;`| zhBSwzI?6hUOqc)U5q&`v5*)fhUAwT0muztan1G;vGPDKV&dJEvVGZ&URA>vnhsIJn zV_(6F3sDB-?g|7ddsA~GXk=4mQfo_cjRH-25SfTuDs383jE5S7%2FRl0j4}T?}d}i z0u?+S&hY6BNZ?D8lzt?Kl!(DJv*{eCGr?;$HZ~u~Db-qqoTXzy&-|Fp�AVOmKH0aE*k2ehGU33?=L}`ge8*^NTmjw-Da~G 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

TpE}7JeE$6F@2$W}3{QMKJp7&S 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 KKmYj|>i+?92fK{` literal 0 HcmV?d00001 diff --git a/integrations/canvaslink/android/assets/style.css b/integrations/canvaslink/android/assets/style.css new file mode 100644 index 000000000..a101c8076 --- /dev/null +++ b/integrations/canvaslink/android/assets/style.css @@ -0,0 +1,31 @@ +@font-face{font-family:Onest;src:url('onest.ttf') format('truetype');font-weight:100 900;font-style:normal;font-display:swap} +:root{color-scheme:dark;--bg:#151819;--panel:#1f2324;--raised:#292e2e;--line:#363d3d;--ink:#f0f2ec;--muted:#adb5b0;--accent:#d4e8b9;--danger:#ffb4a2;--app-height:100dvh;font:16px/1.5 Roboto,'Noto Sans',sans-serif} +*{box-sizing:border-box}html,body{margin:0;min-width:280px;background:var(--bg);color:var(--ink);-webkit-font-smoothing:antialiased}body.is-workspace{height:var(--app-height);overflow:hidden;overscroll-behavior:none}[hidden]{display:none!important}.sr-only{position:absolute!important;width:1px;height:1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap} +button,input{font:inherit;color:inherit;-webkit-tap-highlight-color:transparent}button{cursor:pointer;touch-action:manipulation;display:inline-flex;align-items:center;justify-content:center;gap:10px;border:1px solid var(--line);background:var(--panel);border-radius:10px;min-height:48px;min-width:48px;padding:10px 16px;font-size:14px;font-weight:500}button:hover{background:var(--raised)}button:active{background:#394238}button:disabled{opacity:.45;cursor:default}button:focus-visible,input:focus-visible,summary:focus-visible,#viewport:focus-visible{outline:2px solid var(--accent);outline-offset:2px}.button{min-height:52px}.primary{background:var(--accent);border-color:var(--accent);color:#20271a;font-weight:650}.primary:hover{background:#e1f0cf}.danger{color:var(--danger)}.icon-button{width:48px;padding:0;border:0;background:transparent;font-size:30px}.eyebrow{font:11px/1.5 monospace;letter-spacing:.13em;color:var(--muted)}.subtle,.hint{color:var(--muted);font-size:14px;line-height:1.6} +.pairing-screen{max-width:1280px;margin:auto;padding:0 clamp(20px,5vw,72px) 36px}.pair-header{height:100px;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid #2b3031;gap:20px}.wordmark{display:flex;align-items:center;font-size:23px;font-weight:600;letter-spacing:-1px}.wordmark svg{height:34px;width:34px;margin-right:12px}.wordmark>span{font-weight:400;color:var(--muted)}.local-label{font-size:12px;color:var(--muted);display:flex;align-items:center;gap:8px}.local-label i,.connection-pill i,.session-badge i{width:6px;height:6px;display:inline-block;border-radius:50%;background:var(--accent)}.pair-layout{display:grid;grid-template-columns:1fr 400px;align-items:center;gap:clamp(40px,8vw,120px);padding:clamp(40px,8vh,80px) 0}.pair-intro h1{font-weight:450;font-size:clamp(44px,5.5vw,70px);line-height:1.08;letter-spacing:-3px;margin:22px 0}.pair-intro h1 em{font-style:normal;color:var(--accent)}.pair-intro>p{color:var(--muted);font-size:16px;line-height:1.7}.pair-illustration{margin-top:38px;max-width:350px}.mini-canvas{height:180px;position:relative;border:1px solid var(--line);background:radial-gradient(#4b555039 .8px,transparent .8px) 0 0/14px 14px;border-radius:12px;color:var(--muted);font:10px monospace;padding:10px 12px;overflow:hidden}.mini-dot{display:inline-block;width:5px;height:5px;border-radius:50%;background:var(--accent);margin-right:5px}.mini-window{position:absolute;left:26px;top:44px;right:48px;height:108px;border:1px solid #586451;border-radius:7px;background:#1b201d;box-shadow:0 12px 30px #0003;padding:0 12px;color:var(--ink);font:13px/2 monospace}.mini-title{font:10px/28px sans-serif;border-bottom:1px solid #343d35;display:block;margin:0 -12px 12px;padding:0 12px;color:var(--muted)}.mini-window b{color:var(--accent);font-weight:400}.mini-ghost{position:absolute;left:302px;top:60px;width:100px;height:98px;border:1px solid #424946;border-radius:7px;background:#232927}.illustration-caption{display:block;color:#909b93;font-size:11px;margin:12px 0 0 1px}.pair-card{padding:28px;border:1px solid var(--line);background:var(--panel);border-radius:20px}.pair-card h2{font-size:23px;letter-spacing:-.6px;font-weight:500;line-height:1.25;margin:8px 0}.pair-card>.subtle{font-size:13px;margin-bottom:24px}.field-heading{display:flex;align-items:center;justify-content:space-between;margin:8px 0}.field-label,.field-heading>label{font-size:13px;color:var(--ink);display:block;margin:20px 0 8px}.field-heading>label{margin:0}.text-button{font-size:12px;color:var(--muted);border:0;background:transparent;padding:4px 0;min-width:48px;min-height:36px}.computers{display:grid;gap:8px}.computer{width:100%;text-align:left;justify-content:flex-start;min-height:60px;background:#1b1f20;border-color:#454e49}.computer[aria-pressed=true]{border-color:#849776;background:#2b342a}.computer-icon{font-size:24px;color:var(--accent)}.computer-check{margin-left:auto;color:var(--accent);opacity:0}.computer[aria-pressed=true] .computer-check{opacity:1}#pair-status{color:var(--muted);font-size:13px;min-height:52px;margin:0}input{width:100%;background:#171b1b;border:1px solid #46504a;border-radius:10px;min-height:50px;padding:12px 14px;outline:none}#pin{font:30px/1.5 monospace;letter-spacing:.32em;text-align:center;padding-left:24px}#pin::placeholder{color:#69736c}.hint{font-size:11px;margin:8px 0 20px}#connect{width:100%;justify-content:space-between}#connect>b{font-size:23px;font-weight:400}#pair-error:empty{display:none}#pair-error{color:var(--danger);font-size:13px}details{margin-top:20px;font-size:12px;color:var(--muted)}summary{cursor:pointer;min-height:32px;display:flex;align-items:center;justify-content:center;list-style:none}summary::-webkit-details-marker{display:none}summary:after{content:'⌄';margin-left:8px}details[open] summary:after{content:'⌃'}.pair-foot{text-align:center;font-size:11px;color:#9aa59c;border-top:1px solid #333b35;padding-top:20px;margin-top:18px} +/* The sole reserved chrome is a single 56px keyboard accessory row. */ +.workspace{height:var(--app-height);width:100%;display:flex;flex-direction:column;overflow:hidden;padding:0 env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left);background:#111414}.remote-viewport{flex:1;min-height:0;position:relative;overflow:hidden;touch-action:none;outline:none!important;background:#15191a}#video{position:absolute;inset:0;width:100%;height:100%;object-fit:contain;transform-origin:center;pointer-events:none}.session-badge{position:absolute;top:12px;left:12px;display:flex;align-items:center;gap:7px;border:1px solid #46514688;background:#1a211cba;backdrop-filter:blur(10px);padding:5px 10px;border-radius:20px;color:#d4ddd4;font-size:10px;pointer-events:none;opacity:.8}.session-badge[data-state=reconnecting] i,.session-badge[data-state=suspended] i{background:#e7b571}.keybar{display:flex;align-items:center;height:56px;flex:0 0 56px;padding:4px;gap:4px;border-top:1px solid #303838;background:#202627;z-index:2}.keybar button{border:0;border-radius:6px;background:transparent;height:48px;min-height:48px;min-width:48px;flex:0 0 auto;padding:0 12px;font:14px/1 monospace;color:#e2e8e1}.keybar button:hover,.keybar button:active{background:#354037}.keybar button[aria-pressed=true]{background:var(--accent);color:#1a2618}.keybar .bar-icon{width:48px;flex-shrink:0;font:21px/1 sans-serif;color:var(--muted)}.keys{display:flex;flex:1;min-width:0;overflow-x:auto;overflow-y:hidden;align-items:center;gap:2px;scrollbar-width:none;white-space:nowrap}.keys::-webkit-scrollbar{display:none}.key-divider{height:18px;width:1px;background:#49534c;flex:0 0 1px;margin:0 8px}.keybar .enter-key{color:var(--accent);font-size:23px}.local-cursor{position:absolute;left:0;top:0;font-size:27px;line-height:1;color:#f5f5e8;text-shadow:0 1px 3px #000;pointer-events:none}.touch-feedback{position:absolute;left:0;top:0;width:32px;height:32px;border-radius:50%;border:2px solid #d4e8b9cc;background:#d4e8b926;pointer-events:none}.preedit{position:absolute;left:16px;bottom:12px;max-width:calc(100% - 32px);padding:4px 10px;background:#25332af0;border:1px solid #899f78;border-radius:6px;text-decoration:underline;font:16px/1.4 monospace;white-space:pre-wrap;overflow:hidden}.waiting{position:absolute;inset:0;display:flex;flex-direction:column;justify-content:center;align-items:center;background:#151819f5;padding:24px;text-align:center;z-index:4}.waiting-mark{font-size:48px;color:var(--accent)}.waiting h2{font-size:20px;font-weight:500;margin:10px}.waiting p{font-size:14px;color:var(--muted);max-width:340px}.toast{position:absolute;bottom:12px;left:50%;transform:translateX(-50%);max-width:min(560px,calc(100% - 32px));width:max-content;z-index:6;background:#29382bf2;border:1px solid #718568;border-radius:10px;padding:10px 16px;font-size:13px;margin:0;box-shadow:0 4px 20px #0004}.toast.error{color:var(--danger);background:#332c27}.sheet{color:var(--ink);background:#202627;border:1px solid #47534b;border-radius:20px;padding:24px;width:min(440px,calc(100% - 32px));max-height:calc(var(--app-height) - 24px);overflow:auto;box-shadow:0 20px 80px #0008}.sheet::backdrop{background:#0008;backdrop-filter:blur(4px)}.sheet-heading{display:flex;align-items:center;justify-content:space-between;gap:20px}.sheet h2{font-size:22px;font-weight:500;margin:0}.connection-pill{display:flex;align-items:center;gap:8px;font-size:12px;color:var(--accent);margin:12px 0}.connection-pill[data-state=reconnecting] i{background:#e7b571}.menu-actions{display:grid;margin-top:22px}.menu-actions>button{border:0;border-top:1px solid #3a443e;border-radius:0;background:transparent;justify-content:space-between;min-height:56px;padding-left:0;padding-right:0}.menu-actions>button>span:last-child{color:var(--muted)}.sheet pre{font:13px/2 monospace;color:var(--accent);margin:20px 0}.sheet-actions{display:flex;gap:8px;margin-top:24px}.sheet-actions button{flex:1} +@media(max-width:760px){.pair-header{height:76px}.local-label{font-size:0}.local-label i{width:8px;height:8px}.pair-layout{grid-template-columns:1fr;gap:30px;max-width:430px;margin:auto;padding:32px 0}.pair-intro h1{font-size:43px;letter-spacing:-2px;margin:12px 0}.pair-intro>p{font-size:14px;margin:0}.pair-illustration,.pair-intro>.eyebrow{display:none}.pair-card{padding:24px}.keybar{height:56px;flex-basis:56px}}@media(max-height:500px) and (min-width:761px){.pair-header{height:64px}.pair-layout{padding:24px 0;gap:40px}.pair-illustration{display:none}.pair-intro h1{font-size:48px}.pair-card{padding:20px}.sheet{padding:16px}}@media(prefers-reduced-motion:no-preference){.touch-feedback{animation:touch .28s ease-out}@keyframes touch{from{opacity:1;scale:.7}to{opacity:0;scale:1.3}}} +.text-button{min-height:48px}summary{min-height:48px} +@media(max-width:760px) and (max-height:760px){.pair-header{height:64px}.pair-layout{padding:20px 0;gap:20px}.pair-intro h1{font-size:32px;letter-spacing:-1.5px;margin:8px 0}.pair-intro>p,.pair-card>.eyebrow{display:none}.pair-card{padding:20px}.pair-card h2{margin-top:0}.pair-card>.subtle{margin:8px 0 12px}.field-label{margin-top:14px}.field-heading{margin-top:0}} + +.keybar #paste{color:var(--accent);background:#344031;flex-shrink:0} + +/* CanvasLink workbench: graphite surfaces, quiet keycaps, one clear accent. */ +:root{--bg:#121719;--panel:#1b2225;--raised:#263034;--line:#354045;--ink:#edf1ed;--muted:#a9b7b6;--accent:#d3e2c2;font-family:Onest,'Noto Sans',sans-serif} +.wordmark{font-weight:650;letter-spacing:-.9px}.pair-intro h1{font-weight:500;letter-spacing:-2.5px}.pair-card{box-shadow:0 20px 60px #0002}.pair-foot{letter-spacing:.01em} +.workspace{background:#101617}.remote-viewport{background:#101617}.session-badge{top:14px;left:14px;border-color:#48545166;background:#131c1ee8;border-radius:8px;padding:6px 10px;font-size:11px;letter-spacing:.01em;opacity:1;max-width:calc(100% - 28px)} +.keybar{background:#171e21;border-top:1px solid #394447;gap:5px;padding:4px 6px;box-shadow:0 -5px 20px #0002} +.keybar button{font:500 13px/1 Onest,'Noto Sans',sans-serif;color:#cbd5d5;gap:8px;border:1px solid #354045;border-radius:8px;background:#212a2e;padding:0 13px;letter-spacing:.01em} +.keybar button:hover{background:#2b363a;border-color:#51605e}.keybar button:active{background:#3a4947;box-shadow:inset 0 1px 2px #0003}.keybar button[aria-pressed=true]{background:#cdddba;border-color:#cdddba;color:#1b281c} +.keybar button svg{width:20px;height:20px;flex:none;fill:none;stroke:currentColor;stroke-width:1.65;stroke-linecap:round;stroke-linejoin:round} +.keybar .bar-icon{padding:0;background:transparent;border-color:transparent;color:#b6c5c5}.keys{gap:4px}.key-divider{background:#3e4c50;margin:0 4px;height:22px} +.keybar #paste{background:#d3e2c2;border-color:#d3e2c2;color:#213024;font-weight:650}.keybar #paste:hover{background:#e0edcf}.keybar .enter-key{color:#d3e2c2;background:#2b3730;border-color:#4c5c47}.keybar .view-toggle{padding:0;width:48px;background:transparent;border-color:transparent}.view-toggle span{display:none} +.sheet{border-color:#455157;border-radius:16px;background:#1b2427;padding:24px;box-shadow:0 24px 80px #0008}.sheet h2{font-size:21px;letter-spacing:-.5px;font-weight:600}.sheet::backdrop{background:#080d10aa;backdrop-filter:blur(6px)}.menu-actions>button{font-size:14px;border-top-color:#354348}.menu-actions>button:hover{color:#edf4e8;background:#243135}.connection-pill{font-size:12px;color:#c9dcb5}.subtle,.hint{line-height:1.55} +.toast{font-family:Onest,'Noto Sans',sans-serif;border-radius:8px;background:#1f302a}.preedit{font-family:Onest,'Noto Sans',sans-serif}.waiting h2{font-weight:550;letter-spacing:-.5px} +@media(min-width:1000px){.keybar .view-toggle{width:auto;padding:0 12px}.view-toggle span{display:inline}} +@media(prefers-reduced-motion:no-preference){dialog[open]{animation:sheet-in .14s ease-out}@keyframes sheet-in{from{opacity:0;translate:0 6px}to{opacity:1;translate:0 0}}} + +/* Keep status in the key row, clear of streamed terminal text. */ +.keybar .session-badge{position:static;flex:0 0 auto;max-width:150px;gap:6px;padding:0 8px;border:0;background:none;backdrop-filter:none;white-space:nowrap;overflow:hidden;pointer-events:auto}.session-badge i{flex:0 0 6px}.session-badge span{overflow:hidden;text-overflow:ellipsis} +@media(max-width:999px){.keybar .session-badge{width:8px;padding:0}.session-badge span{position:absolute;width:1px;height:1px;overflow:hidden;clip-path:inset(50%);white-space:nowrap}} diff --git a/integrations/canvaslink/android/assets/transport.js b/integrations/canvaslink/android/assets/transport.js new file mode 100644 index 000000000..2325ed8c0 --- /dev/null +++ b/integrations/canvaslink/android/assets/transport.js @@ -0,0 +1,97 @@ +/* Public designer API v2. This file owns secure transport and delivery state. */ +(()=>{ + let pc,dc,video,paired=false,move,moveFrame,stats={},lastFrames=0,lastFrameAt=0,draftTouched=false; + const listeners=new Set(),computers=new Map(); + let state={version:2,connection:'idle',discovery:'searching',computers:[],computer:'',error:'',pending:0,delivery:null,stats:{},source:null,draft:''}; + const snapshot=()=>JSON.parse(JSON.stringify(state)); + const update=patch=>{Object.assign(state,patch);for(const listener of listeners)listener(snapshot());}; + const signal=message=>Native.signal(JSON.stringify(message)); + const delivery=new CanvasLinkDelivery.Delivery({channel:()=>dc,onChange:value=>{if(['pointer','scroll'].includes(value.kind)){state.pending=value.pending;return;}update({delivery:value,pending:value.pending});}}); + const raw=value=>{if(dc?.readyState==='open'&&dc.bufferedAmount<65536)dc.send(JSON.stringify(value));}; + function close(){delivery.close();dc=null;pc?.close();pc=null;stats={};lastFrames=0;lastFrameAt=0;window.CanvasLink.stats={};update({stats:{},source:null});if(video)video.srcObject=null;} + async function offer(message){ + close();update({connection:'connecting',error:''}); + const current=pc=new RTCPeerConnection({iceServers:[]}); + current.ontrack=event=>{if(current!==pc)return;if(video){video.srcObject=event.streams[0];video.play().catch(()=>{});}}; + current.ondatachannel=event=>{ + if(current!==pc)return;dc=event.channel; + dc.onopen=()=>{if(current===pc)update({connection:'connected',error:''});}; + dc.onclose=()=>{if(current===pc){delivery.close();update({connection:'reconnecting'});}}; + dc.onmessage=event=>{ + if(current!==pc||typeof event.data!=='string'||event.data.length>40000)return; + let value;try{value=JSON.parse(event.data);}catch{return;} + if(value.type==='source-state'){update({source:{capture:value.capture,windowFocused:!!value.windowFocused,target:value.target,surface:value.surface||'canvas',viewport:value.viewport||null,terminalView:value.terminalView||null,dragRects:value.dragRects||[],rect:value.rect||null,caret:value.caret||null}});return;} + if(value.type==='pong')stats.rtt=Math.round(performance.now()-value.at); + if(delivery.receive(value))return; + if(value.type==='fixture-state'){window.CanvasLink.fixture=value;Native.acceptance(JSON.stringify(value));} + }; + }; + current.onconnectionstatechange=()=>{ + if(current!==pc)return; + if(current.connectionState==='connected'&&dc?.readyState==='open')update({connection:'connected',error:''}); + if(['failed','disconnected'].includes(current.connectionState)){ + delivery.close();update({connection:'reconnecting'}); + setTimeout(()=>{if(paired&¤t===pc&¤t.connectionState!=='connected')signal({type:'ping'});},2500); + } + }; + await current.setRemoteDescription(message);await current.setLocalDescription(await current.createAnswer()); + await new Promise((resolve,reject)=>{if(current.iceGatheringState==='complete')return resolve();const timer=setTimeout(()=>reject(Error('ice-timeout')),12000);current.onicegatheringstatechange=()=>{if(current.iceGatheringState==='complete'){clearTimeout(timer);resolve();}};}); + if(current===pc)signal({type:'answer',sdp:current.localDescription.sdp}); + } + function connect({code,computerId,host}={}){ + const address=host||computers.get(computerId)?.host||(computers.size===1?[...computers.values()][0].host:''); + if(!address){update({error:computers.size?'choose-computer':'computer-not-found'});return false;} + if(!/^\d{6}$/.test(code||'')){update({error:'invalid-code'});return false;} + update({connection:'pairing',computer:address,error:''});Native.connect(address,code);return true; + } + function sendInput(input){ + if(input.type==='pointer'&&input.action==='move'){ + // Coalesce motion only; never replay or coalesce text, clicks, or keys. + move=input;if(!moveFrame)moveFrame=requestAnimationFrame(()=>{moveFrame=null;const m=move;move=null;delivery.send(m).catch(()=>{});});return Promise.resolve({status:'coalesced'}); + } + if(move){cancelAnimationFrame(moveFrame);moveFrame=null;const m=move;move=null;delivery.send(m).catch(()=>{});} + return delivery.send(input); + } + const api=window.CanvasLinkClient=Object.freeze({ + version:2,getState:snapshot,subscribe(listener){listeners.add(listener);listener(snapshot());return()=>listeners.delete(listener);}, + attachVideo(element){video=element;},connect,discover(){update({discovery:'searching'});Native.discover();}, + disconnect(){paired=false;close();Native.disconnect();update({connection:'idle'});}, + sendInput,sendText(text){if(typeof text!=='string'||!text.length||text.length>8000)return Promise.reject(Error('invalid-text'));return sendInput({type:'text',text});}, + sendKey(key,modifiers=[]){return sendInput({type:'key',key,modifiers});}, + setDraft(text){draftTouched=true;state.draft=String(text).slice(0,8000);return Native.saveDraft(state.draft);}, + hideKeyboard(){Native.hideKeyboard();},resume(){if(paired)signal({type:'ping'});} + }); + window.CanvasLink={stats:{},fixture:null,connectForTest:(host,code)=>connect({host,code}),requestFixture:requestId=>raw({type:'stats',...(typeof requestId==='string'&&requestId.length<=80?{requestId}:{})}),receive(event){ + const value=event.value; + if(event.type==='discovered'){computers.set(value.id,{...value});update({discovery:'ready',computers:[...computers.values()]});} + if(event.type==='lost'){computers.delete(value);update({computers:[...computers.values()]});} + if(event.type==='discovery')update({discovery:value}); + if(event.type==='draft'&&!draftTouched)update({draft:value||''}); + if(event.type==='paired'){paired=true;update({connection:'connecting',computer:value.host,error:''});} + if(event.type==='signal'){ + if(value.type==='offer')offer(value).catch(error=>update({connection:'error',error:error.message})); + if(value.type==='disconnect'){close();update({connection:'reconnecting'});} + } + if(event.type==='error')update({connection:paired?'reconnecting':'error',error:String(value)}); + if(event.type==='network')update({connection:paired?'reconnecting':'idle',error:String(value)}); + // A reconnect ping sent while Wi-Fi is down cannot reach the host. Retry + // only signalling once encrypted polling succeeds; input is never replayed. + if(event.type==='network-restored'&&paired&&!document.hidden&&state.connection!=='connected')signal({type:'ping'}); + if(event.type==='disconnected'){paired=false;close();update({connection:'idle'});} + }}; + document.addEventListener('visibilitychange',()=>{if(!paired)return;if(document.hidden){signal({type:'disconnect'});close();update({connection:'suspended'});}else{update({connection:'reconnecting'});signal({type:'ping'});}}); + setInterval(async()=>{ + const current=pc;if(!current)return; + try{ + raw({type:'ping',at:performance.now(),videoFrames:stats.framesDecoded||0});const reports=await current.getStats();if(current!==pc)return;let inbound,selected; + reports.forEach(r=>{if(r.type==='inbound-rtp'&&r.kind==='video')inbound=r;if(r.type==='candidate-pair'&&r.state==='succeeded'&&r.nominated)selected=r;}); + if(inbound){ + const now=performance.now(),frames=inbound.framesDecoded||0;if(frames>lastFrames){lastFrameAt=now;lastFrames=frames;} + stats={...stats,framesDecoded:frames,framesPerSecond:inbound.framesPerSecond||0,width:inbound.frameWidth||0,height:inbound.frameHeight||0,inputs:delivery.sent,ackedInputs:delivery.acked,state:current.connectionState,bytes:inbound.bytesReceived||0,stalled:state.source?state.source.capture==='stalled':!!lastFrameAt&&now-lastFrameAt>15000}; + if(selected){const local=reports.get(selected.localCandidateId),remote=reports.get(selected.remoteCandidateId);stats.transport=local?.protocol||'';stats.local=local?.address||'';stats.remote=remote?.address||'';stats.candidateType=remote?.candidateType||'';} + window.CanvasLink.stats=stats;Native.diagnostics(JSON.stringify(stats));update({stats:{...stats}}); + } + }catch{} + },1000); + Native.discover();Native.restore();Native.loadDraft(); +})(); diff --git a/integrations/canvaslink/android/res/drawable/ic_launcher_foreground.xml b/integrations/canvaslink/android/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 000000000..6ab56d856 --- /dev/null +++ b/integrations/canvaslink/android/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,10 @@ + + + + + + diff --git a/integrations/canvaslink/android/res/drawable/ic_launcher_monochrome.xml b/integrations/canvaslink/android/res/drawable/ic_launcher_monochrome.xml new file mode 100644 index 000000000..a483831b3 --- /dev/null +++ b/integrations/canvaslink/android/res/drawable/ic_launcher_monochrome.xml @@ -0,0 +1,10 @@ + + + + + + diff --git a/integrations/canvaslink/android/res/mipmap-anydpi-v26/ic_launcher.xml b/integrations/canvaslink/android/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 000000000..51deb97dc --- /dev/null +++ b/integrations/canvaslink/android/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1 @@ + diff --git a/integrations/canvaslink/android/res/mipmap-anydpi-v33/ic_launcher.xml b/integrations/canvaslink/android/res/mipmap-anydpi-v33/ic_launcher.xml new file mode 100644 index 000000000..3d1008cb4 --- /dev/null +++ b/integrations/canvaslink/android/res/mipmap-anydpi-v33/ic_launcher.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/integrations/canvaslink/android/res/values/colors.xml b/integrations/canvaslink/android/res/values/colors.xml new file mode 100644 index 000000000..e50c4c575 --- /dev/null +++ b/integrations/canvaslink/android/res/values/colors.xml @@ -0,0 +1 @@ +#121814 diff --git a/integrations/canvaslink/android/src/io/nexcore/canvaslink/AcceptanceInstrumentation.java b/integrations/canvaslink/android/src/io/nexcore/canvaslink/AcceptanceInstrumentation.java new file mode 100644 index 000000000..1aa894c58 --- /dev/null +++ b/integrations/canvaslink/android/src/io/nexcore/canvaslink/AcceptanceInstrumentation.java @@ -0,0 +1,185 @@ +package io.nexcore.canvaslink; + +import android.app.Instrumentation; +import android.app.Activity; +import android.content.Intent; +import android.os.Bundle; +import android.os.SystemClock; +import android.view.MotionEvent; +import org.json.*; +import java.util.concurrent.*; + +/** Local adb-only acceptance. Never logs pairing material or captured content. */ +public final class AcceptanceInstrumentation extends Instrumentation { + private Bundle arguments; private MainActivity activity; + void stage(String name){Bundle b=new Bundle();b.putString("stage",name);sendStatus(0,b);} + @Override public void onCreate(Bundle args){arguments=args;start();} + String js(String code) throws Exception { + CountDownLatch latch=new CountDownLatch(1);String[] out={"null"}; + runOnMainSync(()->activity.web.evaluateJavascript(code,value->{out[0]=value;latch.countDown();})); + if(!latch.await(5,TimeUnit.SECONDS))throw new Exception("WebView evaluation timeout");return out[0]; + } + void tap(String selector) throws Exception { + tap(selector,null); + } + void tap(String selector, String point) throws Exception { + boolean[] focused={false};runOnMainSync(()->focused[0]=activity.web.hasWindowFocus()); + if(!focused[0])throw new Exception("Touch Activity is not foreground"); + String coordinate="{x:r.x+r.width/2,y:r.y+r.height/2}"; + if(point!=null){String[] xy=point.split(",");double x=Double.parseDouble(xy[0]),y=Double.parseDouble(xy[1]);if(x<0||x>1||y<0||y>1)throw new Exception("Invalid normalized point");coordinate="(()=>{const f=Math.min(r.width/e.videoWidth,r.height/e.videoHeight),w=e.videoWidth*f,h=e.videoHeight*f;return {x:r.x+(r.width-w)/2+w*"+x+",y:r.y+(r.height-h)/2+h*"+y+"}})()";} + String rect=js("(()=>{const e=document.querySelector("+JSONObject.quote(selector)+");e.scrollIntoView({block:'center',inline:'center'});const r=e.getBoundingClientRect(),p="+coordinate+";return {...p,dpr:devicePixelRatio,w:innerWidth,h:innerHeight}})()"); + JSONObject p=new JSONObject(rect);int[] offset=new int[2];runOnMainSync(()->activity.web.getLocationOnScreen(offset)); + if(p.getDouble("x")<0||p.getDouble("x")>=p.getDouble("w")||p.getDouble("y")<0||p.getDouble("y")>=p.getDouble("h"))throw new Exception("Touch target outside viewport: "+selector); + float x=(float)(p.getDouble("x")*p.getDouble("dpr"))+offset[0]; + float y=(float)(p.getDouble("y")*p.getDouble("dpr"))+offset[1]; + // CSS px and WebView device px differ by density, including viewport scaling. + long at=SystemClock.uptimeMillis();MotionEvent down=MotionEvent.obtain(at,at,MotionEvent.ACTION_DOWN,x,y,0); + MotionEvent up=MotionEvent.obtain(at,at+60,MotionEvent.ACTION_UP,x,y,0); + down.setSource(android.view.InputDevice.SOURCE_TOUCHSCREEN);up.setSource(android.view.InputDevice.SOURCE_TOUCHSCREEN); + if(!getUiAutomation().injectInputEvent(down,true)||!getUiAutomation().injectInputEvent(up,true))throw new Exception("Android rejected touch: "+selector); + down.recycle();up.recycle(); + SystemClock.sleep(120); + } + void commitText(String text) throws Exception { + boolean[] committed={false};runOnMainSync(()->{ + android.view.inputmethod.InputConnection input=activity.web.onCreateInputConnection(new android.view.inputmethod.EditorInfo()); + if(input!=null){input.performContextMenuAction(android.R.id.selectAll);committed[0]=input.commitText(text,1);} + }); + SystemClock.sleep(200); + if(!committed[0])throw new Exception("Android IME commit was rejected"); + stage("android-ime-commit"); + } + void menu(String id)throws Exception{tap("#menu-toggle");tap("#"+id);SystemClock.sleep(400);} + int decodedFrames()throws Exception{return new JSONObject(js("window.CanvasLink.stats")).optInt("framesDecoded");} + void waitVideoAfterTouch(int before)throws Exception{ + for(int i=0;i<120;i++){if(decodedFrames()>before)return;SystemClock.sleep(100);} + throw new Exception("Decoded video did not advance after real remote touch"); + } + String freshNativePoint()throws Exception{ + java.io.File directory=activity.getExternalFilesDir(null);if(directory==null)throw new Exception("Owned external acceptance directory unavailable"); + java.io.File target=new java.io.File(directory,"acceptance-target.json");stage("native-target-request"); + for(int i=0;i<80&&!target.exists();i++)SystemClock.sleep(100); + if(!target.exists())throw new Exception("Fresh owned terminal target unavailable"); + JSONObject p=new JSONObject(new String(java.nio.file.Files.readAllBytes(target.toPath()),java.nio.charset.StandardCharsets.UTF_8));target.delete(); + return p.getDouble("x")+","+p.getDouble("y"); + } + void waitTerminalKeyboard()throws Exception{ + for(int i=0;i<60;i++){ + boolean[] visible={false};runOnMainSync(()->{TerminalWebView view=(TerminalWebView)activity.web;visible[0]=view.terminalActive()&&(android.os.Build.VERSION.SDK_INT<30||view.getRootWindowInsets()!=null&&view.getRootWindowInsets().isVisible(android.view.WindowInsets.Type.ime()));}); + if(visible[0]&&"true".equals(js("CanvasLinkInput.active()"))){SystemClock.sleep(400);return;} + SystemClock.sleep(100); + } + boolean[] nativeState={false,false};runOnMainSync(()->{nativeState[0]=((TerminalWebView)activity.web).terminalActive();nativeState[1]=activity.web.getRootWindowInsets()!=null&&activity.web.getRootWindowInsets().isVisible(android.view.WindowInsets.Type.ime());}); + throw new Exception("Terminal keyboard inactive: native="+nativeState[0]+",visible="+nativeState[1]+",client="+js("JSON.stringify({active:CanvasLinkInput.active(),delivery:CanvasLinkClient.getState().delivery,notice:document.getElementById('stream-error').textContent,source:CanvasLinkClient.getState().source})")); + } + @Override public void onStart(){ + Bundle result=new Bundle(); + try{ + stage("launch"); + Intent intent=new Intent(getTargetContext(),MainActivity.class);intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + getTargetContext().startActivity(intent); + for(int i=0;i<100&&MainActivity.active==null;i++)SystemClock.sleep(100); + activity=MainActivity.active;if(activity==null)throw new Exception("Activity launch timeout");stage("activity-ready"); + for(int i=0;i<80&&!"true".equals(js("!!window.CanvasLink"));i++)SystemClock.sleep(100); + String host=arguments.getString("host"),pin=arguments.getString("pin"); + boolean restoring="restore".equals(arguments.getString("mode")); + boolean discoveryFlow="true".equals(arguments.getString("discovery")); + if(!restoring){ + if(pin==null)throw new Exception("pin argument required"); + if(discoveryFlow){ + String selected=null,port=arguments.getString("port"); + for(int i=0;i<150&&selected==null;i++){ + JSONArray computers=new JSONObject(js("window.CanvasLinkClient.getState()")).getJSONArray("computers"); + for(int j=0;jc.id==="+JSONObject.quote(chosen)+")")); + tap("#computers button:nth-child("+(index+1)+")");SystemClock.sleep(400); + tap("#pin");SystemClock.sleep(200);commitText(pin); + if(!"true".equals(js("document.getElementById('pin').value.length===6")))throw new Exception("PIN IME input did not reach form; focused="+js("document.activeElement.id")+", length="+js("document.getElementById('pin').value.length")); + runOnMainSync(()->((android.view.inputmethod.InputMethodManager)activity.getSystemService(android.content.Context.INPUT_METHOD_SERVICE)).hideSoftInputFromWindow(activity.web.getWindowToken(),0));SystemClock.sleep(400); + tap("#connect");stage("discovery-and-code-form");result.putBoolean("discoveryForm",true); + }else{ + if(host==null)throw new Exception("host required without discovery"); + js("window.CanvasLink.connectForTest("+JSONObject.quote(host)+","+JSONObject.quote(pin)+")"); + } + } + stage("connecting"); + // A static owned window legitimately sends one initial image. Confirm + // subsequent live updates after an actual remote action, below. + int minimumFrames=("native".equals(arguments.getString("mode"))||"settings".equals(arguments.getString("mode")))?1:9; + JSONObject stats=null; + for(int i=0;i<200;i++){ + SystemClock.sleep(200); + try{stats=new JSONObject(js("window.CanvasLink.stats"));}catch(Exception ignored){} + if(stats!=null&&stats.optInt("framesDecoded")>=minimumFrames&&"connected".equals(stats.optString("state")))break; + } + if(stats==null||stats.optInt("framesDecoded")=4){complete=true;break;} + } + if(!complete)throw new Exception("Host did not finish all native input events"); + stage("native-text-and-enter-sent"); + if("true".equals(arguments.getString("snapshot")))SystemClock.sleep(4000); + if("true".equals(arguments.getString("controls"))){ + String encoded=arguments.getString("proofDir64"); + if(encoded==null)throw new Exception("Owned control proof directory required"); + String directory=new String(android.util.Base64.decode(encoded,android.util.Base64.DEFAULT),java.nio.charset.StandardCharsets.UTF_8); + new NativeControlChecks(this).run(directory,result); + } + } + if("settings".equals(arguments.getString("mode")))new SettingsTouchChecks(this).run(result); + if(isFixture){ + menu("fit"); + if(!"true".equals(js("getComputedStyle(document.getElementById('video')).transform.startsWith('matrix(2')")))throw new Exception("Zoom control failed"); + menu("fit");menu("mode"); + if(!"true".equals(js("document.getElementById('mode').getAttribute('aria-pressed')==='true'")))throw new Exception("Trackpad mode failed"); + menu("mode");result.putBoolean("zoomAndModeControls",true); + tap("#viewport");stage("touch-sent");waitTerminalKeyboard(); + commitText("Проверка CanvasLink 42"); + SystemClock.sleep(600);tap("[data-key=Enter]"); + stage("text-and-key-sent"); + JSONObject fixture=null; + for(int i=0;i<40;i++){ + js("window.CanvasLink.requestFixture()");SystemClock.sleep(150); + try{fixture=new JSONObject(js("window.CanvasLink.fixture"));}catch(Exception ignored){} + if(fixture!=null&&fixture.optInt("clicks")>0&&fixture.optString("text").contains("Проверка CanvasLink 42")&&fixture.optJSONArray("keys").toString().contains("Enter"))break; + } + if(fixture==null||fixture.optInt("clicks")<1||!fixture.optString("text").contains("Проверка CanvasLink 42")||!fixture.optJSONArray("keys").toString().contains("Enter"))throw new Exception("Fixture input roundtrip failed: clicks="+(fixture==null?-1:fixture.optInt("clicks"))+", text="+(fixture!=null&&fixture.optString("text").contains("Проверка CanvasLink 42"))+", Enter="+(fixture!=null&&fixture.optJSONArray("keys").toString().contains("Enter"))); + result.putBoolean("touchRoundtrip",true);result.putBoolean("unicodeTextRoundtrip",true);result.putBoolean("enterRoundtrip",true); + if("true".equals(arguments.getString("ux")))new DeviceUxChecks(this,activity).run(result); + } + if("true".equals(arguments.getString("draftCheck")))throw new Exception("Removed composer draft flow; use direct-input UX acceptance"); + result.putString("stats",js("window.CanvasLink.stats"));result.putBoolean("fixture",isFixture);result.putBoolean("restored",restoring);result.putString("status","PASS"); + finish(Activity.RESULT_OK,result); + }catch(Exception e){result.putString("status","FAIL");result.putString("error",e.getMessage());finish(Activity.RESULT_CANCELED,result);} + } +} diff --git a/integrations/canvaslink/android/src/io/nexcore/canvaslink/DeviceUxChecks.java b/integrations/canvaslink/android/src/io/nexcore/canvaslink/DeviceUxChecks.java new file mode 100644 index 000000000..55bb43fe9 --- /dev/null +++ b/integrations/canvaslink/android/src/io/nexcore/canvaslink/DeviceUxChecks.java @@ -0,0 +1,89 @@ +package io.nexcore.canvaslink; + +import android.content.pm.ActivityInfo; +import android.os.Bundle; +import android.os.ParcelFileDescriptor; +import android.os.SystemClock; +import android.view.InputDevice; +import android.view.KeyEvent; +import android.view.MotionEvent; +import android.view.inputmethod.InputConnection; +import org.json.JSONObject; +import java.io.FileInputStream; + +/** Additional adb-only checks on the owned fixture, never a user's terminal. */ +final class DeviceUxChecks { + final AcceptanceInstrumentation test; final MainActivity activity; + DeviceUxChecks(AcceptanceInstrumentation test,MainActivity activity){this.test=test;this.activity=activity;} + boolean value(String expression)throws Exception{return "true".equals(test.js(expression));} + void require(boolean ok,String message)throws Exception{if(!ok)throw new Exception(message);} + void until(String expression,int timeout,String failure)throws Exception{ + long end=SystemClock.uptimeMillis()+timeout; + while(SystemClock.uptimeMillis(){const r=document.getElementById('viewport').getBoundingClientRect();return {x:r.x+r.width*"+x+",y:r.y+r.height*"+y+",d:devicePixelRatio}})()")); + int[] origin=new int[2];test.runOnMainSync(()->activity.web.getLocationOnScreen(origin)); + return new float[]{(float)(r.getDouble("x")*r.getDouble("d"))+origin[0],(float)(r.getDouble("y")*r.getDouble("d"))+origin[1]}; + } + void motion(long down,int action,float[][] coordinates)throws Exception{ + MotionEvent.PointerProperties[] properties=new MotionEvent.PointerProperties[coordinates.length]; + MotionEvent.PointerCoords[] points=new MotionEvent.PointerCoords[coordinates.length]; + for(int i=0;i{const v=document.getElementById('viewport').getBoundingClientRect(),bar=document.getElementById('controls').getBoundingClientRect(),rows=[...document.querySelectorAll('#controls button')].map(e=>e.getBoundingClientRect().top);return Math.abs(v.width-innerWidth)<2&&Math.abs(v.height+56-innerHeight)<2&&bar.height===56&&new Set(rows).size===1&&!document.getElementById('compose');})()"),"Direct workspace geometry failed with real IME"); + } + void rotate(int orientation)throws Exception{ + test.runOnMainSync(()->activity.setRequestedOrientation(orientation)); + until(orientation==ActivityInfo.SCREEN_ORIENTATION_PORTRAIT?"screen.height>screen.width":"screen.width>screen.height",6000,"Android orientation did not change");SystemClock.sleep(500); + } + void run(Bundle result)throws Exception{ + test.stage("ux-gestures");test.menu("reset-view"); + test.js("window.__gestureAudit=[];for(const type of ['pointerdown','pointerup','pointercancel','lostpointercapture','canvaslink:pointer'])document.addEventListener(type,e=>{const a=window.__gestureAudit;a.push({type,id:e.pointerId,target:e.target?.id,action:e.detail?.action});if(a.length>40)a.shift();},true)"); + int before=clicks();drag(false,false);int observed=clicks();require(observed==before+1,"Direct drag press mismatch before="+before+", after="+observed+", state="+test.js("JSON.stringify({pending:CanvasLinkClient.getState().pending,ime:CanvasLinkInput.active(),focus:document.activeElement.id,mode:document.getElementById('mode').getAttribute('aria-pressed'),events:window.__gestureAudit})")); + before=clicks();drag(false,true);test.tap("#video","0.5,0.5");require(clicks()==before+2,"Cancellation left held button or duplicate tap");result.putBoolean("nativeDragAndCancel",true); + test.menu("mode");before=clicks();int acks=acked();drag(false,false);require(clicks()==before&&value("!document.getElementById('local-cursor').hidden"),"Trackpad movement clicked or had no cursor");until("CanvasLink.stats.ackedInputs>"+acks,4000,"Trackpad move missing"); + before=clicks();drag(true,false);require(clicks()==before+1,"Hold-drag press count wrong");test.menu("reset-view");before=clicks();acks=acked();pair(false);require(clicks()==before,"Scroll created click");until("CanvasLink.stats.ackedInputs>"+acks,4000,"Native scroll missing");result.putBoolean("nativeTrackpadAndScroll",true); + test.menu("mode");before=clicks();pair(true);require(value("new DOMMatrix(getComputedStyle(document.getElementById('video')).transform).a>1.25"),"Pinch did not zoom");require(clicks()==before,"Pinch ghost click");test.menu("reset-view");result.putBoolean("nativePinchAndFit",true); + test.stage("ux-direct-ime");test.tap("#video","0.5,0.5");test.waitTerminalKeyboard(); + int oldLength=fixture().getString("text").length();boolean[] accepted={false}; + test.runOnMainSync(()->{InputConnection retired=activity.web.onCreateInputConnection(new android.view.inputmethod.EditorInfo());InputConnection input=activity.web.onCreateInputConnection(new android.view.inputmethod.EditorInfo());accepted[0]=input!=null&&!retired.commitText("STALE",1)&&input.setComposingText("日本🙂X",1)&&input.deleteSurroundingText(1,0)&&input.deleteSurroundingTextInCodePoints(1,0)&&((android.view.inputmethod.BaseInputConnection)input).getEditable().toString().equals("日本")&&input.commitText("日本語🙂",1)&&input.finishComposingText();if(input!=null){input.deleteSurroundingText(1,0);input.sendKeyEvent(new KeyEvent(KeyEvent.ACTION_DOWN,KeyEvent.KEYCODE_ENTER));input.sendKeyEvent(new KeyEvent(KeyEvent.ACTION_UP,KeyEvent.KEYCODE_ENTER));}}); + require(accepted[0],"Native composition/commit failed");SystemClock.sleep(500);JSONObject remote=fixture();require(remote.getString("text").substring(oldLength).equals("日本語"),"Composition was duplicated or lost after emoji Backspace");require(remote.getJSONArray("keys").toString().contains("Backspace"),"Native deletion missing");result.putBoolean("directCompositionUnicodeDeleteEnter",true); + int unchanged=acked();test.runOnMainSync(()->((TerminalWebView)activity.web).paste(new String(new char[8001]).replace('\0','x')));until("document.getElementById('input-status').textContent.includes('8000')",4000,"Oversized paste feedback missing");require(value("CanvasLinkClient.getState().connection==='connected'&&CanvasLinkInput.active()"),"Oversized paste interrupted terminal connection");require(acked()==unchanged,"Oversized paste sent remote input");result.putBoolean("staleConnectionCodePointDeleteOversizedPaste",true); + int previous=activity.getRequestedOrientation();try{for(int orientation:new int[]{ActivityInfo.SCREEN_ORIENTATION_PORTRAIT,ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE}){rotate(orientation);test.waitTerminalKeyboard();workspaceFits();}}finally{test.runOnMainSync(()->activity.setRequestedOrientation(previous));SystemClock.sleep(700);}result.putBoolean("singleRowRealImeBothOrientations",true); + shell("input keyevent KEYCODE_BACK");until("!CanvasLinkInput.active()",4000,"Android Back did not retire terminal input");test.tap("#video","0.5,0.5");test.waitTerminalKeyboard();result.putBoolean("keyboardBackDismissAndRetap",true); + test.stage("ux-background");int hostLength=fixture().getString("text").length();shell("input keyevent KEYCODE_HOME");until("document.hidden&&['suspended','reconnecting'].includes(CanvasLinkClient.getState().connection)",8000,"Background not suspended");shell("am start -n io.nexcore.canvaslink/.MainActivity");until("!document.hidden&&CanvasLinkClient.getState().connection==='connected'&&CanvasLink.stats.framesDecoded>8",30000,"Foreground video missing");require(fixture().getString("text").length()==hostLength,"Background replayed text");require(value("!CanvasLinkInput.active()"),"Old native input owner survived background");result.putBoolean("backgroundNoReplay",true); + test.stage("ux-wifi-loss");try{shell("svc wifi disable");until("CanvasLinkClient.getState().connection!=='connected'",20000,"Wi-Fi loss not detected");require(value("!CanvasLinkInput.active()"),"Offline IME not stopped");}finally{shell("svc wifi enable");}until("CanvasLinkClient.getState().connection==='connected'&&CanvasLink.stats.framesDecoded>8",40000,"Wi-Fi video recovery missing");require(fixture().getString("text").length()==hostLength,"Wi-Fi recovery replayed text");result.putBoolean("wifiLossRecoveryNoReplay",true);test.stage("ux-complete"); + } +} diff --git a/integrations/canvaslink/android/src/io/nexcore/canvaslink/GraphicsChecks.java b/integrations/canvaslink/android/src/io/nexcore/canvaslink/GraphicsChecks.java new file mode 100644 index 000000000..a071f4b5b --- /dev/null +++ b/integrations/canvaslink/android/src/io/nexcore/canvaslink/GraphicsChecks.java @@ -0,0 +1,37 @@ +package io.nexcore.canvaslink; + +import android.os.Bundle; +import android.os.SystemClock; +import org.json.JSONArray; + +/** ADB-only visual acceptance; all view controls use real Android touches. */ +final class GraphicsChecks { + final AcceptanceInstrumentation test; + GraphicsChecks(AcceptanceInstrumentation test){this.test=test;} + void until(String expression,String error)throws Exception{ + long end=SystemClock.uptimeMillis()+12000; + while(SystemClock.uptimeMillis()f.family==='Onest'&&f.status==='loaded')","Bundled Onest font was not consumed by WebView"); + until("document.getElementById('view-toggle-label').textContent==='Весь Canvas'&&!!CanvasLinkClient.getState().source?.terminalView","Initial terminal view did not follow source geometry"); + if(!"false".equals(test.js("CanvasLinkInput.active()")))throw new Exception("Visual framing must not start terminal input"); + until("(()=>{const s=CanvasLinkClient.getState().source.terminalView,e=document.getElementById('video'),r=e.getBoundingClientRect(),v=document.getElementById('viewport').getBoundingClientRect(),k=Math.min(r.width/e.videoWidth,r.height/e.videoHeight),left=r.left+(r.width-e.videoWidth*k)/2+s.rect.x*e.videoWidth*k,top=r.top+(r.height-e.videoHeight*k)/2+s.rect.y*e.videoHeight*k;return left>=v.left-2&&top>=v.top-2&&left+s.rect.width*e.videoWidth*k<=v.right+2&&top+s.rect.height*e.videoHeight*k<=v.bottom+2;})()","Initial framing cuts off the selected terminal"); + result.putBoolean("automaticTerminalViewWithoutIme",true);result.putBoolean("bundledFontLoaded",true); + test.stage("graphics-terminal-view-ready");SystemClock.sleep(700); + int sent=Integer.parseInt(test.js("CanvasLink.stats.inputs||0")); + test.tap("#view-toggle"); + until("document.getElementById('zoom-label').textContent==='100%'","Overview button did not restore Canvas"); + SystemClock.sleep(1200); + if(!"100%".equals(new JSONArray("["+test.js("document.getElementById('zoom-label').textContent")+"]").getString(0)))throw new Exception("Source health overrode explicit overview"); + test.stage("graphics-overview-ready");SystemClock.sleep(700);test.tap("#view-toggle"); + until("document.getElementById('view-toggle-label').textContent==='Весь Canvas'","Terminal button did not restore framing"); + if(!"false".equals(test.js("CanvasLinkInput.active()"))||Integer.parseInt(test.js("CanvasLink.stats.inputs||0"))!=sent)throw new Exception("Local view button sent remote input"); + result.putBoolean("viewToggleTouchRoundtripWithoutRemoteInput",true); + String encoded=new JSONArray("["+test.js("(()=>{const v=document.getElementById('video'),c=document.createElement('canvas');c.width=v.videoWidth;c.height=v.videoHeight;c.getContext('2d').drawImage(v,0,0);return c.toDataURL('image/png').split(',')[1];})()")+"]").getString(0); + byte[] frame=android.util.Base64.decode(encoded,android.util.Base64.DEFAULT); + java.nio.file.Files.write(new java.io.File(MainActivity.active.getExternalFilesDir(null),"graphics-decoded-frame.png").toPath(),frame); + result.putBoolean("decodedVideoFrameSavedPrivately",true);test.stage("graphics-decoded-frame-ready");SystemClock.sleep(500); + } +} diff --git a/integrations/canvaslink/android/src/io/nexcore/canvaslink/MainActivity.java b/integrations/canvaslink/android/src/io/nexcore/canvaslink/MainActivity.java new file mode 100644 index 000000000..d27996404 --- /dev/null +++ b/integrations/canvaslink/android/src/io/nexcore/canvaslink/MainActivity.java @@ -0,0 +1,220 @@ +package io.nexcore.canvaslink; + +import android.app.Activity; +import android.os.Bundle; +import android.os.Build; +import android.net.nsd.NsdManager; +import android.net.nsd.NsdServiceInfo; +import android.net.wifi.WifiManager; +import android.webkit.*; +import android.view.WindowManager; +import android.util.Log; +import org.json.*; +import java.io.*; +import java.net.*; +import java.nio.charset.StandardCharsets; +import java.security.SecureRandom; +import java.util.*; +import java.util.concurrent.*; +import javax.crypto.*; +import javax.crypto.spec.*; + +public final class MainActivity extends Activity { + static volatile MainActivity active; + WebView web; + private final ScheduledExecutorService network = Executors.newSingleThreadScheduledExecutor(); + private final SecureRandom random = new SecureRandom(); + private NsdManager nsd; private NsdManager.DiscoveryListener discovery; + private WifiManager.MulticastLock multicast; + private final Map found = new HashMap<>(); + private final ArrayDeque resolving = new ArrayDeque<>(); + private final android.os.Handler ui = new android.os.Handler(android.os.Looper.getMainLooper()); + private boolean resolveBusy=false; private int discoveryEpoch=0; + private final ExecutorService storage=Executors.newSingleThreadExecutor(); + private SessionVault drafts; + private String base, id; private byte[] key; private long sent=0, received=0; + private long reserved=0; private SessionVault vault; + private ScheduledFuture polling; private int errors=0; private long networkFailureAt=0; + volatile String acceptance = "", diagnostics = "{}"; + @Override public void onCreate(Bundle saved) { + super.onCreate(saved); + getWindow().setStatusBarColor(0xff121814); getWindow().setNavigationBarColor(0xff121814); + web = new TerminalWebView(this); setContentView(web); + web.setBackgroundColor(0xff121814); + WebSettings s=web.getSettings(); s.setJavaScriptEnabled(true); s.setDomStorageEnabled(false); + s.setAllowFileAccess(false); s.setAllowContentAccess(false); s.setMixedContentMode(WebSettings.MIXED_CONTENT_NEVER_ALLOW); + s.setMediaPlaybackRequiresUserGesture(false); s.setSupportZoom(false); + web.setWebChromeClient(new WebChromeClient() { + @Override public boolean onConsoleMessage(ConsoleMessage m) { + if (m.messageLevel()==ConsoleMessage.MessageLevel.ERROR) Log.e("CanvasLink", "Web client error at line " + m.lineNumber()); + return true; + } + @Override public void onPermissionRequest(PermissionRequest r) { r.deny(); } + }); + web.setWebViewClient(new WebViewClient() { + @Override public WebResourceResponse shouldInterceptRequest(WebView view, WebResourceRequest request) { + android.net.Uri u=request.getUrl(); + if (!"https".equals(u.getScheme()) || !"appassets.androidplatform.net".equals(u.getHost()) || !u.getPath().startsWith("/client/")) return blocked(); + String file=u.getPath().substring(8); + if (!Arrays.asList("index.html","client.js","style.css","delivery.js","transport.js","gestures.js","direct-input.js","onest.ttf").contains(file)) return blocked(); + try { return new WebResourceResponse(file.endsWith(".js")?"application/javascript":file.endsWith(".css")?"text/css":file.endsWith(".ttf")?"font/ttf":"text/html", "UTF-8", getAssets().open(file)); } + catch(Exception e) { return blocked(); } + } + @Override public boolean shouldOverrideUrlLoading(WebView view, WebResourceRequest request) { return true; } + }); + web.addJavascriptInterface(new Bridge(), "Native"); + web.loadUrl("https://appassets.androidplatform.net/client/index.html"); + nsd=(NsdManager)getSystemService(NSD_SERVICE); + vault=new SessionVault(this);drafts=new SessionVault(this,"draft"); + active=this; + } + private static WebResourceResponse blocked() { return new WebResourceResponse("text/plain","UTF-8",403,"Blocked",Collections.emptyMap(),new ByteArrayInputStream(new byte[0])); } + private void emit(String type, Object value) { + JSONObject event=new JSONObject(); try { event.put("type",type); event.put("value",value); }catch(JSONException ignored){} + runOnUiThread(() -> web.evaluateJavascript("window.CanvasLink&&window.CanvasLink.receive("+event.toString()+")",null)); + } + private String localBase(String host) throws Exception { + if (!host.matches("[0-9.]+:[0-9]{1,5}")) throw new Exception("Нужен локальный IPv4-адрес и порт"); + String[] hp=host.split(":"); String[] parts=hp[0].split("\\."); + if(parts.length!=4) throw new Exception("Неверный адрес"); + int[] a=new int[4]; for(int i=0;i<4;i++){a[i]=Integer.parseInt(parts[i]); if(a[i]<0||a[i]>255)throw new Exception("Неверный адрес");} + boolean local=a[0]==10||a[0]==127||(a[0]==192&&a[1]==168)||(a[0]==172&&a[1]>=16&&a[1]<=31)||(a[0]==169&&a[1]==254); + int p=Integer.parseInt(hp[1]); if(!local||p<1024||p>65535) throw new Exception("Разрешена только локальная сеть"); + return "http://"+hp[0]+":"+p; + } + private JSONObject post(String route, JSONObject payload) throws Exception { + HttpURLConnection c=(HttpURLConnection)new URL(base+route).openConnection(); + c.setConnectTimeout(4000);c.setReadTimeout(5000);c.setInstanceFollowRedirects(false); + c.setRequestMethod("POST");c.setRequestProperty("Content-Type","application/json");c.setDoOutput(true); + byte[] bytes=payload.toString().getBytes(StandardCharsets.UTF_8);c.setFixedLengthStreamingMode(bytes.length); + try { + try(OutputStream out=c.getOutputStream()){out.write(bytes);} + if(c.getResponseCode()!=200) throw new Exception("Компьютер отклонил подключение. Проверьте код и срок его действия."); + try(InputStream in=c.getInputStream();ByteArrayOutputStream out=new ByteArrayOutputStream()){ + byte[] buffer=new byte[4096];int n;while((n=in.read(buffer))!=-1){out.write(buffer,0,n);if(out.size()>150000)throw new Exception("Ответ слишком большой");} + return new JSONObject(out.toString("UTF-8")); + } + }finally{c.disconnect();} + } + private JSONObject exchange(JSONObject value) throws Exception { + if(id==null||key==null)throw new Exception("Нет сопряжения"); + if(sent+1>=reserved)reserve(); + long seq=++sent;byte[] iv=new byte[12];random.nextBytes(iv); + Cipher cipher=Cipher.getInstance("AES/GCM/NoPadding"); + cipher.init(Cipher.ENCRYPT_MODE,new SecretKeySpec(key,"AES"),new GCMParameterSpec(128,iv)); + cipher.updateAAD(Srp.utf8("CanvasLink/1/"+id+"/"+seq+"/request")); + JSONObject packet=new JSONObject().put("id",id).put("seq",seq).put("iv",Srp.hex(iv)) + .put("data",android.util.Base64.encodeToString(cipher.doFinal(Srp.utf8(value.toString())),android.util.Base64.NO_WRAP)); + JSONObject response=post("/channel",packet); + long next=response.getLong("seq");if(!id.equals(response.getString("id"))||next!=seq||next<=received)throw new SecurityException("Повторный или неподтверждённый ответ"); + String nonce=response.getString("iv");if(!nonce.matches("[0-9a-f]{24}"))throw new SecurityException("Неверный пакет"); + cipher.init(Cipher.DECRYPT_MODE,new SecretKeySpec(key,"AES"),new GCMParameterSpec(128,Srp.bytes(nonce))); + cipher.updateAAD(Srp.utf8("CanvasLink/1/"+id+"/"+next+"/response")); + byte[] plain=cipher.doFinal(android.util.Base64.decode(response.getString("data"),android.util.Base64.NO_WRAP)); + received=next;return new JSONObject(new String(plain,StandardCharsets.UTF_8)); + } + private void connect(String host,String pin){ + network.execute(()->{try{ + if(polling!=null)polling.cancel(false);id=null;key=null; + base=localBase(host);if(!pin.matches("[0-9]{6}"))throw new Exception("Введите шесть цифр"); + Srp srp=new Srp(); + JSONObject challenge=post("/pair/start",new JSONObject().put("A",srp.publicHex()).put("name",(Build.MANUFACTURER+" "+Build.MODEL))); + if(!Srp.USER.equals(challenge.getString("user")))throw new SecurityException("Неверный протокол"); + Srp.Session result=srp.derive(pin,challenge.getString("salt"),challenge.getString("B")); + JSONObject finish=post("/pair/finish",new JSONObject().put("id",challenge.getString("id")).put("proof",Srp.hex(result.proof))); + result.verify(finish.getString("proof")); + String nextId=finish.getString("id");if(!nextId.matches("[0-9a-f]{32}"))throw new SecurityException("Неверный идентификатор"); + id=nextId;key=result.channelKey;sent=0;received=0;reserved=0;errors=0;reserve(); + emit("paired",new JSONObject().put("host",host)); + runOnUiThread(()->getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)); + startPolling(); + }catch(Exception e){emit("error",e.getMessage());}}); + } + private void reserve() throws Exception { + reserved=sent+10000; + vault.save(new JSONObject().put("host",base.substring(7)).put("id",id).put("key",Srp.hex(key)).put("counter",reserved)); + } + private void startPolling(){ + if(polling!=null)polling.cancel(false); + networkFailureAt=0;errors=0; + polling=network.scheduleWithFixedDelay(()->{try{ + JSONObject response=exchange(new JSONObject().put("type","poll"));boolean recovered=errors>0;errors=0;networkFailureAt=0; + if(recovered)emit("network-restored",true); + JSONArray messages=response.getJSONArray("messages");for(int i=0;i=60000){polling.cancel(false);vault.clear();id=null;key=null;emit("disconnected",true);emit("error","Соединение потеряно. Создайте новый код на компьютере.");}}},0,200,TimeUnit.MILLISECONDS); + } + public final class Bridge { + @JavascriptInterface public boolean saveDraft(String text){if(text.length()>8000)return false;try{synchronized(drafts){drafts.save(new JSONObject().put("text",text));}return true;}catch(Exception e){emit("error","Не удалось сохранить черновик");return false;}} + @JavascriptInterface public void loadDraft(){storage.execute(()->{try{JSONObject saved=drafts.load();emit("draft",saved==null?"":saved.optString("text"));}catch(Exception ignored){}});} + @JavascriptInterface public void showKeyboard(String token){runOnUiThread(()->((TerminalWebView)web).openTerminal(token));} + @JavascriptInterface public void terminalKey(String key,String modifiers){runOnUiThread(()->((TerminalWebView)web).key(key,modifiers));} + @JavascriptInterface public void paste(){runOnUiThread(()->{android.content.ClipboardManager clipboard=(android.content.ClipboardManager)getSystemService(CLIPBOARD_SERVICE);if(clipboard.hasPrimaryClip()&&clipboard.getPrimaryClip().getItemCount()>0){CharSequence value=clipboard.getPrimaryClip().getItemAt(0).coerceToText(MainActivity.this);((TerminalWebView)web).paste(value==null?"":value.toString());}else ((TerminalWebView)web).paste("");});} + @JavascriptInterface public void hideKeyboard(){runOnUiThread(()->((TerminalWebView)web).closeTerminal());} + @JavascriptInterface public void connect(String host,String pin){MainActivity.this.connect(host,pin);} + @JavascriptInterface public void signal(String json){ + if(json.length()>90000)return; + network.execute(()->{try{exchange(new JSONObject().put("type","signal").put("message",new JSONObject(json)));}catch(Exception e){emit("error",e.getMessage());}}); + } + @JavascriptInterface public void discover(){runOnUiThread(()->startDiscovery());} + @JavascriptInterface public void restore(){network.execute(()->{try{ + JSONObject saved=vault.load();if(saved==null)return; + base=localBase(saved.getString("host"));id=saved.getString("id");key=Srp.bytes(saved.getString("key")); + if(!id.matches("[0-9a-f]{32}")||key.length!=32)throw new Exception("Invalid saved pairing"); + sent=saved.getLong("counter");received=0;reserve(); + exchange(new JSONObject().put("type","signal").put("message",new JSONObject().put("type","ping"))); + emit("paired",new JSONObject().put("host",saved.getString("host")));startPolling(); + runOnUiThread(()->getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)); + }catch(Exception e){vault.clear();id=null;key=null;emit("network","Введите новый код подключения на компьютере.");}});} + @JavascriptInterface public void disconnect(){network.execute(()->{try{exchange(new JSONObject().put("type","revoke"));}catch(Exception ignored){} if(polling!=null)polling.cancel(false);id=null;key=null;vault.clear();emit("disconnected",true);});runOnUiThread(()->getWindow().clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON));} + @JavascriptInterface public void diagnostics(String json){if(json.length()<2000){diagnostics=json;Log.i("CanvasLink", "STATS "+json);}} + @JavascriptInterface public void acceptance(String json){if(json.length()<2000)acceptance=json;} + } + private void resolveNext(final int epoch){ + if(epoch!=discoveryEpoch||resolveBusy||resolving.isEmpty())return; + final NsdServiceInfo service=resolving.remove();resolveBusy=true; + nsd.resolveService(service,new NsdManager.ResolveListener(){ + public void onResolveFailed(NsdServiceInfo s,int c){ui.post(()->{if(epoch!=discoveryEpoch)return;resolveBusy=false;emit("discovery","resolving-failed");ui.postDelayed(()->resolveNext(epoch),500);});} + public void onServiceResolved(NsdServiceInfo s){ui.post(()->{ + if(epoch!=discoveryEpoch)return;resolveBusy=false; + try{ + String address=s.getHost().getHostAddress()+":"+s.getPort(); + // Host advertises a private IPv4 hint for API26-32 resolvers that + // return only one IPv6 address. This is discovery, never authority: + // localBase and the existing SRP proof remain mandatory. + byte[] hint=s.getAttributes().get("ipv4"); + if(!(s.getHost() instanceof Inet4Address)&&hint!=null)address=new String(hint,StandardCharsets.UTF_8)+":"+s.getPort(); + localBase(address);found.put(s.getServiceName(),address); + emit("discovered",new JSONObject().put("id",s.getServiceName()).put("name",s.getServiceName()).put("host",address)); + }catch(Exception e){emit("discovery","unsupported-address");} + resolveNext(epoch); + });} + }); + } + private void startDiscovery(){ + if(discovery!=null){ + try{nsd.stopServiceDiscovery(discovery);}catch(Exception ignored){} + discovery=null;discoveryEpoch++;resolving.clear();resolveBusy=false; + for(String name:found.keySet())emit("lost",name);found.clear(); + ui.postDelayed(()->startDiscovery(),500);return; + } + final int epoch=++discoveryEpoch; + WifiManager wifi=(WifiManager)getApplicationContext().getSystemService(WIFI_SERVICE); + if(multicast==null){multicast=wifi.createMulticastLock("CanvasLink discovery");multicast.setReferenceCounted(false);} + if(!multicast.isHeld())multicast.acquire(); + discovery=new NsdManager.DiscoveryListener(){ + public void onDiscoveryStarted(String t){if(epoch==discoveryEpoch)emit("discovery","searching");} + public void onDiscoveryStopped(String t){} + public void onStartDiscoveryFailed(String t,int c){ui.post(()->{if(epoch!=discoveryEpoch)return;discovery=null;emit("discovery","unavailable");if(multicast.isHeld())multicast.release();});} + public void onStopDiscoveryFailed(String t,int c){} + public void onServiceLost(NsdServiceInfo service){ui.post(()->{if(epoch!=discoveryEpoch)return;found.remove(service.getServiceName());resolving.removeIf(s->s.getServiceName().equals(service.getServiceName()));emit("lost",service.getServiceName());});} + public void onServiceFound(NsdServiceInfo service){ui.post(()->{if(epoch!=discoveryEpoch||!service.getServiceName().startsWith("CanvasLink "))return;resolving.add(service);resolveNext(epoch);});} + }; + try{nsd.discoverServices("_canvaslink._tcp.",NsdManager.PROTOCOL_DNS_SD,discovery);}catch(Exception e){discovery=null;emit("discovery","unavailable");} + } + @Override protected void onDestroy(){ + if(active==this)active=null; + if(discovery!=null)try{nsd.stopServiceDiscovery(discovery);}catch(Exception ignored){} + if(multicast!=null&&multicast.isHeld())multicast.release(); + discoveryEpoch++;ui.removeCallbacksAndMessages(null);storage.shutdown();network.shutdownNow();web.destroy();super.onDestroy(); + } +} diff --git a/integrations/canvaslink/android/src/io/nexcore/canvaslink/NativeControlChecks.java b/integrations/canvaslink/android/src/io/nexcore/canvaslink/NativeControlChecks.java new file mode 100644 index 000000000..9565b1eac --- /dev/null +++ b/integrations/canvaslink/android/src/io/nexcore/canvaslink/NativeControlChecks.java @@ -0,0 +1,96 @@ +package io.nexcore.canvaslink; + +import android.os.Bundle; +import android.os.SystemClock; + +/** ADB-only controls against the controller's disposable CanvasTTY terminal. */ +final class NativeControlChecks { + final AcceptanceInstrumentation test; + NativeControlChecks(AcceptanceInstrumentation test){this.test=test;} + void until(String expression,String error)throws Exception{ + long end=SystemClock.uptimeMillis()+10000; + while(SystemClock.uptimeMillis()"+before,"Control was not acknowledged: "+key); + test.waitTerminalKeyboard(); + } + void text(String value)throws Exception{ + if(!"true".equals(test.js("CanvasLinkInput.active()")))throw new Exception("Direct IME must remain active during terminal editing"); + int before=Integer.parseInt(test.js("CanvasLink.stats.ackedInputs"));test.commitText(value); + until("CanvasLink.stats.ackedInputs>"+before+"&&CanvasLinkClient.getState().pending===0","Direct text was not acknowledged"); + } + String quote(String text){return "'"+text.replace("'","'\\''")+"'";} + void cursorVisible()throws Exception{ + until("(()=>{const c=CanvasLinkClient.getState().source?.caret;if(!c)return false;const e=document.getElementById('video'),r=e.getBoundingClientRect(),v=document.getElementById('viewport').getBoundingClientRect(),k=Math.min(r.width/e.videoWidth,r.height/e.videoHeight),x=r.left+(r.width-e.videoWidth*k)/2+(c.x+c.width/2)*e.videoWidth*k,y=r.top+(r.height-e.videoHeight*k)/2+(c.y+c.height/2)*e.videoHeight*k;return x>v.left&&xv.top+8&&yMainActivity.active.setRequestedOrientation(orientation)); + until(orientation==android.content.pm.ActivityInfo.SCREEN_ORIENTATION_PORTRAIT?"screen.height>screen.width":"screen.width>screen.height","Native orientation not applied");test.waitTerminalKeyboard();cursorVisible(); + until("(()=>{const v=document.getElementById('viewport').getBoundingClientRect(),b=document.getElementById('controls').getBoundingClientRect();return Math.abs(v.height+56-innerHeight)<2&&b.height===56&&new Set([...document.querySelectorAll('#controls button')].map(e=>e.getBoundingClientRect().top)).size===1;})()","Native IME workspace geometry failed"); + test.stage(orientation==android.content.pm.ActivityInfo.SCREEN_ORIENTATION_PORTRAIT?"native-real-portrait-ready":"native-real-landscape-ready");android.os.SystemClock.sleep(900); + }}finally{test.runOnMainSync(()->MainActivity.active.setRequestedOrientation(previous));} + result.putBoolean("actualCursorAndSingleRowWithRealImeBothOrientations",true); + } + void run(String directory,Bundle result)throws Exception{ + if(directory==null||!directory.startsWith("/")||directory.indexOf('\n')>=0)throw new Exception("Owned proof directory required"); + cursorVisible();orientations(result); + test.stage("native-five-real-retaps"); + for(int i=0;i<5;i++){test.menu("reset-view");test.tap("#video",test.freshNativePoint());test.waitTerminalKeyboard();cursorVisible();} + result.putInt("realTerminalRetaps",5); + test.stage("native-control-cancel-and-history"); + text("touch "+quote(directory+"/must-not-exist"));key("c");key("Escape");key("ArrowUp");key("ArrowDown");key("c"); + test.stage("native-control-editing"); + text("printf '%s\\n' > "+quote(directory+"/controls-edit.txt")+" 'OKX'"); + key("ArrowLeft");key("Backspace");key("ArrowRight");key("Enter"); + test.stage("native-control-tab"); + text("cd "+quote(directory)+"; : > canvaslink-tab-proof.txt");key("Enter"); + text("printf '%s\\n' 'TAB' > canvaslink-tab-pro");key("Tab");key("Enter"); + test.stage("native-control-no-duplicate-enter"); + for(int i=1;i<=5;i++){text("printf '%s\\n' '"+i+"' >> "+quote(directory+"/controls-once.txt"));key("Enter");} + test.stage("native-direct-composition-paste"); + text("printf '%s\\n' '"); + final android.content.ClipData[] oldClip={null}; + boolean[] direct={false},clipboardChanged={false};try{test.runOnMainSync(()->{ + android.view.inputmethod.InputConnection input=MainActivity.active.web.onCreateInputConnection(new android.view.inputmethod.EditorInfo()); + direct[0]=input!=null&&input.setComposingText("日本X",1)&&input.deleteSurroundingText(1,0)&&input.commitText("日本語🙂X",1)&&input.finishComposingText()&&input.deleteSurroundingText(1,0); + android.content.ClipboardManager clipboard=(android.content.ClipboardManager)MainActivity.active.getSystemService(android.content.Context.CLIPBOARD_SERVICE); + oldClip[0]=clipboard.getPrimaryClip(); + clipboard.setPrimaryClip(android.content.ClipData.newPlainText("CanvasLink acceptance"," · ёЖ")); + clipboardChanged[0]=true; + }); + if(!direct[0])throw new Exception("Native direct composing input failed"); + until("CanvasLinkClient.getState().pending===0","Composition did not finish");test.tap("#paste"); + until("CanvasLinkClient.getState().pending===0","Clipboard input did not finish"); + test.runOnMainSync(()->((android.content.ClipboardManager)MainActivity.active.getSystemService(android.content.Context.CLIPBOARD_SERVICE)).setPrimaryClip(android.content.ClipData.newPlainText("CanvasLink acceptance","a"))); + test.tap("#ctrl");test.tap("#paste");until("CanvasLinkClient.getState().pending===0","Literal one-letter paste did not finish"); + }finally{if(clipboardChanged[0])test.runOnMainSync(()->{android.content.ClipboardManager c=(android.content.ClipboardManager)MainActivity.active.getSystemService(android.content.Context.CLIPBOARD_SERVICE);if(oldClip[0]!=null)c.setPrimaryClip(oldClip[0]);else if(android.os.Build.VERSION.SDK_INT>=28)c.clearPrimaryClip();});} + result.putBoolean("literalClipboardWithLatchedCtrl",true); + text("' > "+quote(directory+"/controls-direct.txt")); + test.runOnMainSync(()->MainActivity.active.web.onCreateInputConnection(new android.view.inputmethod.EditorInfo()).performEditorAction(android.view.inputmethod.EditorInfo.IME_ACTION_NONE)); + until("CanvasLinkClient.getState().pending===0","Native IME Enter did not finish"); + result.putBoolean("directCompositionUnicodeBackspaceClipboardImeEnter",true); + test.stage("native-physical-ime-key");text("printf '%s\\n' '"); + test.runOnMainSync(()->((android.view.inputmethod.InputMethodManager)MainActivity.active.getSystemService(android.content.Context.INPUT_METHOD_SERVICE)).restartInput(MainActivity.active.web)); + test.waitTerminalKeyboard();test.stage("native-physical-ime-key-ready");android.os.SystemClock.sleep(800); + android.accessibilityservice.AccessibilityServiceInfo info=test.getUiAutomation().getServiceInfo();info.flags|=android.accessibilityservice.AccessibilityServiceInfo.FLAG_RETRIEVE_INTERACTIVE_WINDOWS;test.getUiAutomation().setServiceInfo(info); + android.graphics.Rect keyRect=null;StringBuilder labels=new StringBuilder(); + for(android.view.accessibility.AccessibilityWindowInfo window:test.getUiAutomation().getWindows())if(window.getType()==android.view.accessibility.AccessibilityWindowInfo.TYPE_INPUT_METHOD){keyRect=findImeKey(window.getRoot(),"a",labels);if(keyRect!=null)break;} + if(keyRect==null){result.putString("imeKeyLabels",labels.toString());throw new Exception("Real keyboard letter a not exposed; inspect IME-only labels");} + long at=android.os.SystemClock.uptimeMillis();for(int action:new int[]{android.view.MotionEvent.ACTION_DOWN,android.view.MotionEvent.ACTION_UP}){android.view.MotionEvent e=android.view.MotionEvent.obtain(at,android.os.SystemClock.uptimeMillis(),action,keyRect.centerX(),keyRect.centerY(),0);e.setSource(android.view.InputDevice.SOURCE_TOUCHSCREEN);if(!test.getUiAutomation().injectInputEvent(e,true))throw new Exception("IME key touch rejected");e.recycle();android.os.SystemClock.sleep(80);} + android.os.SystemClock.sleep(400);text("' > "+quote(directory+"/controls-ime-key.txt"));key("Enter");result.putBoolean("physicalKeyboardKeyTouched",true); + result.putBoolean("nativeToolbarControlsAcknowledged",true); + result.putInt("nativeSingleEnterCommands",5); + } + android.graphics.Rect findImeKey(android.view.accessibility.AccessibilityNodeInfo node,String wanted,StringBuilder labels){ + if(node==null)return null;String text=node.getText()==null?"":node.getText().toString(),desc=node.getContentDescription()==null?"":node.getContentDescription().toString(); + if(labels.length()<1500&&(!text.isEmpty()||!desc.isEmpty()))labels.append('[').append(text).append('|').append(desc).append(']'); + android.graphics.Rect r=new android.graphics.Rect();node.getBoundsInScreen(r);if(node.isVisibleToUser()&&!r.isEmpty()&&(wanted.equalsIgnoreCase(text)||wanted.equalsIgnoreCase(desc)))return r; + for(int i=0;i WebRTC -> ordinary compiled CanvasTTY Settings. */ +final class SettingsTouchChecks { + final AcceptanceInstrumentation test; + final DeviceUxChecks gestures; + SettingsTouchChecks(AcceptanceInstrumentation test){this.test=test;gestures=new DeviceUxChecks(test,MainActivity.active);} + JSONObject probe()throws Exception{ + java.io.File file=new java.io.File(MainActivity.active.getExternalFilesDir(null),"acceptance-settings.json"); + file.delete();test.stage("remote-settings-request"); + for(int i=0;i<80&&!file.exists();i++)SystemClock.sleep(100); + if(!file.exists())throw new Exception("Read-only Settings geometry unavailable"); + JSONObject value=new JSONObject(new String(java.nio.file.Files.readAllBytes(file.toPath()),java.nio.charset.StandardCharsets.UTF_8));file.delete();return value; + } + JSONObject rect(JSONObject p,String name)throws Exception{ + JSONObject item=name.startsWith("settings-tab-")?p.getJSONObject("tabs").optJSONObject(name):p.getJSONObject("controls").optJSONObject(name); + if(item==null||item.isNull("rect"))throw new Exception("Settings target not visible: "+name); + JSONObject r=item.getJSONObject("rect");if(!r.optBoolean("hit"))throw new Exception("Settings target covered: "+name);return r; + } + void tap(JSONObject p,String name)throws Exception{JSONObject r=rect(p,name);test.tap("#video",(r.getDouble("x")+r.getDouble("width")/2)+","+(r.getDouble("y")+r.getDouble("height")/2));SystemClock.sleep(500);} + float[] point(double x,double y)throws Exception{ + JSONObject r=new JSONObject(test.js("(()=>{const e=document.getElementById('video'),r=e.getBoundingClientRect(),k=Math.min(r.width/e.videoWidth,r.height/e.videoHeight);return {x:r.x+(r.width-e.videoWidth*k)/2+e.videoWidth*k*"+x+",y:r.y+(r.height-e.videoHeight*k)/2+e.videoHeight*k*"+y+",d:devicePixelRatio}})()")); + int[] origin=new int[2];test.runOnMainSync(()->MainActivity.active.web.getLocationOnScreen(origin));return new float[]{(float)(r.getDouble("x")*r.getDouble("d"))+origin[0],(float)(r.getDouble("y")*r.getDouble("d"))+origin[1]}; + } + void swipe(JSONObject r,double direction)throws Exception{ + double x=r.getDouble("x")+r.getDouble("width")*.9,y=r.getDouble("y"),h=r.getDouble("height"); + float[] a=point(x,y+h*(direction>0?.75:.25)),b=point(x,y+h*(direction>0?.25:.75)); + long down=SystemClock.uptimeMillis();gestures.motion(down,MotionEvent.ACTION_DOWN,new float[][]{a}); + for(int i=1;i<=16;i++){SystemClock.sleep(25);float f=i/16f;gestures.motion(down,MotionEvent.ACTION_MOVE,new float[][]{{a[0]+(b[0]-a[0])*f,a[1]+(b[1]-a[1])*f}});} + gestures.motion(down,MotionEvent.ACTION_UP,new float[][]{b});SystemClock.sleep(600); + } + JSONObject reveal(JSONObject p,String name)throws Exception{ + for(int i=0;i<28;i++){JSONObject c=p.getJSONObject("controls").optJSONObject(name);if(c!=null&&!c.isNull("rect")&&c.getJSONObject("rect").optBoolean("hit"))return p; + JSONObject scroll=p.getJSONObject("scroll");if(scroll.getDouble("top")+scroll.getDouble("height")>=scroll.getDouble("total")-2)break; + swipe(rect(p,"content"),1);p=probe(); + }throw new Exception("Cannot reveal Settings control: "+name); + } + void noKeyboard(String context)throws Exception{ + boolean[] visible={false};test.runOnMainSync(()->visible[0]=MainActivity.active.web.getRootWindowInsets()!=null&&MainActivity.active.web.getRootWindowInsets().isVisible(android.view.WindowInsets.Type.ime())); + if(visible[0]||!"false".equals(test.js("CanvasLinkInput.active()")))throw new Exception("Non-text Settings control opened IME: "+context); + if(!"true".equals(test.js("document.getElementById('zoom-label').textContent==='100%'")))throw new Exception("Non-text Settings control changed Canvas zoom: "+context); + } + void dragRange(JSONObject r)throws Exception{ + float[] a=point(r.getDouble("x")+r.getDouble("width")*.3,r.getDouble("y")+r.getDouble("height")/2),b=point(r.getDouble("x")+r.getDouble("width")*.65,r.getDouble("y")+r.getDouble("height")/2); + long down=SystemClock.uptimeMillis();gestures.motion(down,MotionEvent.ACTION_DOWN,new float[][]{a});for(int i=1;i<=12;i++){SystemClock.sleep(25);float f=i/12f;gestures.motion(down,MotionEvent.ACTION_MOVE,new float[][]{{a[0]+(b[0]-a[0])*f,a[1]+(b[1]-a[1])*f}});}gestures.motion(down,MotionEvent.ACTION_UP,new float[][]{b});SystemClock.sleep(400); + } + void run(Bundle result)throws Exception{ + test.stage("settings-touch-open");test.menu("reset-view");JSONObject p=probe();int framesBeforeTouch=test.decodedFrames();tap(p,"open");p=probe(); + if(!p.optBoolean("open"))throw new Exception("Touch did not open Settings");result.putBoolean("remoteSettingsOpened",true);noKeyboard("open"); + test.waitVideoAfterTouch(framesBeforeTouch);result.putBoolean("remoteSettingsVideoRespondedToTouch",true); + tap(p,"settings-tab-general");p=probe();boolean wasOn=p.getJSONObject("controls").getJSONObject("toggleOn").getBoolean("selected"); + tap(p,wasOn?"toggleOff":"toggleOn");p=probe();if(p.getJSONObject("controls").getJSONObject("toggleOn").getBoolean("selected")==wasOn)throw new Exception("Touch toggle did not change");noKeyboard("toggle"); + tap(p,wasOn?"toggleOn":"toggleOff");result.putBoolean("remoteToggleChangedAndRestored",true); + tap(probe(),"settings-tab-appearance");p=probe();if(!"settings-panel-appearance".equals(p.optString("tab")))throw new Exception("Touch did not select Appearance"); + double before=p.getJSONObject("scroll").getDouble("top");swipe(rect(p,"content"),1);p=probe(); + if(p.getJSONObject("scroll").getDouble("top")<=before+20)throw new Exception("One-finger Settings swipe did not scroll remote content"); + result.putBoolean("remoteSettingsOneFingerScroll",true);noKeyboard("scroll"); + for(int i=0;i<12&&(p.getJSONObject("controls").optJSONObject("range")==null||p.getJSONObject("controls").getJSONObject("range").isNull("rect"));i++){swipe(rect(p,"content"),1);p=probe();} + double oldRange=p.getJSONObject("controls").getJSONObject("range").getDouble("value"); + tap(p,"range");noKeyboard("range");p=probe();double beforeDrag=p.getJSONObject("controls").getJSONObject("range").getDouble("value");JSONObject slider=rect(p,"range"); + String fresh="(()=>{const p="+slider.toString()+",x=p.x+p.width/2,y=p.y+p.height/2;return CanvasLinkClient.getState().source.dragRects.some(r=>x>=r.x&&x<=r.x+r.width&&y>=r.y&&y<=r.y+r.height)})()"; + if(!"true".equals(test.js(fresh)))throw new Exception("Slider geometry stayed stale after UI resize"); + dragRange(slider);p=probe();double selected=p.getJSONObject("controls").getJSONObject("range").getDouble("value");if(Math.abs(selected-beforeDrag)<.001)throw new Exception("Actual range drag did not change value");result.putBoolean("remoteRangeTouchDrag",true);noKeyboard("range drag"); + test.tap("[data-key=ArrowRight]");SystemClock.sleep(300);p=probe();double next=p.getJSONObject("controls").getJSONObject("range").getDouble("value"); + if(next<=selected)throw new Exception("Range ArrowRight failed without IME"); + for(int i=0;i<60&&Math.abs(next-oldRange)>.001;i++){test.tap(next>oldRange?"[data-key=ArrowLeft]":"[data-key=ArrowRight]");SystemClock.sleep(120);p=probe();next=p.getJSONObject("controls").getJSONObject("range").getDouble("value");} + if(Math.abs(next-oldRange)>.001)throw new Exception("Range could not be restored"); + result.putBoolean("remoteRangeNoKeyboard",true);result.putBoolean("remoteRangeScreenKeysChangedAndRestored",true); + test.stage("settings-text-and-select");tap(probe(),"settings-tab-agents");p=reveal(probe(),"preset");tap(p,"preset");p=reveal(probe(),"text"); + JSONObject textRect=rect(p,"text");test.tap("#video",(textRect.getDouble("x")+textRect.getDouble("width")*.96)+","+(textRect.getDouble("y")+textRect.getDouble("height")/2));test.waitTerminalKeyboard();test.stage("settings-text-ime-ready");SystemClock.sleep(800); + JSONObject legibility=new JSONObject(test.js("(()=>{const e=document.getElementById('video'),r=e.getBoundingClientRect(),k=Math.min(r.width/e.videoWidth,r.height/e.videoHeight);return {font:"+p.getJSONObject("controls").getJSONObject("text").getDouble("fontSize")+"*e.videoWidth/(CanvasLinkClient.getState().source.viewport?.width||e.videoWidth)*k,scale:new DOMMatrix(getComputedStyle(e).transform).a}})()"));if(legibility.getDouble("font")<11.5||legibility.getDouble("scale")>4.01)throw new Exception("Settings field too small or excessively zoomed with real IME"); + test.commitText(" · CL touch");SystemClock.sleep(400);p=probe();if(!p.getJSONObject("controls").getJSONObject("text").optBoolean("testMarker"))throw new Exception("Text field did not receive direct IME commit"); + test.tap("#keyboard-toggle");SystemClock.sleep(400);noKeyboard("text edit ended");result.putBoolean("remoteTextReadableWithImeAndOverviewRestored",true);p=probe();tap(p,"save");SystemClock.sleep(500); + tap(probe(),"settings-tab-general");tap(probe(),"settings-tab-agents");p=reveal(probe(),"text");if(!p.getJSONObject("controls").getJSONObject("text").optBoolean("testMarker"))throw new Exception("Settings text did not persist across tab reopen");result.putBoolean("remoteTextDirectImeSavedAndReopened",true); + p=reveal(p,"select");int prior=p.getJSONObject("controls").getJSONObject("select").getInt("index");tap(p,"select");noKeyboard("select");test.stage("settings-select-open");SystemClock.sleep(800); + p=probe();int choice=prior==0?1:0;tap(p,"option-"+choice);p=probe(); + if(p.getJSONObject("controls").getJSONObject("select").getInt("index")!=choice)throw new Exception("Select did not change through actual option touch");result.putBoolean("remoteSelectOptionTouch",true); + tap(p,"select");test.tap("[data-key=ArrowDown]");test.tap("[data-key=Enter]");SystemClock.sleep(400);p=probe(); + int keyboardChoice=p.getJSONObject("controls").getJSONObject("select").getInt("index");if(keyboardChoice==choice)throw new Exception("Select did not change through screen keys");result.putBoolean("remoteSelectScreenKeys",true); + tap(p,"select");test.tap("[data-key=ArrowUp]");test.tap("[data-key=Escape]");p=probe();if(p.getJSONObject("controls").getJSONObject("select").getInt("index")!=keyboardChoice||p.getJSONObject("controls").has("option-0"))throw new Exception("Select Escape did not dismiss without applying");result.putBoolean("remoteSelectEscape",true);noKeyboard("select options"); + tap(p,"save");tap(probe(),"settings-tab-general");tap(probe(),"settings-tab-agents");p=reveal(probe(),"select");if(p.getJSONObject("controls").getJSONObject("select").getInt("index")!=keyboardChoice)throw new Exception("Select value did not persist");result.putBoolean("remoteSelectSavedAndReopened",true); + tap(p,"remove");SystemClock.sleep(400); + tap(probe(),"close");if(probe().optBoolean("open"))throw new Exception("Touch did not close Settings");result.putBoolean("remoteSettingsClosed",true); + } +} diff --git a/integrations/canvaslink/android/src/io/nexcore/canvaslink/Srp.java b/integrations/canvaslink/android/src/io/nexcore/canvaslink/Srp.java new file mode 100644 index 000000000..0c4c92cf1 --- /dev/null +++ b/integrations/canvaslink/android/src/io/nexcore/canvaslink/Srp.java @@ -0,0 +1,61 @@ +package io.nexcore.canvaslink; + +import java.math.BigInteger; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.SecureRandom; +import java.util.Arrays; + +/** SRP-6a/SHA-256 interoperability with secure-remote-password 0.3.1. + * Fixed RFC 5054 2048-bit group. PIN is never sent to the network. */ +public final class Srp { + static final String PRIME = "AC6BDB41324A9A9BF166DE5E1389582FAF72B6651987EE07FC319294" + + "3DB56050A37329CBB4A099ED8193E0757767A13DD52312AB4B03310D" + + "CD7F48A9DA04FD50E8083969EDB767B0CF6095179A163AB3661A05FB" + + "D5FAAAE82918A9962F0B93B855F97993EC975EEAA80D740ADBF4FF74" + + "7359D041D5C33EA71D281E446B14773BCA97B43A23FB801676BD207A" + + "436C6481F1D2B9078717461A5B9D32E688F87748544523B524B0D57D" + + "5EA77A2775D2ECFA032CFBDBF52FB3786160279004E57AE6AF874E73" + + "03CE53299CCC041C7BC308D82A5698F3A8D0C38271AE35F8E9DBFBB6" + + "94B5C803D89F7AE435DE236D525F54759B65E372FCD68EF20FA7111F9E4AFF73"; + static final BigInteger N = new BigInteger(PRIME, 16), G = BigInteger.valueOf(2); + static final String USER = "CanvasLink/v1"; + final BigInteger secret, publicValue; + public Srp() { byte[] a = new byte[32]; new SecureRandom().nextBytes(a); secret = new BigInteger(1, a); publicValue = G.modPow(secret, N); } + Srp(String fixedSecret) { secret = new BigInteger(fixedSecret, 16); publicValue = G.modPow(secret, N); } + public String publicHex() { return hex(pad(publicValue, 256)); } + public static byte[] utf8(String value) { return value.getBytes(StandardCharsets.UTF_8); } + public static byte[] hash(byte[]... values) { + try { MessageDigest d = MessageDigest.getInstance("SHA-256"); for (byte[] v : values) d.update(v); return d.digest(); } + catch (Exception e) { throw new IllegalStateException(e); } + } + public static String hex(byte[] value) { StringBuilder s = new StringBuilder(); for (byte b : value) s.append(String.format(java.util.Locale.ROOT, "%02x", b & 255)); return s.toString(); } + public static byte[] bytes(String value) { + if (value == null || !value.matches("[0-9a-fA-F]+") || value.length() % 2 != 0) throw new IllegalArgumentException("Invalid hex"); + byte[] out = new byte[value.length() / 2]; for (int i = 0; i < out.length; i++) out[i] = (byte) Integer.parseInt(value.substring(i*2, i*2+2), 16); return out; + } + static byte[] pad(BigInteger value, int length) { + byte[] raw = value.toByteArray(), out = new byte[length]; + int count = Math.min(length, raw.length); System.arraycopy(raw, raw.length-count, out, length-count, count); return out; + } + public Session derive(String pin, String saltHex, String serverHex) { + if (!pin.matches("[0-9]{6}") || !saltHex.matches("[0-9a-f]{64}") || !serverHex.matches("[0-9a-f]{512}")) throw new IllegalArgumentException("Invalid SRP challenge"); + byte[] salt = bytes(saltHex), a = pad(publicValue, 256), b = bytes(serverHex), n = bytes(PRIME), g = {2}; + BigInteger B = new BigInteger(1, b); + if (B.mod(N).equals(BigInteger.ZERO)) throw new IllegalArgumentException("Invalid server ephemeral"); + BigInteger k = new BigInteger(1, hash(n, g)); + BigInteger x = new BigInteger(1, hash(salt, hash(utf8(USER + ":" + pin)))); + BigInteger u = new BigInteger(1, hash(a, b)); + if (u.equals(BigInteger.ZERO)) throw new IllegalArgumentException("Invalid scrambling parameter"); + BigInteger S = B.subtract(k.multiply(G.modPow(x, N))).mod(N).modPow(secret.add(u.multiply(x)), N); + byte[] K = hash(pad(S, 256)), xor = hash(n), hg = hash(g); + for (int i=0; ishowTerminal(next),150);postDelayed(()->showTerminal(next),350); + } + private void showTerminal(String owner){if(owner.equals(token)&&!owner.isEmpty()){requestFocus();ime().showSoftInput(this,InputMethodManager.SHOW_IMPLICIT);}} + void closeTerminal(){token="";connection=null;ime().hideSoftInputFromWindow(getWindowToken(),0);ime().restartInput(this);} + boolean terminalActive(){return !token.isEmpty();} + @Override public boolean onTouchEvent(MotionEvent event){ + String owner=token;boolean handled=super.onTouchEvent(event); + // Blink dismisses its DOM keyboard when a toolbar button is touched. Our + // terminal input connection still owns focus, so keep the terminal IME up. + // Menu/explicit hide invalidates owner before this delayed callback runs. + if(event.getActionMasked()==MotionEvent.ACTION_UP&&!owner.isEmpty())postDelayed(()->showTerminal(owner),150); + return handled; + } + private void event(JSONObject value){ + if(token.isEmpty())return; + try{value.put("token",token);}catch(JSONException ignored){} + evaluateJavascript("window.CanvasLinkInput&&window.CanvasLinkInput.receive("+value.toString()+")",null); + } + private void text(String value){if(value.isEmpty())return;try{event(new JSONObject().put("type","text").put("text",value));}catch(JSONException ignored){}} + void key(String key,String modifiers){ + if(connection!=null)connection.finishComposingText(); + try{event(new JSONObject().put("type","key").put("key",key).put("modifiers",new JSONArray(modifiers)));}catch(JSONException ignored){} + } + void paste(String value){ + if(value.isEmpty()){try{event(new JSONObject().put("type","notice").put("text","Буфер обмена пуст"));}catch(JSONException ignored){}return;} + if(value.length()>8000){try{event(new JSONObject().put("type","notice").put("text","В буфере больше 8000 символов"));}catch(JSONException ignored){}return;} + if(connection!=null)connection.finishComposingText(); + try{event(new JSONObject().put("type","paste").put("text",value));}catch(JSONException ignored){} + } + @Override public boolean onCheckIsTextEditor(){return terminalActive()||super.onCheckIsTextEditor();} + @Override public InputConnection onCreateInputConnection(EditorInfo out){ + if(!terminalActive())return super.onCreateInputConnection(out); + out.inputType=InputType.TYPE_CLASS_TEXT|InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD|InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS|InputType.TYPE_TEXT_FLAG_MULTI_LINE; + out.imeOptions=EditorInfo.IME_FLAG_NO_EXTRACT_UI|EditorInfo.IME_FLAG_NO_FULLSCREEN|EditorInfo.IME_FLAG_NO_PERSONALIZED_LEARNING|EditorInfo.IME_ACTION_NONE; + out.initialSelStart=out.initialSelEnd=0; + return connection=new TerminalConnection(token); + } + private boolean hardware(KeyEvent e){ + if(!terminalActive()||e.getKeyCode()==KeyEvent.KEYCODE_BACK)return false; + String k=null; + switch(e.getKeyCode()){ + case KeyEvent.KEYCODE_ENTER:k="Enter";break;case KeyEvent.KEYCODE_DEL:k="Backspace";break; + case KeyEvent.KEYCODE_FORWARD_DEL:k="Delete";break;case KeyEvent.KEYCODE_TAB:k="Tab";break; + case KeyEvent.KEYCODE_ESCAPE:k="Escape";break;case KeyEvent.KEYCODE_DPAD_LEFT:k="ArrowLeft";break; + case KeyEvent.KEYCODE_DPAD_RIGHT:k="ArrowRight";break;case KeyEvent.KEYCODE_DPAD_UP:k="ArrowUp";break; + case KeyEvent.KEYCODE_DPAD_DOWN:k="ArrowDown";break;case KeyEvent.KEYCODE_MOVE_HOME:k="Home";break; + case KeyEvent.KEYCODE_MOVE_END:k="End";break;case KeyEvent.KEYCODE_PAGE_UP:k="PageUp";break; + case KeyEvent.KEYCODE_PAGE_DOWN:k="PageDown";break; + } + int code=e.getUnicodeChar(); + if(k==null&&code==0)return false; + if(e.getAction()!=KeyEvent.ACTION_DOWN)return true; + JSONArray mods=new JSONArray();if(e.isCtrlPressed())mods.put("control");if(e.isAltPressed())mods.put("alt");if(e.isShiftPressed())mods.put("shift");if(e.isMetaPressed())mods.put("meta"); + if(k!=null)key(k,mods.toString()); + else if(e.isCtrlPressed()||e.isMetaPressed()){ + int base=e.getUnicodeChar(0);if(base>0)key(new String(Character.toChars(base)).toLowerCase(java.util.Locale.ROOT),mods.toString()); + }else if(code>0&&code<=Character.MAX_CODE_POINT){if(connection!=null)connection.finishComposingText();text(new String(Character.toChars(code)));} + return true; + } + @Override public boolean dispatchKeyEvent(KeyEvent e){return hardware(e)||super.dispatchKeyEvent(e);} + @Override public boolean onKeyPreIme(int code,KeyEvent event){ + if(code==KeyEvent.KEYCODE_BACK&&terminalActive()){ + if(event.getAction()==KeyEvent.ACTION_UP){closeTerminal();evaluateJavascript("window.CanvasLinkInput&&window.CanvasLinkInput.stop()",null);} + return true; + } + return super.onKeyPreIme(code,event); + } + private final class TerminalConnection extends BaseInputConnection { + final String owner;final Editable composing=new SpannableStringBuilder(); + TerminalConnection(String owner){super(TerminalWebView.this,true);this.owner=owner;Selection.setSelection(composing,0);} + private boolean valid(){return connection==this&&owner.equals(token)&&!token.isEmpty();} + @Override public Editable getEditable(){return composing;} + private void preedit(){try{event(new JSONObject().put("type","composition").put("text",composing.toString()));}catch(JSONException ignored){}ime().updateSelection(TerminalWebView.this,Selection.getSelectionStart(composing),Selection.getSelectionEnd(composing),getComposingSpanStart(composing),getComposingSpanEnd(composing));} + private void clear(){composing.clear();Selection.setSelection(composing,0);preedit();} + @Override public boolean setComposingText(CharSequence value,int cursor){if(!valid())return false;super.setComposingText(value,cursor);preedit();return true;} + @Override public boolean commitText(CharSequence value,int cursor){if(!valid())return false;String committed=value.toString();clear();if("\n".equals(committed))key("Enter","[]");else text(committed);return true;} + @Override public boolean finishComposingText(){if(!valid())return false;String value=composing.toString();super.finishComposingText();clear();text(value);return true;} + @Override public boolean deleteSurroundingText(int before,int after){ + if(!valid()||before<0||after<0||before>8000||after>8000)return false; + if(composing.length()>0){int cursor=Math.max(0,Selection.getSelectionEnd(composing));int from=Math.max(0,cursor-before),to=Math.min(composing.length(),cursor+after);composing.delete(from,to);Selection.setSelection(composing,from);preedit();return true;} + for(int i=0;i8000||after>8000)return false; + if(composing.length()==0)return deleteSurroundingText(before,after); + int cursor=Math.max(0,Selection.getSelectionEnd(composing)); + int left=Character.codePointCount(composing,0,cursor),right=Character.codePointCount(composing,cursor,composing.length()); + int from=Character.offsetByCodePoints(composing,cursor,-Math.min(before,left)),to=Character.offsetByCodePoints(composing,cursor,Math.min(after,right)); + composing.delete(from,to);Selection.setSelection(composing,from);preedit();return true; + } + @Override public boolean sendKeyEvent(KeyEvent event){return valid()&&hardware(event);} + @Override public boolean performEditorAction(int action){if(!valid())return false;key("Enter","[]");return true;} + @Override public boolean performContextMenuAction(int action){ + if(!valid()||action!=android.R.id.paste)return false; + android.content.ClipboardManager clipboard=(android.content.ClipboardManager)getContext().getSystemService(Context.CLIPBOARD_SERVICE); + if(clipboard.hasPrimaryClip()&&clipboard.getPrimaryClip().getItemCount()>0){CharSequence value=clipboard.getPrimaryClip().getItemAt(0).coerceToText(getContext());if(value!=null)paste(value.toString());}return true; + } + } +} diff --git a/integrations/canvaslink/design/canvaslink-icon.svg b/integrations/canvaslink/design/canvaslink-icon.svg new file mode 100644 index 000000000..b9f3ffcb1 --- /dev/null +++ b/integrations/canvaslink/design/canvaslink-icon.svg @@ -0,0 +1,10 @@ + + + + + CanvasLink — linked windows + A wide remote window and a square touch surface interlock through two open rails. Critical geometry stays within the central 66dp adaptive safe circle. + + + + diff --git a/integrations/canvaslink/host/discovery.cjs b/integrations/canvaslink/host/discovery.cjs new file mode 100644 index 000000000..16a46e18f --- /dev/null +++ b/integrations/canvaslink/host/discovery.cjs @@ -0,0 +1,12 @@ +const {spawn}=require('node:child_process'); +// macOS mDNSResponder owns multicast publication. UDP-only JS Bonjour can +// report "up" while its records remain invisible to native DNS-SD browsers. +function publish({name,port,ipv4,embedded=false}){ + if(process.platform==='darwin'){ + const child=spawn('/usr/bin/dns-sd',['-R',name,'_canvaslink._tcp','local.',String(port),'v=2','ipv4='+ipv4,'host='+(embedded?'canvastty':'standalone')],{stdio:'ignore'}); + child.on('error',()=>{}); + return {destroy(){child.kill('SIGTERM');}}; + } + const {Bonjour}=require('bonjour-service'),bonjour=new Bonjour();bonjour.publish({name,type:'canvaslink',port,txt:{v:'2',ipv4,host:embedded?'canvastty':'standalone'}});return bonjour; +} +module.exports={publish}; diff --git a/integrations/canvaslink/host/display-scale.cjs b/integrations/canvaslink/host/display-scale.cjs new file mode 100644 index 000000000..49dc23ee0 --- /dev/null +++ b/integrations/canvaslink/host/display-scale.cjs @@ -0,0 +1,13 @@ +// Optional high-density startup; the desktop must opt in explicitly. +// Never change density or restart the default desktop process. +function configureRenderScale(app,argv=process.argv){ + if(app.isReady())throw Error('render-scale-requires-startup'); + const scale=argv.find(arg=>arg.startsWith('--force-device-scale-factor=')); + if(scale){if(!['--force-device-scale-factor=1','--force-device-scale-factor=2'].includes(scale))throw Error('invalid-render-scale');return;} + // Electron 43/macOS reads this switch before JavaScript: appendSwitch at the + // top of main still leaves DPR=1. A normal preview launch restarts once with + // the process-start switch. The owned acceptance launcher already passes it. + app.relaunch({args:[...argv.slice(1),'--force-device-scale-factor=2']}); + app.exit(0); +} +module.exports={configureRenderScale}; diff --git a/integrations/canvaslink/host/embedded.cjs b/integrations/canvaslink/host/embedded.cjs new file mode 100644 index 000000000..9ce0164e5 --- /dev/null +++ b/integrations/canvaslink/host/embedded.cjs @@ -0,0 +1,138 @@ +// CanvasTTY main-process integration. No global input or desktop capture. +const {app,BrowserWindow,ipcMain,session,nativeImage}=require('electron'); +const http=require('node:http'),os=require('node:os'),path=require('node:path'),fs=require('node:fs'); +const {publish}=require('./discovery.cjs'); +const {Pairing,privateAddress}=require('./security.cjs'); +const {InputQueue}=require('./input-queue.cjs'); +const {focusOwnedWindow}=require('./owned-focus.cjs'); +const {OwnedPointer}=require('./owned-pointer.cjs'); +const {installSettingsHost}=require('./settings-host.cjs'); +const {validateInput}=require('./native-input.cjs'); +const {selectPointerScript,closeSelectScript}=require('./remote-select.cjs'); +const {createSnapshot}=require('./snapshot.cjs'); +function install({getWindow,glasses}={}){ + let target,panel,server,bonjour,pairing,port,frameTimer,healthTimer,enabled=false; + const queues=new Map(),epochs=new Map(),pointer=new OwnedPointer();let bound=false,captureMode='tab',captureError='',captureQuality=null,frameSequence=0,pendingFrame=0,healthBusy=false,selectPointer=false,sourceSurface='canvas',lastHealth=0;let report={phase:'disabled'},lastFrame=0,startFallback=()=>{}; + const runtime=()=>path.join(app.getPath('userData'),'canvaslink'); + const option=name=>{const i=process.argv.indexOf(name);return i<0?undefined:process.argv[i+1];}; + const closeRemoteSelect=()=>{selectPointer=false;if(target&&!target.isDestroyed())target.webContents.executeJavaScript(closeSelectScript).catch(()=>{});}; + const ownWindow=()=>getWindow?.()||BrowserWindow.getAllWindows().find(w=>!w.getParentWindow()&&/\/renderer\/index\.html/.test(w.webContents.getURL())); + const settingsSnapshot=()=>({enabled,phase:enabled?report.phase:'disabled',pin:enabled?pairing.pin:'',expires:enabled?pairing.expires:0,devices:enabled?[...pairing.sessions.values()].filter(s=>s.expires>Date.now()).map(s=>({name:s.name})):[]}); + installSettingsHost({ipcMain,getWindow:ownWindow,snapshot:settingsSnapshot,command:async action=>{ + if(action==='enable')await open(); + else if(action==='disable')disable(); + else{if(!enabled)throw Error('canvaslink-disabled');pairing.rotate();report={phase:'waiting',inputs:0,error:''};events({type:'state'});} + return settingsSnapshot(); + }}); + const addresses=()=>Object.values(os.networkInterfaces()).flat().filter(i=>i.family==='IPv4'&&!i.internal&&privateAddress(i.address)).map(i=>i.address); + const events=value=>{if(panel&&!panel.isDestroyed())panel.webContents.send('link:event',value);}; + const source=()=>({id:'embedded:'+target.id,name:target.getTitle(),pid:process.pid}); + const state=()=>{const [width,height]=target.getContentSize();return {pin:pairing.pin,expires:pairing.expires,addresses:addresses(),port,fixture:false,embedded:true,nativeCapture:true,captureMode,glassesSupported:!!glasses,width,height,source:target.getTitle(),connected:[...pairing.sessions.values()].map(s=>({id:s.id,name:s.name})),report};}; + const trusted=event=>{if(!panel||event.sender!==panel.webContents||event.senderFrame!==panel.webContents.mainFrame)throw Error('untrusted-renderer');}; + const authorized=id=>enabled&&pairing?.sessions.has(id)&&pairing.sessions.get(id).expires>Date.now()&&target&&!target.isDestroyed(); + function safeSignal(message){if(!message||!['offer','answer','disconnect','ping'].includes(message.type))throw Error('invalid-signal');if(['offer','answer'].includes(message.type)&&(typeof message.sdp!=='string'||message.sdp.length>80000))throw Error('invalid-sdp');return message;} + function stopFrames(){clearInterval(healthTimer);clearInterval(frameTimer);frameTimer=null;try{if(target&&!target.isDestroyed())target.webContents.endFrameSubscription();}catch{}} + const {focusScript}=require('./focus.cjs'); + async function apply(input,active){ + validateInput(input);if(!target||target.isDestroyed())throw Error('window-closed'); + if(target.isMinimized())throw Error('window-minimized'); + // Electron routes these events solely to its own renderer. Never use CGEvent + // or system keystrokes, and never inject into another app's focused window. + // The pairing child must not retain key-window focus over its parent. + if(panel&&!panel.isDestroyed()&&panel.isVisible())panel.hide(); + await focusOwnedWindow({app,target,active}); + if(!target.isFocused())throw Error('canvas-focus-required'); + const wc=target.webContents,[width,height]=target.getContentSize(); + const point={x:Math.min(width-1,Math.round(input.x*width)),y:Math.min(height-1,Math.round(input.y*height))}; + if(input.type==='text'){const focus=await wc.executeJavaScript(focusScript);if(!active())throw Error('session-ended');if(!focus.editable)throw Error('text-target-required');await wc.insertText(input.text);return;} + if(input.type==='pointer'){ + if(input.action==='down')selectPointer=input.button!==2&&await wc.executeJavaScript(selectPointerScript(point.x,point.y)); + if(!active()){await wc.executeJavaScript(closeSelectScript);throw Error('session-ended');} + if(!selectPointer)pointer.send(wc,{...point,type:{down:'mouseDown',up:'mouseUp',move:'mouseMove'}[input.action],button:input.button===2?'right':'left',clickCount:1,modifiers:input.drag?['leftButtonDown']:[]});if(input.action==='up'){ + selectPointer=false; + // Input dispatch is asynchronous. Read focus after the renderer has handled + // the click and settled its layout, rather than acknowledging old focus. + const focus=await wc.executeJavaScript(`new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(()=>resolve(${focusScript}))))`); + sourceSurface=focus.surface;if(!active())throw Error('session-ended');return {focus};}return;} + if(input.type==='scroll'){wc.sendInputEvent({...point,type:'mouseWheel',deltaX:-input.dx,deltaY:-input.dy,canScroll:true});return;} + const keyCode={Enter:'Return',ArrowLeft:'Left',ArrowRight:'Right',ArrowUp:'Up',ArrowDown:'Down'}[input.key]||input.key; + wc.sendInputEvent({type:'keyDown',keyCode,modifiers:input.modifiers});wc.sendInputEvent({type:'keyUp',keyCode,modifiers:input.modifiers}); + } + function reply(res,status,body){res.writeHead(status,{'Content-Type':'application/json','Cache-Control':'no-store','X-Content-Type-Options':'nosniff'});res.end(JSON.stringify(body));} + async function serve(req,res){ + if(!enabled||!privateAddress(req.socket.remoteAddress)||req.headers.origin||req.method!=='POST'||!['/pair/start','/pair/finish','/channel'].includes(req.url)){reply(res,403,{error:'forbidden'});return;} + if(!/^application\/json(?:;|$)/i.test(req.headers['content-type']||'')){reply(res,415,{error:'json-required'});return;} + try{let size=0,chunks=[];for await(const chunk of req){size+=chunk.length;if(size>131072)throw Error('too-large');chunks.push(chunk);}const body=JSON.parse(Buffer.concat(chunks).toString());let result; + if(req.url==='/pair/start')result=pairing.start(body,req.socket.remoteAddress); + else if(req.url==='/pair/finish')result=pairing.finish(body,req.socket.remoteAddress); + else result=pairing.receive(body,req.socket.remoteAddress,(s,message)=>{ + if(message.type==='poll')return {messages:s.queue.splice(0),source:target.getTitle()}; + if(message.type==='signal'){events({type:'signal',id:s.id,message:safeSignal(message.message)});return {ok:true};} + if(message.type==='revoke'){pairing.rotate();events({type:'state'});return {ok:true};}throw Error('unsupported-operation'); + });reply(res,200,result); + }catch{if(!res.headersSent)reply(res,403,{error:'request-rejected'});} + } + function bindIPC(){ + if(bound)return;bound=true; + ipcMain.handle('link:input-session',(event,id,token)=>{trusted(event);if(token===null){epochs.delete(id);pointer.release();selectPointer=false;target?.webContents.executeJavaScript(closeSelectScript).catch(()=>{});}else{if(!authorized(id))throw Error('not-authorized');pointer.release();epochs.set(id,token);queues.delete(id);}return true;}); + ipcMain.handle('link:capture-fallback',(event,reason)=>{trusted(event);captureMode='snapshot-fallback';captureError=String(reason||'').replace(/[^A-Za-z]/g,'').slice(0,60);startFallback();return true;}); + ipcMain.on('link:frame-consumed',(event,sequence)=>{trusted(event);if(sequence===pendingFrame)pendingFrame=0;}); + ipcMain.handle('link:glasses',async(event,command)=>{trusted(event);if(!glasses||!['state','begin-pairing','cancel-pairing'].includes(command))throw Error('unsupported-glasses-command');return command==='state'?glasses.state():glasses.command({type:command});}); + ipcMain.handle('link:state',event=>{trusted(event);return state();}); + ipcMain.handle('link:permissions',event=>{trusted(event);return {platform:process.platform,embedded:true};}); + ipcMain.handle('link:settings',event=>{trusted(event);throw Error('not-required-for-owned-window');}); + ipcMain.handle('link:sources',event=>{trusted(event);return [source()];}); + ipcMain.handle('link:select',(event,id)=>{trusted(event);if(id!==source().id)throw Error('invalid-source');return state();}); + ipcMain.handle('link:rotate',event=>{trusted(event);pairing.rotate();return state();}); + ipcMain.handle('link:signal',(event,id,message)=>{trusted(event);if(!authorized(id))throw Error('not-authorized');pairing.queue(id,safeSignal(message));}); + ipcMain.handle('link:input',(event,id,input)=>{ + trusted(event);if(!authorized(id))throw Error('not-authorized');if(typeof input?.id!=='string'||!/^[A-Za-z0-9-]{1,80}$/.test(input.id))throw Error('invalid-input-id'); + let q=queues.get(id);if(!q){const active=()=>authorized(id)&&epochs.get(id)===input.epoch;q=new InputQueue({active,apply:value=>apply(value,active),reply:result=>{const resolve=q.waiters.get(result.id);if(resolve){q.waiters.delete(result.id);resolve(result);}}});q.waiters=new Map();queues.set(id,q);} + if(q.waiters.has(input.id))throw Error('input-in-progress');return new Promise(resolve=>{q.waiters.set(input.id,resolve);q.receive(input);}); + }); + ipcMain.on('link:report',(event,value)=>{ + if(!panel||panel.isDestroyed())return; + trusted(event);if(!value||typeof value.phase!=='string')return; + report={phase:value.phase.slice(0,80),inputs:Number(value.inputs)||0,error:String(value.error||'').slice(0,200)}; + }); + } + async function open(){ + if(enabled)return; + target=ownWindow(); + if(!target||target.isDestroyed())throw Error('canvas-window-unavailable'); + fs.mkdirSync(runtime(),{recursive:true,mode:0o700}); + pairing=new Pairing({onPair:s=>{events({type:'paired',id:s.id});},onRevoke:id=>{epochs.delete(id);queues.delete(id);pointer.release();closeRemoteSelect();events({type:'revoke',id});}}); + server=http.createServer(serve);server.requestTimeout=10000;server.headersTimeout=5000;server.maxConnections=32; + try{await new Promise((resolve,reject)=>{server.once('error',reject);server.listen(Number(option('--canvaslink-port')||3499),'0.0.0.0',resolve);});}catch(error){server.close();throw error;} + try{ + port=server.address().port;enabled=true;captureMode='tab';captureError='';lastFrame=0;pendingFrame=0;bindIPC(); + const captureSession=session.fromPartition('canvaslink-owned-capture'); + panel=new BrowserWindow({width:600,height:700,show:false,parent:target,title:'CanvasTTY · Телефон / планшет',webPreferences:{session:captureSession,preload:path.join(__dirname,'preload.cjs'),contextIsolation:true,sandbox:true,nodeIntegration:false,backgroundThrottling:false}}); + // Older Electron reports display capture as media with an empty mediaTypes + // array. Actual camera/microphone requests remain denied in this own session. + captureSession.setPermissionCheckHandler((contents,permission)=>contents===panel?.webContents&&permission==='display-capture'); + captureSession.setPermissionRequestHandler((contents,permission,callback,details)=>callback(contents===panel?.webContents&&(permission==='display-capture'||permission==='media'&&Array.isArray(details.mediaTypes)&&details.mediaTypes.length===0))); + captureSession.setDisplayMediaRequestHandler((request,callback)=>{try{if(!enabled||request.frame!==panel.webContents.mainFrame||target.isDestroyed())return callback({});callback({video:target.webContents.mainFrame});}catch{callback({});}}); + panel.webContents.setWindowOpenHandler(()=>({action:'deny'}));panel.webContents.on('will-navigate',e=>e.preventDefault()); + panel.on('close',event=>{if(enabled){event.preventDefault();panel.hide();}}); + bonjour=publish({name:`CanvasLink ${os.hostname().split('.')[0]}`,port,ipv4:addresses()[0]||'',embedded:true}); + await panel.loadFile(path.join(__dirname,'index.html')); + startFallback=()=>{if(frameTimer)return;let busy=false; + const frame=image=>{ + const now=Date.now();if(!enabled||!pairing.sessions.size||pendingFrame||now-lastFrame<100||image.isEmpty())return;lastFrame=now; + const encoded=createSnapshot(image,nativeImage);if(!encoded)return; + captureQuality={format:encoded.format,width:encoded.width,height:encoded.height,nativeScale:encoded.scaleFactor,bytes:encoded.pixels.length,encodeMs:Date.now()-now}; + pendingFrame=++frameSequence;events({type:'frame',sequence:pendingFrame,width:encoded.width,height:encoded.height,pixels:encoded.pixels}); + }; + target.webContents.beginFrameSubscription(false,frame); + frameTimer=setInterval(async()=>{if(busy||pendingFrame||!pairing.sessions.size||Date.now()-lastFrame<100||target.isDestroyed())return;busy=true;try{frame(await target.webContents.capturePage(undefined,{stayHidden:true,stayAwake:false}));}catch{}finally{busy=false;}},100); + }; + healthTimer=setInterval(async()=>{if(healthBusy||!pairing.sessions.size||target.isDestroyed()||sourceSurface!=='settings'&&Date.now()-lastHealth<1000)return;healthBusy=true;lastHealth=Date.now();try{const focus=await target.webContents.executeJavaScript(focusScript);sourceSurface=focus.surface;events({type:'health',value:{capture:captureMode==='tab'?(target.webContents.isBeingCaptured()?'active':'stalled'):(Date.now()-lastFrame<3000?'active':'stalled'),windowFocused:target.isFocused(),target:focus.kind,surface:focus.surface,viewport:focus.viewport,terminalView:focus.terminalView,dragRects:focus.dragRects,rect:focus.rect,caret:focus.caret}});}catch{}finally{healthBusy=false;}},200); + target.removeListener('closed',disable);target.once('closed',disable); + }catch(error){disable();throw error;} + } + function disable(){enabled=false;pairing?.rotate();pointer.release();closeRemoteSelect();stopFrames();bonjour?.destroy();server?.close();if(panel&&!panel.isDestroyed())panel.destroy();queues.clear();epochs.clear();} + app.on('before-quit',()=>{disable();}); + return {open,disable}; +} +module.exports={install}; diff --git a/integrations/canvaslink/host/focus.cjs b/integrations/canvaslink/host/focus.cjs new file mode 100644 index 000000000..5dda9e91b --- /dev/null +++ b/integrations/canvaslink/host/focus.cjs @@ -0,0 +1,18 @@ +// Geometry only; no field values or surrounding text cross the input channel. +function readFocus(){ + const e=document.activeElement,panel=document.querySelector('.settings-panel--open'); + const editable=!!e&&!e.disabled&&!e.readOnly&&(e.tagName==='TEXTAREA'||e.tagName==='INPUT'&&['text','search','email','url','tel','password','number'].includes(e.type)||e.isContentEditable); + const kind=e?.classList.contains('xterm-helper-textarea')?'terminal':editable?'text-field':e?.matches('input,select,button,[role=button],[role=slider]')?'control':'canvas'; + const node=kind==='terminal'?e.closest('.xterm')?.querySelector('.xterm-screen'):editable?e:null; + const r=node?.getBoundingClientRect(),c=kind==='terminal'?e.getBoundingClientRect():null; + const normalized=b=>b&&b.width>0&&b.height>0?{x:b.x/innerWidth,y:b.y/innerHeight,width:b.width/innerWidth,height:b.height/innerHeight}:null; + const clipped=e=>{const r=e.getBoundingClientRect();let x=Math.max(0,r.left),y=Math.max(0,r.top),right=Math.min(innerWidth,r.right),bottom=Math.min(innerHeight,r.bottom);for(let p=e.parentElement;p;p=p.parentElement){const s=getComputedStyle(p);if(/hidden|auto|scroll|clip/.test(s.overflow+s.overflowX+s.overflowY)){const b=p.getBoundingClientRect();x=Math.max(x,b.left);y=Math.max(y,b.top);right=Math.min(right,b.right);bottom=Math.min(bottom,b.bottom);}}return normalized({x,y,width:right-x,height:bottom-y});}; + const dragRects=panel?[...panel.querySelectorAll('input[type=range]')].filter(e=>!e.disabled).map(clipped).filter(Boolean):[]; + const screens=panel?[]:[...document.querySelectorAll('.xterm-screen')].filter(node=>clipped(node)); + const selected=screens.filter(node=>node.closest('.terminal-card--selected')); + const candidate=selected.length===1?selected[0]:screens.length===1?screens[0]:null; + const terminalView=candidate?{rect:clipped(candidate),viewport:{width:innerWidth,height:innerHeight}}:null; + const context=kind==='text-field'?e.closest('.api-profile-row,.provider-secret,.setting-group'):null; + return {editable,kind,terminalView,surface:panel?'settings':'canvas',viewport:{width:innerWidth,height:innerHeight},dragRects,rect:normalized(r),contextRect:context?clipped(context):null,fontSize:editable?parseFloat(getComputedStyle(e).fontSize)||14:null,caret:r&&c&&c.x>=r.x-2&&c.y>=r.y-2&&c.right<=r.right+2&&c.bottom<=r.bottom+2?normalized(c):null}; +} +module.exports={focusScript:'('+readFocus.toString()+')()'}; diff --git a/integrations/canvaslink/host/frames.js b/integrations/canvaslink/host/frames.js new file mode 100644 index 000000000..de05b4467 --- /dev/null +++ b/integrations/canvaslink/host/frames.js @@ -0,0 +1,11 @@ +// NativeImage pixels are BGRA with premultiplied alpha; ImageData expects RGBA. +// The main process waits for frameConsumed before sending another full frame. +(function(root){ + function rgba(pixels,width,height){ + if(!ArrayBuffer.isView(pixels)||pixels.byteLength!==width*height*4||width<1||height<1||width>2560||height>1600)throw Error('invalid-bitmap-frame'); + const data=new Uint8ClampedArray(pixels); + for(let i=0;i + +CanvasLink + +

+
C↗

CanvasLink

Рабочее окно. На расстоянии касания.

LOCAL / 01
+
01 — ПОДКЛЮЧЕНИЕ ANDROID
··· ···
Подготовка…
+
02 — ОКНО ДЛЯ ПЕРЕДАЧИ

На macOS при первом запуске потребуется разрешение на запись экрана. Передаётся выбранное окно CanvasTTY.

+
03 — ПРЯМОЕ СОЕДИНЕНИЕ

Откройте CanvasLink на Android, выберите компьютер и введите код. Оба устройства должны быть в одной локальной сети.

+
Ожидание планшета
+ + +
CANVASTTY → ANDROIDЛокально · Без облака
+
diff --git a/integrations/canvaslink/host/input-queue.cjs b/integrations/canvaslink/host/input-queue.cjs new file mode 100644 index 000000000..959aeebdd --- /dev/null +++ b/integrations/canvaslink/host/input-queue.cjs @@ -0,0 +1,23 @@ +const {validateInput}=require('./native-input.cjs'); +// One peer owns one ordered queue. Keep IDs for the whole peer lifetime: an old +// text/Enter request can never execute twice, even after its ACK was lost. +class InputQueue { + constructor({apply,active,reply,limit=64,maxIds=256}){Object.assign(this,{apply,active,reply,limit,maxIds});this.tail=Promise.resolve();this.ids=new Map();this.pending=0;this.highWater=0;} + receive(value){ + const id=value?.id; + if(typeof id!=='string'||!/^[A-Za-z0-9-]{1,80}$/.test(id))return; + const previous=this.ids.get(id);if(previous){if(previous.result)this.reply(previous.result);return;} + const respond=result=>{this.reply(result);}; + if(!Number.isSafeInteger(value.seq)||value.seq<=this.highWater){respond({type:'input-error',id,error:'input-expired'});return;} + if(!this.active()||this.pending>=this.limit){respond({type:'input-error',id,error:'input-busy'});return;} + try{validateInput(value);}catch(error){respond({type:'input-error',id,error:error.message});return;} + this.highWater=value.seq;const entry={};this.ids.set(id,entry);this.pending++; + for(const [oldId,old] of this.ids){if(this.ids.size<=this.maxIds)break;if(old.result)this.ids.delete(oldId);} + this.tail=this.tail.catch(()=>{}).then(async()=>{ + try{if(!this.active())throw Error('session-ended');const result=await this.apply(value);entry.result={type:'input-ok',id,...(result?.focus?{focus:result.focus}:{})};} + catch(error){entry.result={type:'input-error',id,error:error.message||'input-rejected'};} + finally{this.pending--;if(this.active())respond(entry.result);} + }); + } +} +module.exports={InputQueue}; diff --git a/integrations/canvaslink/host/native-input.cjs b/integrations/canvaslink/host/native-input.cjs new file mode 100644 index 000000000..49bbccc16 --- /dev/null +++ b/integrations/canvaslink/host/native-input.cjs @@ -0,0 +1,11 @@ +const KEYS = new Set(['Enter', 'Tab', 'Escape', 'Backspace', 'Delete', 'ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Home', 'End', 'PageUp', 'PageDown', 'c', 'a', 'v', 'z', 'd', 'l']); +function validateInput(e) { + if (!e || !['pointer', 'scroll', 'key', 'text'].includes(e.type)) throw Error('invalid-input'); + if (['pointer', 'scroll'].includes(e.type) && (![e.x, e.y].every(n => Number.isFinite(n) && n >= 0 && n <= 1))) throw Error('invalid-coordinates'); + if (e.type === 'pointer' && !['down', 'up', 'move'].includes(e.action)) throw Error('invalid-pointer'); + if (e.type === 'pointer' && ![0, 2].includes(e.button)) throw Error('invalid-button'); + if (e.type === 'scroll' && (![e.dx, e.dy].every(n => Number.isFinite(n) && Math.abs(n) <= 2000))) throw Error('invalid-scroll'); + if (e.type === 'text' && (typeof e.text !== 'string' || !e.text.length || e.text.length > 8000)) throw Error('invalid-text'); + if (e.type === 'key' && (!Array.isArray(e.modifiers) || !(KEYS.has(e.key)||(/^[a-z]$/.test(e.key)&&e.modifiers.some(m=>m==='control'||m==='meta'))) || e.modifiers.length > 4 || e.modifiers.some(m => !['control', 'shift', 'alt', 'meta'].includes(m)))) throw Error('invalid-key'); +} +module.exports = { validateInput }; diff --git a/integrations/canvaslink/host/owned-focus.cjs b/integrations/canvaslink/host/owned-focus.cjs new file mode 100644 index 000000000..358f1a5a5 --- /dev/null +++ b/integrations/canvaslink/host/owned-focus.cjs @@ -0,0 +1,18 @@ +// Activation is asynchronous on macOS. Wait only for our own window; never send +// system keys or continue a revoked input after waiting for activation. +async function focusOwnedWindow({app,target,active,timeout=1000,now=Date.now,wait=ms=>new Promise(resolve=>setTimeout(resolve,ms))}){ + const check=()=>{if(!active())throw Error('session-ended');if(target.isDestroyed())throw Error('window-closed');if(target.isMinimized())throw Error('window-minimized');}; + check(); + const request=()=>{app.focus({steal:true});target.show();target.focus();}; + if(!target.isFocused())request(); + const deadline=now()+timeout;let retryAt=now()+100; + while(!target.isFocused()){ + check();if(now()>=deadline)throw Error('canvas-focus-required'); + // Hiding the pairing child may asynchronously resign the app's key window + // after the first activation request. Reassert only this own window, bounded. + if(now()>=retryAt){request();retryAt=now()+100;} + await wait(20); + } + check();target.webContents.focus(); +} +module.exports={focusOwnedWindow}; diff --git a/integrations/canvaslink/host/owned-pointer.cjs b/integrations/canvaslink/host/owned-pointer.cjs new file mode 100644 index 000000000..f469c481f --- /dev/null +++ b/integrations/canvaslink/host/owned-pointer.cjs @@ -0,0 +1,19 @@ +// Only the owned renderer receives events; cancellation never activates a window. +class OwnedPointer { + constructor(){this.held=new Map();} + send(contents,event){ + contents.sendInputEvent(event); + if(event.type==='mouseDown')this.held.set(event.button,{contents,event}); + else if(event.type==='mouseUp')this.held.delete(event.button); + else if(event.type==='mouseMove')for(const state of this.held.values()){ + if(state.contents===contents)state.event={...state.event,x:event.x,y:event.y}; + } + } + release(){ + const held=[...this.held.values()];this.held.clear(); + for(const {contents,event} of held){ + try{if(!contents.isDestroyed())contents.sendInputEvent({...event,type:'mouseUp',modifiers:[],clickCount:1});}catch{} + } + } +} +module.exports={OwnedPointer}; diff --git a/integrations/canvaslink/host/preload.cjs b/integrations/canvaslink/host/preload.cjs new file mode 100644 index 000000000..a96acbd9e --- /dev/null +++ b/integrations/canvaslink/host/preload.cjs @@ -0,0 +1,17 @@ +const { contextBridge, ipcRenderer } = require('electron'); +contextBridge.exposeInMainWorld('host', { + frameConsumed:sequence=>ipcRenderer.send('link:frame-consumed',sequence), + captureFallback:reason=>ipcRenderer.invoke('link:capture-fallback',reason), + glasses: command => ipcRenderer.invoke('link:glasses',command), + state: () => ipcRenderer.invoke('link:state'), + permissions: () => ipcRenderer.invoke('link:permissions'), + settings: kind => ipcRenderer.invoke('link:settings',kind), + sources: () => ipcRenderer.invoke('link:sources'), + select: id => ipcRenderer.invoke('link:select', id), + rotate: () => ipcRenderer.invoke('link:rotate'), + signal: (id, message) => ipcRenderer.invoke('link:signal', id, message), + inputSession: (id, token) => ipcRenderer.invoke('link:input-session',id,token), + input: (id, event) => ipcRenderer.invoke('link:input', id, event), + report: report => ipcRenderer.send('link:report', report), + onEvent: fn => { const listener = (_event, value) => fn(value); ipcRenderer.on('link:event', listener); return () => ipcRenderer.removeListener('link:event', listener); } +}); diff --git a/integrations/canvaslink/host/remote-select.cjs b/integrations/canvaslink/host/remote-select.cjs new file mode 100644 index 000000000..ce5737428 --- /dev/null +++ b/integrations/canvaslink/host/remote-select.cjs @@ -0,0 +1,30 @@ +// Native OS select popups are outside an owned WebContents stream. Open a +// renderer-local list only for an authenticated remote pointer on a select. +function selectAtPoint(x,y){ + const select=document.elementFromPoint(x,y)?.closest('select'); + if(!select||select.disabled||select.multiple||select.size>1)return false; + window.__canvasLinkSelect?.close(); + const box=document.createElement('div'),r=select.getBoundingClientRect(),style=getComputedStyle(select); + box.setAttribute('role','listbox');box.setAttribute('aria-label',select.getAttribute('aria-label')||select.labels?.[0]?.textContent||'Выбор значения'); + box.dataset.canvaslinkSelect=''; + Object.assign(box.style,{position:'fixed',zIndex:'2147483646',boxSizing:'border-box',width:Math.min(innerWidth-16,Math.max(220,r.width))+'px',maxHeight:Math.min(320,innerHeight-16)+'px',overflowY:'auto',padding:'6px',border:'1px solid #626773',borderRadius:'10px',background:'#20232b',color:'#f3f4f6',boxShadow:'0 8px 32px #0008',font:style.font}); + const entries=[...select.options].map((option,index)=>({option,index,disabled:option.disabled||option.parentElement?.tagName==='OPTGROUP'&&option.parentElement.disabled})); + let current=select.selectedIndex,closed=false; + const close=(restore=true)=>{if(closed)return;closed=true;box.remove();document.removeEventListener('pointerdown',outside,true);document.removeEventListener('keydown',keys,true);window.removeEventListener('resize',dismiss);if(window.__canvasLinkSelect?.box===box)delete window.__canvasLinkSelect;if(restore&&select.isConnected)select.focus({preventScroll:true});}; + const dismiss=()=>close(false); + const choose=index=>{if(entries[index]?.disabled)return;select.selectedIndex=index;select.dispatchEvent(new Event('input',{bubbles:true}));select.dispatchEvent(new Event('change',{bubbles:true}));close();}; + const paint=()=>{for(const button of box.children){const active=Number(button.dataset.index)===current;button.setAttribute('aria-selected',String(active));button.style.background=active?'#485660':'transparent';}box.querySelector('[aria-selected=true]')?.scrollIntoView({block:'nearest'});}; + const outside=event=>{if(!box.contains(event.target))close(false);}; + const keys=event=>{ + if(!['ArrowDown','ArrowUp','Home','End','Enter','Escape','Tab'].includes(event.key))return; + if(event.key==='Tab'){close();return;} + event.preventDefault();event.stopImmediatePropagation(); + if(event.key==='Escape'){close();return;}if(event.key==='Enter'){choose(current);return;} + const available=entries.filter(e=>!e.disabled).map(e=>e.index);if(!available.length)return; + const at=available.indexOf(current);current=event.key==='Home'?available[0]:event.key==='End'?available.at(-1):available[Math.max(0,Math.min(available.length-1,at+(event.key==='ArrowDown'?1:-1)))];paint(); + }; + for(const entry of entries){const button=document.createElement('button');button.type='button';button.setAttribute('role','option');button.dataset.index=String(entry.index);button.textContent=entry.option.label;button.disabled=entry.disabled;Object.assign(button.style,{display:'block',width:'100%',minHeight:'40px',padding:'9px 12px',border:'0',borderRadius:'6px',textAlign:'left',color:entry.disabled?'#858a94':'inherit',font:'inherit',cursor:entry.disabled?'default':'pointer',whiteSpace:'normal'});button.addEventListener('click',()=>choose(entry.index));box.append(button);} + document.body.append(box);const b=box.getBoundingClientRect();box.style.left=Math.max(8,Math.min(r.left,innerWidth-b.width-8))+'px';box.style.top=Math.max(8,r.bottom+b.height+8<=innerHeight?r.bottom+4:r.top-b.height-4)+'px'; + document.addEventListener('pointerdown',outside,true);document.addEventListener('keydown',keys,true);window.addEventListener('resize',dismiss);window.__canvasLinkSelect={box,close};select.focus({preventScroll:true});paint();return true; +} +module.exports={selectPointerScript:(x,y)=>'('+selectAtPoint.toString()+')('+JSON.stringify(x)+','+JSON.stringify(y)+')',closeSelectScript:'window.__canvasLinkSelect?.close(false)'}; diff --git a/integrations/canvaslink/host/renderer.js b/integrations/canvaslink/host/renderer.js new file mode 100644 index 000000000..87fcd8dac --- /dev/null +++ b/integrations/canvaslink/host/renderer.js @@ -0,0 +1,162 @@ +const $ = id => document.getElementById(id); +let state, stream, peer, channel, activeId, inputs = 0, generation = 0, firstFrameTimer; +let fixtureText = '', fixtureClicks = 0, fixtureKeys = [], fixturePointer = { x: .5, y: .5 }; +function report(phase, error = '') { window.host.report({ phase, inputs, error }); } +async function refreshState() { + state = await window.host.state(); + if(state.embedded){$('source-section').hidden=true;$('mac-permissions').hidden=true;$('device-tabs').hidden=!state.glassesSupported;} + $('pin').textContent = state.pin ? state.pin.slice(0, 3) + ' ' + state.pin.slice(3) : 'СВЯЗАНО'; + $('addresses').textContent = state.addresses.map(ip => `${ip}:${state.port}`).join('\n') || 'Нет локального IPv4-адреса'; + $('status').textContent = state.connected.length ? `Подключено: ${state.connected[0].name} · ${state.source || 'выберите окно'}` : 'Ожидание планшета'; +} +function closePeer() { + clearTimeout(firstFrameTimer); + if(activeId)window.host.inputSession(activeId,null).catch(()=>{}); + generation++; activeId = null; channel?.close(); peer?.close(); stream?.getTracks().forEach(t => t.stop()); + channel = null; peer = null; stream = null; +} +const iceReady = pc => new Promise((resolve, reject) => { + if (pc.iceGatheringState === 'complete') return resolve(); + const timer = setTimeout(() => { pc.removeEventListener('icegatheringstatechange', listener); reject(Error('Не удалось подготовить локальное соединение')); }, 12000); + function listener() { if (pc.iceGatheringState === 'complete') { clearTimeout(timer); pc.removeEventListener('icegatheringstatechange', listener); resolve(); } } + pc.addEventListener('icegatheringstatechange', listener); +}); +async function start(id) { + closePeer(); activeId = id; + const token=generation; + let captured; + const current=()=>token===generation&&activeId===id; + $('error').textContent = ''; + try { + if (!state.source) return; + if (state.embedded) { + try{if(['jpeg-fallback','snapshot-fallback'].includes(state.captureMode))throw Error('SnapshotCapture');captured=await navigator.mediaDevices.getDisplayMedia({video:{width:{max:2560},height:{max:1600},frameRate:{ideal:30,max:30}},audio:false});} + catch(error){if(!current())return;const canvas=$('fixture');canvas.width=state.width;canvas.height=state.height;captured=canvas.captureStream(30);await window.host.captureFallback(error.name);} + } else if (state.fixture) captured = $('fixture').captureStream(30); + else captured = await navigator.mediaDevices.getDisplayMedia({ video: { frameRate: 30 }, audio: false }); + if (!current()) { captured.getTracks().forEach(t => t.stop()); return; } + stream=captured; + const pc = peer = new RTCPeerConnection({ iceServers: [] }); + const track = stream.getVideoTracks()[0]; track.contentHint = 'detail'; + const sender = pc.addTrack(track, stream); + const codecs = RTCRtpSender.getCapabilities('video')?.codecs || []; + const h264 = codecs.filter(c => c.mimeType.toLowerCase() === 'video/h264'); + const transceiver = pc.getTransceivers()[0]; + if (h264.length) transceiver.setCodecPreferences([...h264, ...codecs.filter(c => !h264.includes(c))]); + const dc = channel = pc.createDataChannel('input', { ordered: true });let receivedVideoFrames; + await window.host.inputSession(id,String(token)); + if(!current())return; + dc.onmessage = message => { + if (typeof message.data !== 'string' || message.data.length > 40000 || !current()) return; + let value; try { value = JSON.parse(message.data); } catch { return; } + if (value.type === 'ping') { if(Number.isSafeInteger(value.videoFrames)&&value.videoFrames>=0)receivedVideoFrames=value.videoFrames;if (dc.readyState === 'open') dc.send(JSON.stringify({ type: 'pong', at: value.at })); return; } + if (value.type === 'stats') { if (state.fixture && dc.readyState === 'open') dc.send(JSON.stringify({ type: 'fixture-state', ...(typeof value.requestId==='string'&&value.requestId.length<=80?{requestId:value.requestId}:{}), clicks: fixtureClicks, text: fixtureText, keys: fixtureKeys })); return; } + if(typeof value.id!=='string')return; + // Main owns validation, deduplication and cancellation of queued events. + window.host.input(id, {...value,epoch:String(token)}).then(result=>{ + if(token!==generation||activeId!==id)return; + if(result.type==='input-ok'){inputs++;report('streaming');} + else{$('error').textContent=result.error;report('input-error',result.error);} + if(dc.readyState==='open')dc.send(JSON.stringify(result)); + }).catch(error=>{if(token===generation&&dc.readyState==='open')dc.send(JSON.stringify({type:'input-error',id:value.id,error:error.message}));}); + }; + dc.onopen = () => { + if(!current())return; $('status').textContent = 'Видео и управление подключены'; report('streaming'); + // Some owned-tab captures produce no initial frame when the window is + // unchanged or occluded. A connected data channel alone is not video. + if(state.embedded)firstFrameTimer=setTimeout(async()=>{ + let fallback; + try{ + if(!current())return; + const stats=await pc.getStats();let sent=0; + stats.forEach(s=>{if(s.type==='outbound-rtp'&&(s.kind==='video'||s.mediaType==='video'))sent+=s.framesEncoded||s.framesSent||0;}); + if((sent&&receivedVideoFrames!==0)||!current())return; + const canvas=$('fixture');canvas.width=state.width;canvas.height=state.height; + fallback=canvas.captureStream(10); + await window.host.captureFallback('NoInitialVideo'); + if(!current()){fallback.getTracks().forEach(t=>t.stop());return;} + const replacement=fallback.getVideoTracks()[0];replacement.contentHint='detail'; + await sender.replaceTrack(replacement); + if(!current()){fallback.getTracks().forEach(t=>t.stop());return;} + const previous=stream;stream=fallback; + previous.getTracks().forEach(t=>{t.onended=null;t.stop();}); + replacement.onended=()=>{if(current()){window.host.signal(id,{type:'disconnect'});closePeer();report('capture-ended');}}; + }catch(error){fallback?.getTracks().forEach(t=>t.stop());if(current()){closePeer();report('capture-error',error.message);}} + },5000); + }; + dc.onclose = () => { if(token===generation&&activeId===id){closePeer();report('disconnected');} }; + pc.onconnectionstatechange = () => { if (['failed', 'disconnected'].includes(pc.connectionState)&&token===generation&&activeId===id) { const phase=pc.connectionState; $('status').textContent = 'Соединение прервано — переподключение с планшета'; closePeer();report(phase); } }; + track.onended = () => { if (current()) { window.host.signal(id, { type: 'disconnect' }); closePeer(); report('capture-ended'); } }; + await pc.setLocalDescription(await pc.createOffer()); + if(!current())return; + await iceReady(pc); + if(!current())return; + await window.host.signal(id, { type: 'offer', sdp: pc.localDescription.sdp }); + if(!current())return; + const params = sender.getParameters(); + if (params.encodings?.length) { params.encodings[0].maxBitrate = 18000000; params.degradationPreference='maintain-resolution'; await sender.setParameters(params); } + if(current())report('offered'); + } catch (error) { if(!current()){captured?.getTracks().forEach(t=>t.stop());return;} $('error').textContent = error.message; report('capture-error', error.message); closePeer(); } +} +$('refresh').onclick = async () => { + try { + $('error').textContent = ''; const sources = await window.host.sources(); $('source').replaceChildren(); + for (const s of sources) { const option = document.createElement('option'); option.value = s.id; option.textContent = s.name; $('source').append(option); } + if (!sources.length) throw Error('Окно CanvasTTY не найдено. Проверьте разрешение на запись экрана.'); + await window.host.select(sources[0].id); await refreshState(); + } catch (error) { $('error').textContent = error.message; } +}; +$('source').onchange = async () => { await window.host.select($('source').value); await refreshState(); }; +$('rotate').onclick = async () => { closePeer(); await window.host.rotate(); await refreshState(); report('waiting'); }; +$('screen-settings').onclick=()=>window.host.settings('screen'); +$('input-settings').onclick=()=>window.host.settings('input'); +window.host.permissions().then(p=>{if(p.platform==='darwin'&&!p.embedded){$('mac-permissions').hidden=false;$('permission-status').textContent=`Экран: ${p.screen==='granted'?'доступ есть':'нужен доступ'}. Управление: ${p.inputAccess?'доступ есть':'нужен доступ'}.`;}}).catch(()=>{}); +window.host.onEvent(async event => { + if(event.type==='show-glasses'){$('glasses-tab').click();return;} + if(event.type==='health'){if(channel?.readyState==='open')channel.send(JSON.stringify({type:'source-state',...event.value}));return;} + if(event.type==='frame'){ + if(event.pixels){try{if(state?.embedded&&activeId){const c=$('fixture');if(c.width!==event.width||c.height!==event.height){c.width=event.width;c.height=event.height;}context.putImageData(new ImageData(window.CanvasLinkFrames.rgba(event.pixels,c.width,c.height),c.width,c.height),0,0);}}finally{window.host.frameConsumed(event.sequence);}return;} + return; + } + if (event.type === 'fixture-input') { + const e = event.input; + if (e.type === 'text') fixtureText += e.text; + if (e.type === 'key') { fixtureKeys.push(e.key); if (e.key === 'Backspace') fixtureText = [...fixtureText].slice(0, -1).join(''); } + if (e.type === 'pointer') { fixturePointer = e; if (e.action === 'down') fixtureClicks++; } + return; + } + await refreshState(); + if (event.type === 'paired' || event.type === 'source') { + if (state.connected[0] && state.source) await start(state.connected[0].id); + } + if (event.type === 'revoke' && activeId === event.id) closePeer(); + if (event.type === 'signal') { + if (event.message.type === 'answer' && peer && activeId === event.id) await peer.setRemoteDescription(event.message); + if (event.message.type === 'ping') await start(event.id); + if (event.message.type === 'disconnect' && activeId === event.id) closePeer(); + } +}); +setInterval(() => { if (state) $('expiry').textContent = state.pin ? `Действует ещё ${Math.max(0, Math.ceil((state.expires - Date.now()) / 1000))} с` : 'Код использован · связь зашифрована'; }, 1000); +const context = $('fixture').getContext('2d'); +setInterval(() => { + if (!state?.fixture) return; + const c = context; c.fillStyle = '#101a14'; c.fillRect(0, 0, 1280, 800); + c.fillStyle = '#d6ef88'; c.font = 'bold 42px monospace'; c.fillText('CanvasLink / live acceptance', 50, 80); + c.fillStyle = '#a8b6ac'; c.font = '24px monospace'; c.fillText('Android ↔ Mac · WebRTC · LAN', 50, 128); + c.fillStyle = '#eaf1e5'; c.font = '28px monospace'; + c.fillText(`Clicks: ${fixtureClicks} Keys: ${fixtureKeys.join(' ').slice(-60)}`, 50, 230); + c.fillText('Text:', 50, 310); + fixtureText.match(/.{1,65}/gu)?.slice(-6).forEach((l, i) => c.fillText(l, 50, 360 + i * 36)); + c.fillStyle = '#d6ef88'; c.beginPath(); c.arc(60 + (Date.now() % 5000) / 5, 700, 14, 0, Math.PI * 2); c.fill(); + c.strokeStyle = '#ed9272'; c.strokeRect(fixturePointer.x * 1280 - 12, fixturePointer.y * 800 - 12, 24, 24); + c.fillStyle = '#798d80'; c.font = '18px monospace'; c.fillText(new Date().toISOString(), 50, 770); +}, 33); +refreshState().then(async () => { if (state.fixture||state.embedded) await $('refresh').onclick(); report('waiting'); }); + +async function glasses(command){ + try{const s=await window.host.glasses(command);$('glasses-code').textContent=s.pairing?.code||'';$('glasses-status').textContent=s.pairing?'Код для очков действует 2 минуты. Подтверждение устройства — в настройках Even G2.':'Используются существующие настройки и разрешения Even G2.';} + catch{$('glasses-status').textContent='Сначала включите Even G2 и выберите доступные сессии в настройках CanvasTTY.';} +} +$('glasses-tab').onclick=()=>{$('phone-panel').hidden=true;$('glasses-panel').hidden=false;glasses('state');}; +$('phone-tab').onclick=()=>{$('phone-panel').hidden=false;$('glasses-panel').hidden=true;}; +$('glasses-begin').onclick=()=>glasses('begin-pairing');$('glasses-cancel').onclick=()=>glasses('cancel-pairing'); diff --git a/integrations/canvaslink/host/security.cjs b/integrations/canvaslink/host/security.cjs new file mode 100644 index 000000000..54dad7140 --- /dev/null +++ b/integrations/canvaslink/host/security.cjs @@ -0,0 +1,86 @@ +const crypto = require('node:crypto'); +const srp = require('secure-remote-password/server'); +const client = require('secure-remote-password/client'); + +const USER = 'CanvasLink/v1'; +const hex = n => crypto.randomBytes(n).toString('hex'); +function privateAddress(ip) { + ip = String(ip || '').replace(/^::ffff:/, ''); + if (ip === '::1') return true; + if (!/^\d+\.\d+\.\d+\.\d+$/.test(ip)) return false; + const a = ip.split('.').map(Number); + return a.every(v => v >= 0 && v <= 255) && + (a[0] === 127 || a[0] === 10 || (a[0] === 192 && a[1] === 168) || + (a[0] === 172 && a[1] >= 16 && a[1] <= 31) || (a[0] === 169 && a[1] === 254)); +} +function seal(key, id, seq, direction, value) { + const iv = crypto.randomBytes(12); + const cipher = crypto.createCipheriv('aes-256-gcm', key, iv); + cipher.setAAD(Buffer.from(`CanvasLink/1/${id}/${seq}/${direction}`)); + const data = Buffer.concat([cipher.update(JSON.stringify(value)), cipher.final(), cipher.getAuthTag()]); + return { id, seq, iv: iv.toString('hex'), data: data.toString('base64') }; +} +function open(key, id, packet, direction) { + if (!packet || packet.id !== id || !Number.isSafeInteger(packet.seq) || packet.seq < 1 || + !/^[0-9a-f]{24}$/.test(packet.iv) || typeof packet.data !== 'string' || packet.data.length > 131072 || + !/^[A-Za-z0-9+/]+={0,2}$/.test(packet.data)) throw Error('invalid-packet'); + const data = Buffer.from(packet.data, 'base64'); + if (data.length < 17) throw Error('invalid-packet'); + const cipher = crypto.createDecipheriv('aes-256-gcm', key, Buffer.from(packet.iv, 'hex')); + cipher.setAAD(Buffer.from(`CanvasLink/1/${id}/${packet.seq}/${direction}`)); + cipher.setAuthTag(data.subarray(-16)); + return JSON.parse(Buffer.concat([cipher.update(data.subarray(0, -16)), cipher.final()]).toString()); +} +class Pairing { + constructor({ now = Date.now, onPair = () => {}, onRevoke = () => {} } = {}) { + this.now = now; this.onPair = onPair; this.onRevoke = onRevoke; + this.sessions = new Map(); this.rotate(); + } + rotate() { + for (const id of this.sessions.keys()) this.onRevoke(id); + this.sessions.clear(); this.pending = new Map(); this.attempts = 0; this.starts = 0; + this.pin = String(crypto.randomInt(0, 1000000)).padStart(6, '0'); + this.expires = this.now() + 120000; + this.salt = client.generateSalt(); + this.verifier = client.deriveVerifier(client.derivePrivateKey(this.salt, USER, this.pin)); + } + start(body, address) { + if (this.now() >= this.expires || this.attempts >= 5 || this.starts >= 30) throw Error('pairing-closed'); + if (!body || !/^[0-9a-f]{512}$/.test(body.A) || typeof body.name !== 'string' || body.name.length > 80) + throw Error('invalid-pairing'); + if (BigInt('0x' + body.A) === 0n) throw Error('invalid-pairing'); + this.starts++; + const id = hex(16), ephemeral = srp.generateEphemeral(this.verifier); + this.pending.set(id, { ...ephemeral, A: body.A, name: body.name.replace(/[\x00-\x1f]/g, ''), address, until: this.now() + 30000 }); + return { id, salt: this.salt, B: ephemeral.public, user: USER }; + } + finish(body, address) { + const p = this.pending.get(body?.id); + if (p) this.pending.delete(body.id); + if (!p || p.address !== address || this.now() >= p.until || this.now() >= this.expires || this.attempts >= 5) + throw Error('pairing-closed'); + this.attempts++; + if (!/^[0-9a-f]{64}$/.test(body.proof)) throw Error('invalid-proof'); + const result = srp.deriveSession(p.secret, p.A, this.salt, USER, this.verifier, body.proof); + const key = crypto.createHash('sha256').update(Buffer.from(result.key, 'hex')).update('CanvasLink/channel/1').digest(); + const id = hex(16); + this.expires = 0; this.pending.clear(); this.pin = ''; + const session = { id, key, name: p.name, address, incoming: 0, outgoing: 0, expires: this.now() + 12 * 3600000, queue: [] }; + this.sessions.set(id, session); this.onPair(session); + return { id, proof: result.proof }; + } + receive(packet, address, handle) { + const session = this.sessions.get(packet?.id); + if (!session || session.expires <= this.now() || session.address !== address || packet.seq <= session.incoming) + throw Error('unauthorized'); + const value = open(session.key, session.id, packet, 'request'); + session.incoming = packet.seq; // Authenticated requests are consumed once, including rejected operations. + const result = handle(session, value); + return seal(session.key, session.id, packet.seq, 'response', result); + } + queue(id, message) { + const session = this.sessions.get(id); + if (session && session.queue.length < 16) session.queue.push(message); + } +} +module.exports = { USER, hex, privateAddress, seal, open, Pairing }; diff --git a/integrations/canvaslink/host/settings-host.cjs b/integrations/canvaslink/host/settings-host.cjs new file mode 100644 index 000000000..2a574855f --- /dev/null +++ b/integrations/canvaslink/host/settings-host.cjs @@ -0,0 +1,11 @@ +// The desktop settings API is available only to the owning CanvasTTY main frame. +function installSettingsHost({ipcMain,getWindow,snapshot,command}){ + const trusted=event=>{const target=getWindow();if(!target||target.isDestroyed()||event.sender!==target.webContents||event.senderFrame!==target.webContents.mainFrame)throw Error('untrusted-settings-renderer');}; + let pending=Promise.resolve(); + ipcMain.handle('canvaslink-settings:state',event=>{trusted(event);return snapshot();}); + ipcMain.handle('canvaslink-settings:command',(event,action)=>{ + trusted(event);if(!['enable','disable','new-code','revoke'].includes(action))throw Error('invalid-settings-command'); + const work=pending.then(()=>{trusted(event);return command(action);});pending=work.catch(()=>{});return work; + }); +} +module.exports={installSettingsHost}; diff --git a/integrations/canvaslink/host/settings.css b/integrations/canvaslink/host/settings.css new file mode 100644 index 000000000..59d6dd33c --- /dev/null +++ b/integrations/canvaslink/host/settings.css @@ -0,0 +1,9 @@ +.canvaslink-settings{max-width:54rem;color:var(--text);line-height:1.5} +.canvaslink-settings__intro{padding:.3rem 0 1.6rem}.canvaslink-settings__eyebrow{font:600 .7rem var(--font-mono,monospace);letter-spacing:.1em;color:var(--primary)} +.canvaslink-settings h3{font-size:1.05em;margin:0 0 .4rem}.canvaslink-settings__intro h3{font-size:1.55em;letter-spacing:-.025em}.canvaslink-settings p{margin:.4rem 0;color:var(--text-muted-on-dark)} +.canvaslink-settings__card{padding:1.4rem;margin-bottom:1rem;border:1px solid color-mix(in srgb,var(--text) 13%,transparent);border-radius:1rem;background:color-mix(in srgb,var(--surface-soft) 65%,var(--surface))} +.canvaslink-settings__row{display:flex;gap:1.2rem;align-items:center;justify-content:space-between}.canvaslink-settings__status{font-size:.85em}.canvaslink-settings__connection{margin-top:1.2rem;border-top:1px solid color-mix(in srgb,var(--text) 10%,transparent);padding-top:.8rem} +.canvaslink-settings ol{padding-left:1.3rem;font-size:.9em;color:var(--text-muted-on-dark)}.canvaslink-settings li+li{margin-top:.45rem}.canvaslink-settings__code-block{display:flex;flex-direction:column;align-items:flex-start;gap:.35rem;margin:1.2rem 0}.canvaslink-settings__code-block>span,.canvaslink-settings small{font-size:.8em;color:var(--text-muted-on-dark)} +.canvaslink-settings__code{font:600 clamp(1.8rem,4vw,2.8rem)/1.3 var(--font-mono,monospace);letter-spacing:.14em;color:var(--text);font-variant-numeric:tabular-nums}.canvaslink-settings__button{min-height:48px;padding:.65rem 1rem;border:1px solid color-mix(in srgb,var(--text) 25%,transparent);border-radius:.65rem;color:var(--text);background:transparent;font:inherit;font-size:.85em;cursor:pointer}.canvaslink-settings__button:hover{background:color-mix(in srgb,var(--text) 8%,transparent)}.canvaslink-settings__button--primary{border-color:var(--primary);background:var(--primary);color:var(--text-dark)}.canvaslink-settings__button--primary:hover{background:var(--primary-hover)}.canvaslink-settings__button:focus-visible{outline:2px solid var(--primary);outline-offset:3px}.canvaslink-settings__button:disabled{opacity:.55;cursor:wait} +.canvaslink-settings__card--quiet p{font-size:.85em}.canvaslink-settings__note{font-size:.9em}.canvaslink-settings__devices h4{margin:.8rem 0 .4rem}.canvaslink-settings__error{color:var(--danger)!important} +@media(max-width:760px){.canvaslink-settings__row{align-items:flex-start;flex-direction:column}.canvaslink-settings__card{padding:1rem}} diff --git a/integrations/canvaslink/host/snapshot.cjs b/integrations/canvaslink/host/snapshot.cjs new file mode 100644 index 000000000..3183c06b8 --- /dev/null +++ b/integrations/canvaslink/host/snapshot.cjs @@ -0,0 +1,20 @@ +// Transfer bounded BGRA pixels inside Electron. Only WebRTC performs lossy +// encoding; do not spend CPU encoding/decoding JPEG or PNG on the same Mac. +function createSnapshot(image,nativeImage,{maxWidth=2560,maxHeight=1600}={}){ + if(image.isEmpty())return null; + const scaleFactor=Math.max(1,...image.getScaleFactors().filter(s=>Number.isFinite(s)&&s>0)); + let pixels=image.toBitmap({scaleFactor}),size=image.getSize(scaleFactor); + if(pixels.length!==size.width*size.height*4){ + size={width:Math.round(size.width*scaleFactor),height:Math.round(size.height*scaleFactor)}; + if(pixels.length!==size.width*size.height*4)throw Error('invalid-snapshot-size'); + } + const factor=Math.min(1,maxWidth/size.width,maxHeight/size.height); + if(factor<1){ + const normalized=nativeImage.createFromBitmap(pixels,{...size,scaleFactor:1}); + size={width:Math.max(1,Math.floor(size.width*factor)),height:Math.max(1,Math.floor(size.height*factor))}; + pixels=normalized.resize({...size,quality:'best'}).toBitmap({scaleFactor:1}); + if(pixels.length!==size.width*size.height*4)throw Error('invalid-resized-snapshot'); + } + return {pixels,...size,scaleFactor,format:'bgra'}; +} +module.exports={createSnapshot}; diff --git a/integrations/canvaslink/host/style.css b/integrations/canvaslink/host/style.css new file mode 100644 index 000000000..ad166cf86 --- /dev/null +++ b/integrations/canvaslink/host/style.css @@ -0,0 +1 @@ +:root{color-scheme:dark;--bg:#121814;--panel:#1d2720;--line:#37463a;--ink:#eaf1e5;--muted:#a0afa2;--accent:#d6ef88}*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--ink);font:15px -apple-system,BlinkMacSystemFont,sans-serif}main{padding:32px;max-width:720px;margin:auto}header{display:flex;gap:14px;align-items:center;margin-bottom:32px}.mark{width:52px;height:52px;background:var(--accent);color:var(--bg);display:grid;place-items:center;font:700 30px Georgia;position:relative}.mark span{font:18px monospace;position:absolute;right:3px;top:2px}h1{font-size:25px;font-weight:550;letter-spacing:-1px;margin:0}header p{font-size:12px;color:var(--muted);margin:5px 0}.tag{margin-left:auto;font:10px monospace;color:var(--muted)}section{border-top:1px solid var(--line);padding:20px 0}.eyebrow{font:10px monospace;letter-spacing:1.6px;color:var(--muted);margin-bottom:16px}.pair{background:var(--panel);padding:22px;border:1px solid var(--line)}#pin{font:56px ui-monospace,Menlo,monospace;letter-spacing:8px;color:var(--accent);margin:16px 0}.pair-foot{display:flex;align-items:center;justify-content:space-between;font-size:12px;color:var(--muted)}button,select{font:inherit;color:var(--ink);background:transparent;border:1px solid var(--line);border-radius:4px;min-height:42px;padding:8px 12px}button{cursor:pointer}button:hover{border-color:var(--accent);color:var(--accent)}button:focus-visible,select:focus-visible{outline:2px solid var(--accent);outline-offset:3px}.row{display:flex;gap:10px}select{min-width:0;flex:1}.hint{color:var(--muted);font-size:12px;line-height:1.6;margin-bottom:0}.mono{font:14px ui-monospace,Menlo,monospace;line-height:1.7}#status{padding:14px;background:var(--panel);border-left:3px solid var(--accent);font-size:13px}#error{color:#ffb4a2;font-size:12px;line-height:1.6;white-space:pre-wrap;margin-top:10px}footer{display:flex;justify-content:space-between;margin-top:24px;font:10px monospace;color:var(--muted)} diff --git a/integrations/canvaslink/package.json b/integrations/canvaslink/package.json new file mode 100644 index 000000000..ff865c64c --- /dev/null +++ b/integrations/canvaslink/package.json @@ -0,0 +1,19 @@ +{ + "name": "@canvastty/canvaslink", + "version": "0.3.0-alpha.1", + "private": true, + "type": "commonjs", + "license": "MIT", + "scripts": { + "test": "node --test tests/*.test.cjs", + "build:android": "node scripts/build-android.cjs" + }, + "dependencies": { + "bonjour-service": "1.3.0", + "secure-remote-password": "0.3.1" + }, + "devDependencies": { + "playwright": "1.64.0", + "sharp": "0.35.5" + } +} diff --git a/integrations/canvaslink/scripts/build-android.cjs b/integrations/canvaslink/scripts/build-android.cjs new file mode 100644 index 000000000..624dc4ef0 --- /dev/null +++ b/integrations/canvaslink/scripts/build-android.cjs @@ -0,0 +1,40 @@ +// SDK-only debug build. No Gradle downloads, release keys or device installation. +const fs = require('node:fs'); +const path = require('node:path'); +const { spawnSync } = require('node:child_process'); +const root = path.resolve(__dirname, '..'); +const sdk = process.env.ANDROID_SDK_ROOT || process.env.ANDROID_HOME; +if (!sdk) throw Error('Set ANDROID_SDK_ROOT or ANDROID_HOME to an Android SDK with platform 35 and build-tools 35.0.0.'); +const jdk = process.env.JAVA_HOME; +const javaTool = name => jdk ? path.join(jdk, 'bin', name + (process.platform === 'win32' ? '.exe' : '')) : name; +const tools = path.join(sdk, 'build-tools', '35.0.0'); +const androidTool = name => path.join(tools, name + (process.platform === 'win32' ? (['d8', 'apksigner'].includes(name) ? '.bat' : '.exe') : '')); +const platform = path.join(sdk, 'platforms', 'android-35', 'android.jar'); +if (!fs.existsSync(platform)) throw Error('Missing Android SDK platform 35. See docs/canvaslink.md.'); +const build = path.join(root, 'build', 'android'), dist = path.join(root, 'dist'), runtime = path.join(root, '.runtime'); +fs.rmSync(build, { recursive: true, force: true }); +for (const dir of [build, dist, runtime, path.join(build, 'classes'), path.join(build, 'dex')]) fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); +function run(binary, args) { + const result = spawnSync(binary, args, { stdio: 'inherit', shell: process.platform === 'win32' && binary.endsWith('.bat') }); + if (result.error) throw result.error; + if (result.status !== 0) throw Error(`${path.basename(binary)} exited ${result.status}`); +} +// Device acceptance instrumentation remains source-only and is never shipped. +const files = ['MainActivity', 'TerminalWebView', 'Srp', 'SessionVault'].map(name => path.join(root, 'android', 'src', 'io', 'nexcore', 'canvaslink', name + '.java')); +run(javaTool('javac'), ['--release', '8', '-cp', platform, '-d', path.join(build, 'classes'), ...files]); +run(javaTool('jar'), ['cf', path.join(build, 'classes.jar'), '-C', path.join(build, 'classes'), '.']); +run(androidTool('d8'), ['--release', '--min-api', '26', '--lib', platform, '--output', path.join(build, 'dex'), path.join(build, 'classes.jar')]); +const compiled = path.join(build, 'resources.zip'); +run(androidTool('aapt2'), ['compile', '--dir', path.join(root, 'android', 'res'), '-o', compiled]); +run(androidTool('aapt2'), ['link', '-o', path.join(build, 'base.apk'), '--manifest', path.join(root, 'android', 'AndroidManifest.xml'), '-I', platform, '-A', path.join(root, 'android', 'assets'), compiled]); +run(javaTool('jar'), ['uf', path.join(build, 'base.apk'), '-C', path.join(build, 'dex'), 'classes.dex']); +run(androidTool('zipalign'), ['-f', '-p', '4', path.join(build, 'base.apk'), path.join(build, 'aligned.apk')]); +const keystore = path.join(runtime, 'debug.keystore'); +if (!fs.existsSync(keystore)) { + run(javaTool('keytool'), ['-genkeypair', '-keystore', keystore, '-alias', 'canvaslink-debug', '-storepass', 'android', '-keypass', 'android', '-keyalg', 'RSA', '-keysize', '2048', '-validity', '3650', '-dname', 'CN=CanvasLink Local Development']); + fs.chmodSync(keystore, 0o600); +} +const apk = path.join(dist, 'CanvasLink-debug.apk'); +run(androidTool('apksigner'), ['sign', '--ks', keystore, '--ks-key-alias', 'canvaslink-debug', '--ks-pass', 'pass:android', '--key-pass', 'pass:android', '--out', apk, path.join(build, 'aligned.apk')]); +run(androidTool('apksigner'), ['verify', '--verbose', apk]); +console.log(`Built ${path.relative(root, apk)} (local development signature; not a release build)`); diff --git a/integrations/canvaslink/scripts/ui-test-harness.cjs b/integrations/canvaslink/scripts/ui-test-harness.cjs new file mode 100644 index 000000000..cd8c8387b --- /dev/null +++ b/integrations/canvaslink/scripts/ui-test-harness.cjs @@ -0,0 +1,32 @@ +'use strict'; +const fs=require('node:fs');const path=require('node:path'); +const {chromium}=require(process.env.CANVASLINK_PLAYWRIGHT || 'playwright'); +const root=path.resolve(__dirname,'..'); +function fixtureTransport(){ + const state={version:2,connection:'idle',discovery:'ready',computers:[{id:'test-computer',name:'CanvasLink Тестовый компьютер',host:'127.0.0.1:3499'}],computer:'',error:'',pending:0,delivery:null,draft:'',stats:{width:1280,height:800,framesDecoded:2,framesPerSecond:0,rtt:0},source:{capture:'active',windowFocused:true,target:'terminal'}}; + const listeners=new Set(),calls=[];let behavior={saveDraft:true,deferText:false,focus:null};let pendingText;let nativeToken='';window.Native={showKeyboard(token){nativeToken=token;calls.push({type:'show-keyboard'});},terminalKey(key,modifiers){CanvasLinkInput.receive({token:nativeToken,type:'key',key,modifiers:JSON.parse(modifiers)});},paste(){CanvasLinkInput.receive({token:nativeToken,type:'paste',text:behavior.clipboard||''});}}; + const snapshot=()=>JSON.parse(JSON.stringify(state));const emit=patch=>{Object.assign(state,patch);listeners.forEach(fn=>fn(snapshot()));}; + const ensure=()=>{if(state.connection!=='connected')throw Error('not-connected');}; + window.__uiFixture={calls,emit,ime:value=>CanvasLinkInput.receive({...value,token:nativeToken}),configure:patch=>Object.assign(behavior,patch),resolveText(status='processed'){if(!pendingText)throw Error('No pending send');const id='fixture-text';emit({pending:0,delivery:{id,kind:'text',status}});pendingText.resolve({id,status});pendingText=null;},rejectText(message='delivery-uncertain'){emit({pending:0,delivery:{id:'fixture-text',kind:'text',status:'uncertain'}});pendingText?.reject(Error(message));pendingText=null;}}; + const api=window.CanvasLinkClient={version:2,getState:snapshot,subscribe(fn){listeners.add(fn);fn(snapshot());return()=>listeners.delete(fn);},attachVideo(video){ + const canvas=document.createElement('canvas');canvas.width=1280;canvas.height=800;const c=canvas.getContext('2d'); + c.fillStyle='#161d18';c.fillRect(0,0,1280,800);c.fillStyle='#202a22';c.fillRect(0,0,190,800);c.fillStyle='#d6ef88';c.font='bold 23px sans-serif';c.fillText('CanvasTTY',28,54);c.fillStyle='#aab6a5';c.font='18px sans-serif';c.fillText('Тестовая сцена',28,117);c.fillText('Интерфейс',28,162);c.fillText('Жесты',28,206); + c.strokeStyle='#3e4b3f';c.lineWidth=2;c.strokeRect(235,105,990,570);c.fillStyle='#edf1e6';c.font='32px sans-serif';c.fillText('Рабочее пространство',273,165);c.fillStyle='#aab6a5';c.font='22px sans-serif';c.fillText('Собственная сцена для проверки интерфейса',273,220);c.font='18px sans-serif';c.fillText('Нет подключения к компьютеру. Нет реального терминала.',273,264); + c.fillStyle='#28352a';for(let i=0;i<3;i++)c.fillRect(273+i*300,320,270,210);c.fillStyle='#d6ef88';c.font='20px sans-serif';c.fillText('Касание',297,365);c.fillText('Масштаб',597,365);c.fillText('Ввод',897,365);c.fillStyle='#879682';c.font='15px sans-serif';c.fillText('UI FIXTURE · НЕ СНИМОК ПОДКЛЮЧЁННОГО КОМПЬЮТЕРА',273,720); + video.srcObject=canvas.captureStream(2);video.play().catch(()=>{}); + },discover(){calls.push({type:'discover'});emit({discovery:'searching'});},connect(options){calls.push({type:'connect',computerId:options.computerId,manual:Boolean(options.host),codeLength:options.code?.length});if(!/^\d{6}$/.test(options.code||'')){emit({error:'invalid-code'});return false;}emit({connection:'pairing',error:''});return true;},setDraft(text){calls.push({type:'draft',text});state.draft=String(text).slice(0,8000);return behavior.saveDraft;},sendText(text){calls.push({type:'text',text});try{ensure();}catch(error){return Promise.reject(error);}emit({pending:1,delivery:{id:'fixture-text',kind:'text',status:'pending'}});if(behavior.deferText)return new Promise((resolve,reject)=>{pendingText={resolve,reject};});emit({pending:0,delivery:{id:'fixture-text',kind:'text',status:'processed'}});return Promise.resolve({id:'fixture-text',status:'processed'});},sendKey(key,modifiers=[]){calls.push({type:'key',key,modifiers});try{ensure();return Promise.resolve({id:'fixture-key',status:'processed'});}catch(error){return Promise.reject(error);}},sendInput(input){calls.push({type:'input',input});if(input.type==='text')return api.sendText(input.text);try{ensure();return Promise.resolve({id:'fixture-input',status:'processed',...(input.action==='up'&&behavior.focus?{focus:behavior.focus}:{})});}catch(error){return Promise.reject(error);}},hideKeyboard(){calls.push({type:'hide-keyboard'});},resume(){calls.push({type:'resume'});},disconnect(){calls.push({type:'disconnect'});emit({connection:'idle'});}}; +} +async function openUI({width=1024,height=768,touch=false,connected=false}={}){ + const browser=await chromium.launch({headless:true});const context=await browser.newContext({viewport:{width,height},isMobile:touch,hasTouch:touch}); + const errors=[];const page=await context.newPage();page.setDefaultTimeout(7000);page.on('pageerror',e=>errors.push(e.message));page.on('console',event=>{if(event.type()==='error')errors.push(event.text());}); + await page.route('https://appassets.androidplatform.net/client/**',route=>{ + const file=new URL(route.request().url()).pathname.split('/').pop(); + if(file==='transport.js')return route.fulfill({contentType:'application/javascript',body:'('+fixtureTransport.toString()+')();'}); + if(!['index.html','style.css','client.js','gestures.js','delivery.js','direct-input.js','onest.ttf'].includes(file))return route.abort(); + return route.fulfill({contentType:file.endsWith('.js')?'application/javascript':file.endsWith('.css')?'text/css':file.endsWith('.ttf')?'font/ttf':'text/html',body:fs.readFileSync(path.join(root,'android/assets',file))}); + }); + await page.goto('https://appassets.androidplatform.net/client/index.html'); + if(connected)await page.evaluate(()=>__uiFixture.emit({connection:'connected'})); + return {browser,context,page,errors}; +} +module.exports={openUI,root}; diff --git a/integrations/canvaslink/tests/control-delivery.test.cjs b/integrations/canvaslink/tests/control-delivery.test.cjs new file mode 100644 index 000000000..42a8e4dc9 --- /dev/null +++ b/integrations/canvaslink/tests/control-delivery.test.cjs @@ -0,0 +1,107 @@ +const {test}=require('node:test'); +const assert=require('node:assert/strict'); +const {InputQueue}=require('../host/input-queue.cjs'); +const {Delivery}=require('../android/assets/delivery.js'); +const input=(seq,extra={})=>({id:`input-${seq}`,seq,type:'key',key:'Enter',modifiers:[],...extra}); +const deferred=()=>{let resolve;return {promise:new Promise(r=>resolve=r),release:()=>resolve()};}; + +test('an in-flight duplicate with a different payload cannot execute twice',async()=>{ + const gate=deferred(),applied=[],replies=[]; + const q=new InputQueue({active:()=>true,apply:async e=>{applied.push(e.text);await gate.promise;},reply:e=>replies.push(e)}); + q.receive(input(1,{type:'text',text:'original'})); + q.receive(input(2,{id:'input-1',type:'text',text:'changed'})); + gate.release();await q.tail; + q.receive(input(3,{id:'input-1',type:'text',text:'third'})); + assert.deepEqual(applied,['original']);assert.equal(replies.length,2); + assert(replies.every(e=>e.type==='input-ok'&&e.id==='input-1')); +}); + +test('queue saturation rejects explicitly and accepts fresh input after draining',async()=>{ + const gate=deferred(),applied=[],replies=[]; + const q=new InputQueue({limit:2,active:()=>true,apply:async e=>{applied.push(e.id);await gate.promise;},reply:e=>replies.push(e)}); + q.receive(input(1));q.receive(input(2));q.receive(input(3)); + assert.deepEqual(replies,[{type:'input-error',id:'input-3',error:'input-busy'}]); + gate.release();await q.tail;q.receive(input(4));await q.tail; + assert.deepEqual(applied,['input-1','input-2','input-4']);assert.equal(q.pending,0); +}); + +test('invalid payload and unsafe sequence cannot consume a future valid sequence',async()=>{ + const applied=[],replies=[]; + const q=new InputQueue({active:()=>true,apply:async e=>applied.push(e.seq),reply:e=>replies.push(e)}); + q.receive(input(9,{type:'text',text:''}));q.receive(input(Number.MAX_SAFE_INTEGER+1)); + q.receive(input(1));q.receive(input(3));q.receive(input(2));await q.tail; + assert.deepEqual(applied,[1,3]); + assert.deepEqual(replies.filter(e=>e.type==='input-error').map(e=>e.error),['invalid-text','input-expired','input-expired']); +}); + +test('a failed operation does not poison the ordered queue or replay on duplicate',async()=>{ + const applied=[],replies=[]; + const q=new InputQueue({active:()=>true,apply:async e=>{applied.push(e.seq);if(e.seq===1)throw Error('text-target-required');},reply:e=>replies.push(e)}); + q.receive(input(1));q.receive(input(2));await q.tail;q.receive(input(1)); + assert.deepEqual(applied,[1,2]);assert.equal(replies.at(-1).error,'text-target-required');assert.equal(q.pending,0); +}); + +test('revocation drains queued work without sending stale ACKs into a new peer',async()=>{ + let active=true;const gate=deferred(),applied=[],replies=[]; + const q=new InputQueue({active:()=>active,apply:async e=>{applied.push(e.seq);await gate.promise;},reply:e=>replies.push(e)}); + q.receive(input(1));q.receive(input(2));await Promise.resolve();await Promise.resolve(); + active=false;gate.release();await q.tail; + assert.deepEqual(applied,[1]);assert.deepEqual(replies,[]);assert.equal(q.pending,0); +}); + +test('500 ordered operations and duplicate retries execute once beyond the ID cache',async()=>{ + const applied=[],replies=[]; + const q=new InputQueue({maxIds:8,active:()=>true,apply:async e=>applied.push(e.seq),reply:e=>replies.push(e)}); + for(let n=1;n<=500;n++){q.receive(input(n));q.receive(input(n));await q.tail;} + for(let n=1;n<=500;n++)q.receive(input(n));await q.tail; + assert.deepEqual(applied,Array.from({length:500},(_,i)=>i+1));assert.equal(q.pending,0);assert(q.ids.size<=8); + assert.equal(replies.filter(e=>e.error==='input-expired').length,492); +}); + +test('send exception is not-sent, clears its timer and never retries',async()=>{ + let sends=0,clears=0; + const d=new Delivery({channel:()=>({readyState:'open',bufferedAmount:0,send(){sends++;throw Error('closed');}}),id:()=> 'send-failed',setTimer:()=>42,clearTimer:t=>{assert.equal(t,42);clears++;}}); + await assert.rejects(d.send({type:'key',key:'Enter',modifiers:[]}),/not-sent/); + assert.equal(sends,1);assert.equal(clears,1);assert.equal(d.pending.size,0);assert.equal(d.sent,0); +}); + +test('64 pending operations enforce backpressure; one ACK frees one slot',async()=>{ + let id=0;const messages=[]; + const d=new Delivery({channel:()=>({readyState:'open',bufferedAmount:0,send:v=>messages.push(JSON.parse(v))}),id:()=>`pending-${++id}`,setTimer:()=>0,clearTimer(){}}); + const operations=Array.from({length:64},()=>d.send({type:'key',key:'Enter',modifiers:[]}).catch(e=>e.message)); + await assert.rejects(d.send({type:'text',text:'must stay local'}),/input-busy/); + d.receive({type:'input-ok',id:'pending-1'}); + const last=d.send({type:'key',key:'Escape',modifiers:[]}).catch(e=>e.message); + assert.equal(messages.length,65);assert.equal(messages.at(-1).seq,65); + d.close();await Promise.all([...operations,last]);assert.equal(d.pending.size,0); +}); + +test('late, duplicate and malformed ACKs cannot turn uncertainty into success',async()=>{ + const changes=[];let expire; + const d=new Delivery({channel:()=>({readyState:'open',bufferedAmount:0,send(){}}),id:()=> 'late',onChange:e=>changes.push(e),setTimer:fn=>{expire=fn;return 1;},clearTimer(){}}); + const operation=d.send({type:'text',text:'draft'});expire();await assert.rejects(operation,/delivery-uncertain/); + for(const ack of [null,{}, {type:'input-ok',id:'late'},{type:'input-error',id:'late',error:'rejected'}])assert.equal(d.receive(ack),false); + assert.deepEqual(changes.map(e=>e.status),['pending','uncertain']);assert.equal(d.acked,0); +}); + +test('host rejection is explicit and does not retry text or Enter',async()=>{ + const sent=[];let id=0; + const d=new Delivery({channel:()=>({readyState:'open',bufferedAmount:0,send:v=>sent.push(JSON.parse(v))}),id:()=>`rejected-${++id}`,setTimer:()=>0,clearTimer(){}}); + for(const payload of [{type:'text',text:'draft'},{type:'key',key:'Enter',modifiers:[]}]){ + const operation=d.send(payload);d.receive({type:'input-error',id:`rejected-${id}`,error:'canvas-focus-required'}); + await assert.rejects(operation,/canvas-focus-required/); + } + assert.equal(sent.length,2);assert.equal(d.pending.size,0);assert.equal(d.acked,0); +}); + +test('Focus metadata belongs to the acknowledged click and remains immutable on duplicate replay',async()=>{ + const replies=[];let call=0; + const q=new InputQueue({active:()=>true,apply:async()=>{call++;return{focus:{editable:true,kind:'terminal',rect:{x:.1,y:.2,width:.5,height:.6}}};},reply:e=>replies.push(e)}); + const click=input(1,{type:'pointer',action:'up',button:0,x:.5,y:.5});q.receive(click);await q.tail;q.receive(click); + assert.equal(call,1);assert.deepEqual(replies[0].focus,{editable:true,kind:'terminal',rect:{x:.1,y:.2,width:.5,height:.6}});assert.deepEqual(replies[1],replies[0]); +}); +test('Delivery returns focus only from its own processed ACK, ignoring unrelated source-state',async()=>{ + const d=new Delivery({channel:()=>({readyState:'open',bufferedAmount:0,send(){}}),id:()=> 'tap-focus',setTimer:()=>0,clearTimer(){}}); + const result=d.send({type:'pointer',action:'up',button:0,x:.5,y:.5});assert.equal(d.receive({type:'source-state',focus:{editable:true}}),false); + d.receive({type:'input-ok',id:'tap-focus',focus:{editable:false,kind:'canvas'}});assert.equal((await result).focus.editable,false); +}); diff --git a/integrations/canvaslink/tests/delivery.test.cjs b/integrations/canvaslink/tests/delivery.test.cjs new file mode 100644 index 000000000..8cc910304 --- /dev/null +++ b/integrations/canvaslink/tests/delivery.test.cjs @@ -0,0 +1,8 @@ +const {test}=require('node:test'),assert=require('node:assert/strict'); +const {Delivery}=require('../android/assets/delivery.js'); +const {InputQueue}=require('../host/input-queue.cjs'); +test('offline and backpressure reject explicitly, without sending text',async()=>{let channel;const d=new Delivery({channel:()=>channel});await assert.rejects(d.send({type:'text',text:'x'}),/not-connected/);channel={readyState:'open',bufferedAmount:70000,send(){throw Error('must not send');}};await assert.rejects(d.send({type:'text',text:'x'}),/input-busy/);assert.equal(d.pending.size,0);}); +test('only matching ACK completes text and close marks uncertain without replay',async()=>{const messages=[],changes=[];let seq=0;const d=new Delivery({channel:()=>({readyState:'open',bufferedAmount:0,send:v=>messages.push(JSON.parse(v))}),onChange:e=>changes.push(e),id:()=>String(++seq)});const sent=d.send({type:'text',text:'Промпт'});assert.equal(d.receive({type:'input-ok',id:'foreign'}),false);assert.equal(d.pending.size,1);d.receive({type:'input-ok',id:'1'});assert.equal((await sent).status,'processed');const next=d.send({type:'key',key:'Enter',modifiers:[]});d.close();await assert.rejects(next,/delivery-uncertain/);assert.equal(messages.length,2);assert.equal(changes.at(-1).status,'uncertain');}); +test('ACK timeout retains uncertainty; no automatic retry',async()=>{let expire;const d=new Delivery({channel:()=>({readyState:'open',bufferedAmount:0,send(){}}),id:()=> 'x',setTimer:fn=>{expire=fn;return 1;},clearTimer(){}});const p=d.send({type:'text',text:'x'});expire();await assert.rejects(p,/delivery-uncertain/);assert.equal(d.sent,1);}); +test('ordered input executes once, rejects expired IDs after bounded cache',async()=>{const applied=[],replies=[];const q=new InputQueue({apply:async v=>applied.push(v.id),active:()=>true,reply:r=>replies.push(r),maxIds:1});const input=n=>({id:String(n),seq:n,type:'key',key:'Enter',modifiers:[]});q.receive(input(1));q.receive(input(1));await q.tail;q.receive(input(2));await q.tail;q.receive(input(3));await q.tail;q.receive(input(1));await q.tail;assert.deepEqual(applied,['1','2','3']);assert.equal(replies.at(-1).error,'input-expired');}); +test('revoking a peer cancels queued text before application',async()=>{let active=true,release;const applied=[];const gate=new Promise(r=>release=r);const q=new InputQueue({active:()=>active,apply:async v=>{applied.push(v.id);await gate;},reply(){}});q.receive({id:'a',seq:1,type:'text',text:'a'});q.receive({id:'b',seq:2,type:'text',text:'b'});await Promise.resolve();await Promise.resolve();active=false;release();await q.tail;assert.deepEqual(applied,['a']);}); diff --git a/integrations/canvaslink/tests/embedded-lifecycle.test.cjs b/integrations/canvaslink/tests/embedded-lifecycle.test.cjs new file mode 100644 index 000000000..94b56a917 --- /dev/null +++ b/integrations/canvaslink/tests/embedded-lifecycle.test.cjs @@ -0,0 +1,64 @@ +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const vm = require('node:vm'); +const { createRequire } = require('node:module'); +const { EventEmitter } = require('node:events'); +const file = path.join(__dirname, '../host/embedded.cjs'); +function fixture({ failLoad = false } = {}) { + const counters = { listeners: 0, discovery: 0, panels: 0, close: 0, destroy: 0 }; + const handlers = new Map(), timers = new Set(), app = new EventEmitter(); + app.getPath = () => '/unused-test-runtime'; + const target = new EventEmitter(); target.isDestroyed = () => false; + target.webContents = { mainFrame: {}, executeJavaScript: async () => {}, endFrameSubscription() {} }; + target.getContentSize = () => [1000, 700]; target.getTitle = () => 'Owned fixture'; + let panel, captureHandler; + class Panel extends EventEmitter { + constructor() { super(); counters.panels++; panel = this; this.webContents = new EventEmitter(); this.webContents.mainFrame = {}; this.webContents.setWindowOpenHandler = () => {}; } + isDestroyed() { return !!this.destroyed; } + destroy() { this.destroyed = true; counters.destroy++; } + hide() {} + async loadFile() { if (failLoad) throw Error('fixture-load-failed'); } + } + const captureSession = { setPermissionCheckHandler(fn) { this.check = fn; }, setPermissionRequestHandler() {}, setDisplayMediaRequestHandler(fn) { captureHandler = fn; } }; + const electron = { app, BrowserWindow: Panel, nativeImage: {}, session: { fromPartition: () => captureSession }, ipcMain: { handle: (key, fn) => handlers.set(key, fn), on: (key, fn) => handlers.set(key, fn) } }; + const localRequire = createRequire(file); + const replacements = { + electron, + 'node:fs': { mkdirSync() {} }, + 'node:http': { createServer() { return { requestTimeout: 0, once() {}, listen(port, host, done) { counters.listeners++; done(); }, address: () => ({ port: 3499 }), close() { counters.close++; } }; } }, + './discovery.cjs': { publish() { counters.discovery++; return { destroy() {} }; } } + }; + const context = { require: name => Object.hasOwn(replacements, name) ? replacements[name] : localRequire(name), module: { exports: {} }, __dirname: path.dirname(file), process, setInterval(fn) { const token = {}; timers.add(token); return token; }, clearInterval(token) { timers.delete(token); } }; + vm.runInNewContext(fs.readFileSync(file, 'utf8'), context); + context.module.exports.install({ getWindow: () => target }); + const event = { sender: target.webContents, senderFrame: target.webContents.mainFrame }; + return { counters, handlers, event, target, timers, captureSession, get panel() { return panel; }, capture: request => { let result; captureHandler(request, value => result = value); return result; }, state: () => handlers.get('canvaslink-settings:state')(event), command: action => handlers.get('canvaslink-settings:command')(event, action) }; +} +test('Installing CanvasLink is inert until an owning settings command enables access', async () => { + const f = fixture(); + assert.equal(f.state().enabled, false); assert.equal(f.state().pin, ''); + assert.deepEqual(f.counters, { listeners: 0, discovery: 0, panels: 0, close: 0, destroy: 0 }); + assert.equal(f.timers.size, 0); + await f.command('enable'); assert.equal(f.state().enabled, true); + assert.equal(f.counters.listeners, 1); assert.equal(f.counters.panels, 1); + await f.command('enable'); assert.equal(f.counters.listeners, 1); + await f.command('disable'); assert.equal(f.state().enabled, false); assert.equal(f.timers.size, 0); assert.equal(f.panel.destroyed, true); +}); +test('Capture grants only the owned CanvasTTY main frame and stops after disable', async () => { + const f = fixture(); await f.command('enable'); + assert.equal(f.capture({ frame: f.panel.webContents.mainFrame }).video, f.target.webContents.mainFrame); + assert.equal(f.capture({ frame: {} }).video, undefined); + assert.equal(f.captureSession.check({}, 'display-capture'), false); + assert.equal(f.captureSession.check(f.panel.webContents, 'display-capture'), true); + assert.equal(f.captureSession.check(f.panel.webContents, 'media'), false); + await f.command('disable'); assert.equal(f.capture({ frame: f.panel.webContents.mainFrame }).video, undefined); +}); +test('A failed capture-worker load closes the listener and allows a clean retry', async () => { + const f = fixture({ failLoad: true }); + await assert.rejects(f.command('enable'), /fixture-load-failed/); + assert.equal(f.state().enabled, false); assert.equal(f.counters.close, 1); assert.equal(f.timers.size, 0); assert.equal(f.panel.destroyed, true); + await assert.rejects(f.command('enable'), /fixture-load-failed/); + assert.equal(f.counters.listeners, 2); assert.equal(f.counters.close, 2); +}); diff --git a/integrations/canvaslink/tests/frontend.test.cjs b/integrations/canvaslink/tests/frontend.test.cjs new file mode 100644 index 000000000..447a1b269 --- /dev/null +++ b/integrations/canvaslink/tests/frontend.test.cjs @@ -0,0 +1,46 @@ +const {test}=require('node:test');const assert=require('node:assert/strict');const {openUI}=require('../scripts/ui-test-harness.cjs'); +const focus={editable:true,kind:'terminal',rect:{x:.2,y:.2,width:.6,height:.6}}; +async function typing(t,options={}){const ui=await openUI({connected:true,touch:true,...options});t.after(async()=>{await ui.browser.close();assert.deepEqual(ui.errors,[]);});await ui.page.evaluate(focus=>__uiFixture.configure({focus}),options.focus||focus);await ui.page.locator('#viewport').click();await ui.page.waitForFunction(()=>__uiFixture.calls.some(c=>c.type==='show-keyboard'));return ui;} +const commands=page=>page.evaluate(()=>__uiFixture.calls.filter(c=>c.type==='input'&&['key','text'].includes(c.input.type)).map(c=>c.input)); +test('Tap opens direct native input only after processed pointer-up confirms editable target',async t=>{const {page}=await typing(t);assert.equal(await page.locator('#compose,#text,#send').count(),0);assert.match(await page.locator('#target-status').textContent(),/Ввод.*Терминал/);assert.ok(await page.locator('.session-badge').evaluate(e=>Number(getComputedStyle(e).opacity)>.5));await page.evaluate(()=>__uiFixture.ime({type:'text',text:'Привет 🙂'}));assert.deepEqual(await commands(page),[{type:'text',text:'Привет 🙂'}]);}); +test('A canvas tap never opens a keyboard from stale polled source.target',async t=>{const ui=await openUI({connected:true});t.after(()=>ui.browser.close());await ui.page.locator('#viewport').click();assert.equal(await ui.page.evaluate(()=>__uiFixture.calls.filter(c=>c.type==='show-keyboard').length),0);}); +test('Composition is displayed locally; one commit is sent and Enter follows it directly',async t=>{const {page}=await typing(t);await page.evaluate(()=>{__uiFixture.ime({type:'composition',text:'日本'});});assert.equal(await page.locator('#preedit').textContent(),'日本');assert.deepEqual(await commands(page),[]);await page.evaluate(()=>{__uiFixture.ime({type:'text',text:'日本語'});__uiFixture.ime({type:'composition',text:''});__uiFixture.ime({type:'key',key:'Enter',modifiers:[]});});assert.deepEqual(await commands(page),[{type:'text',text:'日本語'},{type:'key',key:'Enter',modifiers:[]}]);}); +test('Native delete, arrows and Enter preserve order without a second editor',async t=>{const {page}=await typing(t);await page.evaluate(()=>{__uiFixture.ime({type:'text',text:'ab'});for(const key of ['Backspace','ArrowLeft','Enter'])__uiFixture.ime({type:'key',key,modifiers:[]});});assert.deepEqual((await commands(page)).map(v=>v.key||v.text),['ab','Backspace','ArrowLeft','Enter']);}); +test('Uncertain delivery stops IME and reconnect never replays committed input or Enter',async t=>{const {page}=await typing(t);await page.evaluate(()=>{__uiFixture.configure({deferText:true});__uiFixture.ime({type:'text',text:'uncertain'});__uiFixture.rejectText();});await page.waitForFunction(()=>!CanvasLinkInput.active());await page.evaluate(()=>{__uiFixture.emit({connection:'reconnecting'});__uiFixture.emit({connection:'connected'});__uiFixture.ime({type:'key',key:'Enter',modifiers:[]});});assert.deepEqual(await commands(page),[{type:'text',text:'uncertain'}]);assert.match(await page.locator('#input-status').textContent(),/подтвержд|Проверьте/);}); +test('Ctrl is one-shot and Paste sends Android clipboard as text with no implicit Enter',async t=>{const {page}=await typing(t);await page.locator('#ctrl').click();await page.evaluate(()=>__uiFixture.ime({type:'text',text:'c'}));assert.equal(await page.locator('#ctrl').getAttribute('aria-pressed'),'false');await page.evaluate(()=>__uiFixture.configure({clipboard:'текст 🙂'}));await page.locator('#paste').click();assert.deepEqual(await commands(page),[{type:'key',key:'c',modifiers:['control']},{type:'text',text:'текст 🙂'}]);}); +test('A late native callback from the old keyboard token is ignored after a new target tap',async t=>{const {page}=await typing(t);await page.evaluate(()=>{const f=Native.showKeyboard;Native.showKeyboard=t=>{window.__lastToken=t;f(t);};CanvasLinkInput.open({editable:true,kind:'terminal'});window.__oldToken=__lastToken;CanvasLinkInput.open({editable:true,kind:'terminal'});CanvasLinkInput.receive({token:__oldToken,type:'text',text:'old'});});assert.deepEqual(await commands(page),[]);}); +test('One computer is automatic; IP is secondary; pairing hint names actual native Settings',async t=>{const {browser,page}=await openUI();t.after(()=>browser.close());assert.equal(await page.locator('#computers button').getAttribute('aria-pressed'),'true');assert.equal(await page.locator('#manual').evaluate(e=>e.open),false);assert.match(await page.locator('#pin-hint').textContent(),/Настройки.*CanvasLink/);await page.locator('#pin').fill('1'.repeat(6));await page.locator('#connect').click();assert.equal(await page.locator('#pin').inputValue(),'');}); +test('Only a single 56px bind row reserves workspace space across keyboard sizes',async t=>{for(const [width,height] of [[390,844],[844,390],[844,240],[1280,800],[768,1024],[320,320]]){const {browser,page}=await openUI({connected:true,width,height});try{const g=await page.evaluate(()=>{const r=document.getElementById('viewport').getBoundingClientRect(),b=document.getElementById('controls').getBoundingClientRect();const rows=[...document.getElementById('controls').querySelectorAll('button')].map(e=>e.getBoundingClientRect().top);return{r:{width:r.width,height:r.height},b:{top:b.top,height:b.height},rows,overflow:document.documentElement.scrollWidth>innerWidth};});assert.equal(g.r.width,width);assert.equal(g.r.height,height-56);assert.equal(g.b.height,56);assert.equal(new Set(g.rows).size,1);assert.equal(g.overflow,false);}finally{await browser.close();}}}); +test('Selected terminal keeps usable width and focus when IME shrinks the viewport',async t=>{const {page}=await typing(t,{width:1024,height:768});await page.setViewportSize({width:1024,height:360});await page.waitForTimeout(50);const g=await page.evaluate(()=>{const r=document.getElementById('video').getBoundingClientRect(),v=document.getElementById('viewport').getBoundingClientRect(),k=Math.min(r.width/1280,r.height/800);return {terminalWidth:1280*k*.6,viewportWidth:v.width,viewportHeight:v.height};});assert.ok(g.terminalWidth>=g.viewportWidth*.9);assert.equal(g.viewportHeight,304);}); +test('Diagnostics is a sheet off the working canvas, RTT explicitly ms',async t=>{const {page}=await typing(t);assert.equal(await page.locator('#metrics').isVisible(),false);await page.evaluate(()=>__uiFixture.emit({stats:{rtt:17.4,framesDecoded:20}}));await page.locator('#menu-toggle').click();await page.locator('#diagnostics-toggle').click();assert.match(await page.locator('#metrics').textContent(),/RTT канала\s+17 мс/);}); + +test('An empty terminal cursor and a moving bottom cursor stay visible with the IME',async t=>{ + const selected={...focus,caret:{x:.25,y:.22,width:.012,height:.025}}; + const {page}=await typing(t,{width:1024,height:768,focus:selected}); + const visible=()=>page.evaluate(()=>{const c=CanvasLinkClient.getState().source?.caret||window.__caret;const r=document.getElementById('video').getBoundingClientRect(),v=document.getElementById('viewport').getBoundingClientRect(),k=Math.min(r.width/1280,r.height/800),y=r.top+(r.height-800*k)/2+(c.y+c.height/2)*800*k;return y>v.top+8&&ywindow.__caret=c,selected.caret); + for(const [width,height] of [[1024,360],[390,310],[1024,768]]){await page.setViewportSize({width,height});await page.waitForTimeout(70);assert.ok(await visible(),'top cursor must stay visible after IME or rotation');} + await page.evaluate(focus=>__uiFixture.emit({source:{target:'terminal',rect:focus.rect,caret:{x:.65,y:.77,width:.012,height:.025}}}),selected); + await page.waitForTimeout(30);assert.ok(await visible(),'health must follow the active selected cursor'); +}); +test('Cursor following never overrides a manual view or opens a new keyboard',async t=>{ + const {page}=await typing(t);await page.locator('#menu-toggle').click();await page.locator('#reset-view').click(); + const before=await page.locator('#video').evaluate(e=>getComputedStyle(e).transform); + await page.evaluate(focus=>__uiFixture.emit({source:{target:'terminal',rect:focus.rect,caret:{x:.2,y:.21,width:.01,height:.02}}}),focus); + assert.equal(await page.locator('#video').evaluate(e=>getComputedStyle(e).transform),before);assert.equal(await page.evaluate(()=>CanvasLinkInput.active()),false); +}); + +test('Explicit paste is literal even when one-shot Ctrl was latched',async t=>{const {page}=await typing(t);await page.locator('#ctrl').click();await page.evaluate(()=>__uiFixture.configure({clipboard:'a'}));await page.locator('#paste').click();assert.deepEqual(await commands(page),[{type:'text',text:'a'}]);assert.equal(await page.locator('#ctrl').getAttribute('aria-pressed'),'false');}); + +test('Initial terminal framing is visual only and overview stays under user control',async t=>{ + const ui=await openUI({connected:true,width:1024,height:768,touch:true});t.after(()=>ui.browser.close()); + const source={target:'canvas',surface:'canvas',terminalView:{rect:{x:.3,y:.2,width:.5,height:.5},viewport:{width:1440,height:900}}}; + await ui.page.evaluate(source=>__uiFixture.emit({source}),source); + assert.equal(await ui.page.evaluate(()=>CanvasLinkInput.active()),false); + assert.equal(await ui.page.locator('#view-toggle-label').textContent(),'Весь Canvas'); + assert.notEqual(await ui.page.locator('#video').evaluate(e=>getComputedStyle(e).transform),'matrix(1, 0, 0, 1, 0, 0)'); + await ui.page.locator('#view-toggle').click();await ui.page.evaluate(source=>__uiFixture.emit({source}),source); + assert.equal(await ui.page.locator('#video').evaluate(e=>getComputedStyle(e).transform),'matrix(1, 0, 0, 1, 0, 0)'); + await ui.page.locator('#view-toggle').click();assert.equal(await ui.page.evaluate(()=>CanvasLinkInput.active()),false); + assert.equal(await ui.page.evaluate(()=>__uiFixture.calls.filter(c=>c.type==='show-keyboard').length),0); +}); diff --git a/integrations/canvaslink/tests/gestures-ui.test.cjs b/integrations/canvaslink/tests/gestures-ui.test.cjs new file mode 100644 index 000000000..9896caeec --- /dev/null +++ b/integrations/canvaslink/tests/gestures-ui.test.cjs @@ -0,0 +1,322 @@ +'use strict'; +const {test}=require('node:test'); +const assert=require('node:assert/strict'); +const {openUI}=require('../scripts/ui-test-harness.cjs'); + +async function fixture(t,options={}) { + const ui=await openUI({width:1024,height:900,touch:true,connected:true,...options}); + t.after(async()=>{const unhandled=await ui.page.evaluate(()=>window.__unhandled||[]);await ui.browser.close();assert.deepEqual(ui.errors,[],'browser page/console errors');assert.deepEqual(unhandled,[],'unhandled API promises');}); + await ui.page.waitForFunction(()=>document.querySelector('#video').videoWidth===1280); + await ui.page.evaluate(()=>{ + window.__gestureEvents=[];window.__viewEvents=[];window.__unhandled=[]; + window.addEventListener('canvaslink:pointer',e=>__gestureEvents.push(e.detail)); + window.addEventListener('canvaslink:view',e=>__viewEvents.push(e.detail)); + window.addEventListener('unhandledrejection',e=>__unhandled.push(String(e.reason))); + }); + return ui; +} +test('fit/mode/reset preserve instrumentation, labels, icons and publish view state',async t=>{ + const {page}=await fixture(t); + await page.evaluate(()=>{ + for(const [button,id,label]of [['fit','zoom-label','По размеру'],['mode','mode-label','Касание']]){ + if(!document.getElementById(id))document.getElementById(button).innerHTML=''+label+''; + } + if(!document.getElementById('reset-view')){const b=document.createElement('button');b.id='reset-view';b.textContent='По размеру';document.querySelector('#controls').append(b);} + if(!document.getElementById('gesture-hint')){const p=document.createElement('p');p.id='gesture-hint';document.querySelector('#controls').append(p);} + }); + await clear(page);await menu(page,'fit'); + const fit=await page.evaluate(()=>({matrix:getComputedStyle(document.getElementById('video')).transform,label:document.getElementById('zoom-label').textContent,view:__viewEvents.at(-1)})); + assert.match(fit.matrix,/matrix\(2, 0, 0, 2,/);assert.match(fit.label,/200|2/); + assert.deepEqual(fit.view,{scale:2,offset:{x:0,y:0},trackpad:false}); + await menu(page,'mode'); + assert.equal(await page.locator('#mode').getAttribute('aria-pressed'),'true'); + assert.equal(await page.locator('#mode-label').textContent(),'Тачпад'); + assert.equal(await page.locator('#mode-label').count(),1,'mode label is preserved'); + assert.match(await page.locator('#gesture-hint').textContent(),/дв[ау]|2|пальц/i); + await menu(page,'reset-view'); + assert.deepEqual(await page.evaluate(()=>__viewEvents.at(-1)),{scale:1,offset:{x:0,y:0},trackpad:true}); + await menu(page,'mode');assert.equal(await page.locator('#mode-label').textContent(),'Касание'); +}); + +test('direct drag releases exactly once on viewport capture loss but ignores bubbled video loss',async t=>{ + const {page}=await fixture(t);const a=await area(page);await clear(page); + await page.mouse.move(a.x-40,a.y);await page.mouse.down();await page.mouse.move(a.x+40,a.y); + await page.evaluate(()=>document.querySelector('#video').dispatchEvent(new PointerEvent('lostpointercapture',{pointerId:1,bubbles:true}))); + assert.equal((await inputs(page)).filter(c=>c.input?.action==='up').length,0,'child capture transfer is not cancellation'); + await page.evaluate(()=>document.querySelector('#viewport').releasePointerCapture(1)); + await page.mouse.move(a.x+45,a.y); + assert.equal((await inputs(page)).filter(c=>c.input?.action==='up').length,1,'capture loss releases before physical pointerup'); + await page.mouse.up(); + const values=(await inputs(page)).map(c=>c.input); + assert.equal(values.filter(c=>c.action==='down').length,1);assert.equal(values.filter(c=>c.action==='up').length,1,'capture loss releases remote down'); + assert.equal(values.at(-1).action,'up','nothing follows the release'); + assert.deepEqual(await page.evaluate(()=>__gestureEvents.map(e=>({x:e.x,y:e.y,action:e.action,trackpad:e.trackpad}))),values.map(e=>({x:e.x,y:e.y,action:e.action,trackpad:false}))); +}); + +test('blur ends a drag and a connection interruption cannot replay its tail',async t=>{ + const {page}=await fixture(t);const a=await area(page);await clear(page); + await page.mouse.move(a.x-40,a.y);await page.mouse.down();await page.mouse.move(a.x+40,a.y); + await page.evaluate(()=>window.dispatchEvent(new Event('blur'))); + assert.deepEqual((await inputs(page)).filter(c=>c.input?.action!=='move').map(c=>c.input.action),['down','up']); + await page.mouse.up();await clear(page); + await page.mouse.move(a.x-40,a.y);await page.mouse.down();await page.mouse.move(a.x+40,a.y); + await page.evaluate(()=>__uiFixture.emit({connection:'reconnecting'}));await clear(page); + await page.evaluate(()=>__uiFixture.emit({connection:'connected'})); + await page.mouse.move(a.x+60,a.y);await page.mouse.up(); + assert.deepEqual(await inputs(page),[],'interrupted gesture is discarded, not resumed'); +}); + +test('editing suppresses pointer/wheel input and workspace touch restores safe keyboard ownership',async t=>{ + const {page}=await fixture(t);const a=await area(page); + await page.evaluate(()=>{const e=document.createElement('input');e.id='local-test-editor';document.body.append(e);e.focus();});await clear(page); + await page.touchscreen.tap(a.x,a.y);await page.mouse.move(a.x,a.y);await page.mouse.wheel(1,20); + assert.deepEqual(await inputs(page),[],'no remote gestures while editing a prompt'); + await page.evaluate(()=>document.getElementById('local-test-editor').remove());await page.touchscreen.tap(a.x,a.y);await clear(page); + assert.equal(await page.evaluate(()=>document.activeElement.id),'viewport','workspace takes explicit keyboard focus'); + await page.keyboard.down('Enter');await page.keyboard.down('Enter');await page.keyboard.up('Enter'); + const keys=await inputs(page);assert.deepEqual(keys,[{type:'key',key:'Enter',modifiers:[]}],'hardware autorepeat never repeats Enter'); +}); + +test('trackpad two-finger pinch zooms locally without scrolling or a ghost tap',async t=>{ + const {page,context}=await fixture(t);const a=await area(page);await menu(page,'mode');await clear(page); + const touch=await touchSession(context,page); + await touch('touchStart',[[0,a.x-50,a.y],[1,a.x+50,a.y]]); + await touch('touchMove',[[0,a.x-90,a.y],[1,a.x+90,a.y]]); + await page.evaluate(()=>new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)))); + await touch('touchEnd',[[1,a.x+90,a.y]]);await touch('touchEnd',[]); + assert.deepEqual(await inputs(page),[],'pinch is local-only and never clicks'); + const view=await page.evaluate(()=>__viewEvents.at(-1));assert.ok(view&&view.scale>1.6&&view.scale<2,'trackpad pinch zooms'); + await page.touchscreen.tap(a.x,a.y); + assert.deepEqual((await inputs(page)).map(c=>c.input.action),['down','up'],'next independent tap still works'); +}); + +test('direct pinch keeps the off-centre content anchor and clamps local pan bounds',async t=>{ + const {page,context}=await fixture(t);const a=await area(page);const x=a.x+80,y=a.y; + const touch=await touchSession(context,page);await clear(page); + await page.touchscreen.tap(x,y);const before=(await inputs(page))[0].input;await clear(page); + await touch('touchStart',[[0,x-50,y],[1,x+50,y]]); + await touch('touchMove',[[0,x-100,y],[1,x+100,y]]); + await page.evaluate(()=>new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)))); + await touch('touchEnd',[]); + const view=await page.evaluate(()=>__viewEvents.at(-1)); + assert.ok(Math.abs(view.scale-2)<.02);assert.ok(Math.abs(view.offset.x+80)<2,'anchor compensation'); + assert.deepEqual(await inputs(page),[],'direct pinch is local-only'); + await page.touchscreen.tap(x,y);const after=(await inputs(page))[0].input; + assert.ok(Math.abs(before.x-after.x)<.003&&Math.abs(before.y-after.y)<.003,'same content remains under the pinch centre'); + await menu(page,'fit');await clear(page); + await touch('touchStart',[[0,a.x-50,y],[1,a.x+50,y]]); + await touch('touchMove',[[0,a.x-20,y+30],[1,a.x+80,y+30]]); + await touch('touchEnd',[]); + assert.deepEqual(await page.evaluate(()=>__viewEvents.at(-1)?.offset),{x:0,y:0},'fit cannot be panned outside the viewport'); +}); + +test('trackpad motion returning to its origin is not mistaken for a tap',async t=>{ + const {page,context}=await fixture(t);const a=await area(page);await menu(page,'mode');await clear(page); + const touch=await touchSession(context,page); + await touch('touchStart',[[0,a.x,a.y]]);await touch('touchMove',[[0,a.x+70,a.y]]);await touch('touchMove',[[0,a.x,a.y]]);await touch('touchEnd',[]); + const values=(await inputs(page)).map(c=>c.input); + assert.ok(values.length>0);assert.ok(values.every(e=>e.action==='move'),'movement never becomes a click on release'); +}); + +test('trackpad long-press drag releases once on touchcancel and cannot fire after a second finger',async t=>{ + const {page,context}=await fixture(t);const a=await area(page);await menu(page,'mode');await clear(page);const touch=await touchSession(context,page); + await touch('touchStart',[[0,a.x,a.y]]); + await page.waitForFunction(()=>__uiFixture.calls.some(c=>c.input?.action==='down'),{},{timeout:1500}); + await touch('touchMove',[[0,a.x+40,a.y+20]]);await touch('touchCancel',[]); + assert.deepEqual((await inputs(page)).filter(c=>c.input.action!=='move').map(c=>c.input.action),['down','up']); + assert.ok((await inputs(page)).some(c=>c.input.action==='move'&&c.input.drag===true),'held cursor moves as a drag'); + await clear(page);await touch('touchStart',[[0,a.x-30,a.y]]); + await touch('touchStart',[[0,a.x-30,a.y],[1,a.x+30,a.y]]);await touch('touchEnd',[]); + await page.waitForTimeout(550);assert.deepEqual(await inputs(page),[],'no delayed long-press after multitouch'); +}); + +test('editing and mode switches safely release an existing down before discarding its tail',async t=>{ + const {page}=await fixture(t);let a=await area(page);await page.evaluate(()=>{const e=document.createElement('input');e.id='local-test-editor';document.body.append(e);});await page.locator('#viewport').focus();a=await area(page);await clear(page); + await page.mouse.move(a.x-40,a.y);await page.mouse.down();await page.mouse.move(a.x+40,a.y); + await page.locator('#local-test-editor').focus(); + assert.deepEqual((await inputs(page)).filter(c=>c.input.action!=='move').map(c=>c.input.action),['down','up'],'editor focus releases held remote button'); + await page.mouse.up();await page.locator('#viewport').focus();await clear(page); + await page.mouse.move(a.x-40,a.y);await page.mouse.down();await page.mouse.move(a.x+40,a.y); + await page.evaluate(()=>document.querySelector('#mode').click()); + assert.deepEqual((await inputs(page)).filter(c=>c.input.action!=='move').map(c=>c.input.action),['down','up'],'switching mode releases with the original mode'); + await clear(page);await page.mouse.move(a.x+70,a.y);await page.mouse.up();assert.deepEqual(await inputs(page),[]); +}); + +test('Ctrl-wheel pinch zooms locally at the pointer instead of sending remote scroll',async t=>{ + const {page}=await fixture(t);const a=await area(page);await clear(page); + await page.mouse.move(a.x+60,a.y);await page.keyboard.down('Control');await page.mouse.wheel(0,-150);await page.keyboard.up('Control'); + await page.evaluate(()=>new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)))); + assert.deepEqual(await inputs(page),[],'browser trackpad pinch must not scroll the remote terminal'); + const v=await page.evaluate(()=>__viewEvents.at(-1));assert.ok(v?.scale>1);assert.ok(v.offset.x<0,'zoom anchor follows the wheel pointer'); +}); + +test('scroll deltas normalize wheel units and clamp the v2 payload',async t=>{ + const {page}=await fixture(t);const a=await area(page);await clear(page); + await page.mouse.move(a.x,a.y);await page.mouse.wheel(9999,-9999); + await page.waitForFunction(()=>__uiFixture.calls.some(c=>c.input?.type==='scroll')); + assert.deepEqual((await inputs(page))[0].input,{type:'scroll',x:.5,y:.5,dx:2000,dy:-2000}); + await clear(page); + await page.evaluate(()=>document.querySelector('#viewport').dispatchEvent(new WheelEvent('wheel',{clientX:500,clientY:300,deltaMode:1,deltaX:1,deltaY:2,bubbles:true,cancelable:true}))); + const line=(await inputs(page))[0].input;assert.equal(line.dx,16);assert.equal(line.dy,32); +}); + +test('two-finger trackpad translation scrolls once per centroid change and never clicks',async t=>{ + const {page,context}=await fixture(t);const a=await area(page);await menu(page,'mode');await clear(page);const touch=await touchSession(context,page); + await touch('touchStart',[[0,a.x-50,a.y],[1,a.x+50,a.y]]); + await touch('touchMove',[[0,a.x-30,a.y+30],[1,a.x+70,a.y+30]]); + await page.evaluate(()=>new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)))); + await touch('touchEnd',[[1,a.x+70,a.y+30]]);await touch('touchMove',[[1,a.x+80,a.y+40]]);await touch('touchEnd',[]); + assert.deepEqual((await inputs(page)).map(c=>c.input),[{type:'scroll',x:.5,y:.5,dx:-20,dy:-30}]); + assert.deepEqual(await page.evaluate(()=>__viewEvents),[],'translation does not change local zoom'); +}); + +test('Settings swipe scrolls under the finger while slider movement stays a drag',async t=>{ + const {page,context}=await fixture(t);const a=await area(page),touch=await touchSession(context,page); + await page.evaluate(()=>__uiFixture.emit({source:{target:'control',surface:'settings',dragRects:[]}}));await clear(page); + await touch('touchStart',[[0,a.x,a.y+40]]);await touch('touchMove',[[0,a.x,a.y-40]]);await touch('touchEnd',[]); + const scroll=(await inputs(page)).map(c=>c.input);assert.ok(scroll.length>0);assert.ok(scroll.every(c=>c.type==='scroll'));assert.ok(scroll[0].dy>0); + await page.evaluate(()=>__uiFixture.emit({source:{target:'control',surface:'settings',dragRects:[{x:0,y:0,width:1,height:1}]}}));await clear(page); + await touch('touchStart',[[0,a.x-40,a.y]]);await touch('touchMove',[[0,a.x+40,a.y]]);await touch('touchEnd',[]); + const slider=(await inputs(page)).map(c=>c.input);assert.deepEqual(slider.filter(c=>c.action!=='move').map(c=>c.action),['down','up']);assert.ok(slider.every(c=>c.type==='pointer')); +}); +test('Settings scrolling uses remote CSS units rather than high density video pixels',async t=>{ + const {page,context}=await fixture(t);const a=await area(page),touch=await touchSession(context,page); + async function swipe(width){ + await page.evaluate(width=>__uiFixture.emit({source:{target:'control',surface:'settings',viewport:{width,height:800},dragRects:[]}}),width);await clear(page); + await touch('touchStart',[[0,a.x,a.y+40]]);await touch('touchMove',[[0,a.x,a.y-40]]);await touch('touchEnd',[]); + return (await inputs(page)).filter(c=>c.input.type==='scroll').reduce((sum,c)=>sum+c.input.dy,0); + } + const normal=await swipe(1280),dense=await swipe(640);assert.ok(normal>0);assert.ok(Math.abs(dense*2-normal)<.001); +}); + +test('pointer geometry uses contained video content at fit and zoom across portrait/landscape',async t=>{ + const {page}=await fixture(t,{width:390,height:844}); + for(const size of [{width:390,height:844},{width:844,height:390},{width:1280,height:800}]){ + await page.setViewportSize(size); + await page.evaluate(()=>new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)))); + for(let zoom=0;zoom<2;zoom++){ + if(zoom)await menu(page,'fit'); + const g=await page.evaluate(()=>{const v=document.querySelector('#video'),r=v.getBoundingClientRect(),p=document.querySelector('#viewport').getBoundingClientRect(),f=Math.min(r.width/v.videoWidth,r.height/v.videoHeight);return {left:r.left+(r.width-v.videoWidth*f)/2,top:r.top+(r.height-v.videoHeight*f)/2,width:v.videoWidth*f,height:v.videoHeight*f,p:{left:p.left,top:p.top,width:p.width,height:p.height}};}); + const at={x:g.left+g.width*.6,y:g.top+g.height*.55};assert.ok(at.xc.input);assert.deepEqual(values.map(e=>e.action),['down','up'],JSON.stringify({size,zoom,at,g,dom:await page.evaluate(({x,y})=>({at:document.elementFromPoint(x,y)?.outerHTML,active:document.activeElement.id,innerWidth,innerHeight}),at)})); + for(const e of values){assert.ok(Math.abs(e.x-.6)<.006&&Math.abs(e.y-.55)<.006,'coordinates map to actual object-fit content');assert.equal(e.button,0);} + if(zoom)await menu(page,'fit'); + } + } +}); + +test('direct touch drag emits one down/move/up chain and cancel has no tap',async t=>{ + const {page,context}=await fixture(t);const a=await area(page);const touch=await touchSession(context,page);await clear(page); + await touch('touchStart',[[0,a.x-40,a.y]]);await touch('touchMove',[[0,a.x,a.y]]);await touch('touchMove',[[0,a.x+40,a.y+20]]);await touch('touchEnd',[]); + let values=(await inputs(page)).map(c=>c.input);assert.deepEqual(values.map(e=>e.action),['down','move','move','up']);assert.ok(values.every(e=>e.x>=0&&e.x<=1&&e.y>=0&&e.y<=1)); + await clear(page);await touch('touchStart',[[0,a.x,a.y]]);await touch('touchCancel',[]);assert.deepEqual(await inputs(page),[]); +}); + +test('sync throws and rejected API promises are consumed without input replay',async t=>{ + const {page}=await fixture(t);const a=await area(page);await clear(page); + await page.evaluate(()=>{CanvasLinkClient.sendInput=()=>Promise.reject(Error('fixture rejection'));CanvasLinkClient.sendText=()=>Promise.reject(Error('fixture rejection'));CanvasLinkClient.sendKey=()=>{throw Error('fixture sync error');};}); + await page.touchscreen.tap(a.x,a.y);await page.keyboard.press('a');await page.keyboard.press('Enter'); + await page.evaluate(()=>{CanvasLinkClient.sendInput=()=>{throw Error('fixture sync error');};}); + await page.touchscreen.tap(a.x,a.y);await page.waitForTimeout(100); + assert.deepEqual(await page.evaluate(()=>__unhandled),[]);assert.deepEqual(await inputs(page),[]); +}); + +test('capture acquisition failure cancels input without a browser exception',async t=>{ + const {page,errors}=await fixture(t);const a=await area(page);await clear(page); + await page.evaluate(()=>document.querySelector('#viewport').setPointerCapture=()=>{throw new DOMException('Fixture capture race','NotFoundError');}); + await page.mouse.move(a.x,a.y);await page.mouse.down();await page.mouse.move(a.x+40,a.y);await page.mouse.up(); + assert.deepEqual(await inputs(page),[]);assert.deepEqual(errors,[],'capture race is handled'); +}); + +test('a third finger suppresses the whole multitouch sequence until every contact ends',async t=>{ + const {page,context}=await fixture(t);const a=await area(page);const touch=await touchSession(context,page);await clear(page); + await touch('touchStart',[[0,a.x-50,a.y],[1,a.x+50,a.y]]); + await touch('touchStart',[[0,a.x-50,a.y],[1,a.x+50,a.y],[2,a.x,a.y+50]]); + await touch('touchEnd',[[0,a.x-50,a.y],[1,a.x+50,a.y]]); + await touch('touchMove',[[0,a.x-90,a.y],[1,a.x+90,a.y]]); + await page.evaluate(()=>new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)))); + await touch('touchEnd',[]); + assert.deepEqual(await inputs(page),[]);assert.deepEqual(await page.evaluate(()=>__viewEvents),[],'no resumed pair or zoom jump after third contact'); + await page.touchscreen.tap(a.x,a.y);assert.deepEqual((await inputs(page)).map(c=>c.input.action),['down','up']); +}); + +test('an open HTML dialog owns Escape and hardware keys instead of the terminal',async t=>{ + const {page}=await fixture(t);await clear(page); + const prevented=await page.evaluate(()=>{ + document.querySelector('#diagnostics').show();const v=document.querySelector('#viewport');v.focus(); + const e=new KeyboardEvent('keydown',{key:'Escape',bubbles:true,cancelable:true});v.dispatchEvent(e);return e.defaultPrevented; + }); + assert.equal(prevented,false,'gesture layer must not cancel dialog Escape');assert.deepEqual(await inputs(page),[]); + await page.evaluate(()=>document.querySelector('#diagnostics').close()); + for(const [button,dialog]of [['#diagnostics-toggle','#diagnostics'],['#disconnect','#disconnect-dialog']]){ + await page.locator('#menu-toggle').click();await page.locator(button).click();await page.waitForFunction(selector=>document.querySelector(selector).open,dialog); + await page.keyboard.press('Escape');await page.waitForFunction(selector=>!document.querySelector(selector).open,dialog); + } + assert.deepEqual(await inputs(page),[],'real native dialog Escape does not send a remote key'); +}); + +test('zoom stays bounded and survives composer/viewport resize without resetting the view',async t=>{ + const {page}=await fixture(t);const a=await area(page);await menu(page,'fit'); + const before=await page.locator('#video').evaluate(el=>el.style.transform); + await page.setViewportSize({width:1024,height:650}); + await page.evaluate(()=>new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)))); + assert.equal(await page.locator('#video').evaluate(el=>el.style.transform),before,'keyboard/layout changes preserve the user view'); + const v=await area(page);await clear(page); + await page.mouse.move(v.x,v.y);await page.keyboard.down('Control');await page.mouse.wheel(0,-99999); + await page.evaluate(()=>new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)))); + assert.equal(await page.evaluate(()=>__viewEvents.at(-1).scale),8); + await page.mouse.wheel(0,99999);await page.keyboard.up('Control'); + await page.evaluate(()=>new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)))); + assert.equal(await page.evaluate(()=>__viewEvents.at(-1).scale),1);assert.deepEqual(await inputs(page),[]); +}); + +test('trackpad cursor starts at the last direct tap and relative movement stays normalized',async t=>{ + const {page,context}=await fixture(t);const a=await area(page);await page.touchscreen.tap(a.x+40,a.y);const start=(await inputs(page))[0].input; + await menu(page,'mode');await clear(page);const touch=await touchSession(context,page); + await touch('touchStart',[[0,a.x,a.y]]);await touch('touchMove',[[0,a.x+50,a.y+20]]);await touch('touchEnd',[]); + const values=(await inputs(page)).map(c=>c.input);assert.ok(values.length>0&&values.every(v=>v.action==='move')); + assert.ok(Math.abs(values.at(-1).x-(start.x+50/a.width))<.005,'relative movement has no cursor jump'); + assert.ok(values.every(v=>v.x>=0&&v.x<=1&&v.y>=0&&v.y<=1)); +}); + +const inputs=page=>page.evaluate(()=>__uiFixture.calls.filter(c=>['input','key','text'].includes(c.type))); +const clear=page=>page.evaluate(()=>{__uiFixture.calls.length=0;__gestureEvents.length=0;__viewEvents.length=0;}); +async function area(page){const b=await page.locator('#viewport').boundingBox();assert.ok(b&&b.width>0&&b.height>0,'visible viewport');return {x:b.x+b.width/2,y:b.y+b.height/2,width:b.width,height:b.height,left:b.x,top:b.y};} +async function touchSession(context,page){ + const cdp=await context.newCDPSession(page); + return async(type,points)=>cdp.send('Input.dispatchTouchEvent',{type,touchPoints:points.map(([id,x,y])=>({id,x,y,radiusX:2,radiusY:2,force:1}))}); +} + +test('connection boundary blocks gestures and never replays them on reconnect',async t=>{ + const {page}=await fixture(t);const a=await area(page); + await page.evaluate(()=>__uiFixture.emit({connection:'reconnecting'}));await clear(page); + await page.touchscreen.tap(a.x,a.y);await page.mouse.move(a.x,a.y);await page.mouse.wheel(30,80); + await page.evaluate(()=>__uiFixture.emit({connection:'suspended'})); + await page.touchscreen.tap(a.x,a.y); + await page.evaluate(()=>__uiFixture.emit({connection:'connected'})); + assert.deepEqual(await inputs(page),[],'no offline call or delayed replay'); +}); + +test('hardware keys leave native button activation and editor/IME handling intact',async t=>{ + const {page}=await fixture(t);await clear(page); + await page.locator('#menu-toggle').click();await page.locator('#mode').focus();await page.keyboard.press('Enter'); + assert.equal(await page.locator('#mode').getAttribute('aria-pressed'),'true','Enter activates the UI button'); + assert.deepEqual(await inputs(page),[],'UI activation is not remote Enter'); + await page.evaluate(()=>{const e=document.createElement('input');e.id='local-test-editor';document.body.append(e);e.focus();}); + await page.keyboard.type('safe');await page.keyboard.press('Enter'); + assert.deepEqual(await inputs(page),[],'composer owns hardware input'); + await page.evaluate(()=>{ + const host=document.createElement('div');host.innerHTML='
edit
'; + document.body.append(host); + for(const selector of ['#test-select','#test-edit span']){ + document.querySelector(selector).dispatchEvent(new KeyboardEvent('keydown',{key:'Enter',bubbles:true,cancelable:true})); + } + const v=document.querySelector('#viewport');v.tabIndex=0;v.focus(); + v.dispatchEvent(new KeyboardEvent('keydown',{key:'Enter',isComposing:true,bubbles:true,cancelable:true})); + v.dispatchEvent(new KeyboardEvent('keydown',{key:'a',keyCode:229,bubbles:true,cancelable:true})); + }); + assert.deepEqual(await inputs(page),[],'select, contenteditable and IME are never intercepted'); +}); + +async function menu(page,id){await page.locator('#menu-toggle').click();await page.locator('#'+id).click();} diff --git a/integrations/canvaslink/tests/graphics-quality.test.cjs b/integrations/canvaslink/tests/graphics-quality.test.cjs new file mode 100644 index 000000000..36f0791f9 --- /dev/null +++ b/integrations/canvaslink/tests/graphics-quality.test.cjs @@ -0,0 +1,29 @@ +const {test}=require('node:test'),assert=require('node:assert/strict'); +const {createSnapshot}=require('../host/snapshot.cjs'),{rgba}=require('../host/frames.js'); +test('High density is configured at startup without changing CSS zoom or accepting unbounded factors',()=>{ + const {configureRenderScale}=require('../host/display-scale.cjs');let actual,exited=false; + const app={isReady:()=>false,relaunch:options=>actual=options,exit:code=>{assert.equal(code,0);exited=true;}}; + configureRenderScale(app,['preview','--private-profile']);assert.deepEqual(actual,{args:['--private-profile','--force-device-scale-factor=2']});assert(exited); + for(const scale of [1,2])configureRenderScale({...app,relaunch(){throw Error('Relaunch loop');}},['preview','--force-device-scale-factor='+scale]); + assert.throws(()=>configureRenderScale({...app,isReady:()=>true}),/requires-startup/); + for(const scale of [0,1.5,3,Infinity])assert.throws(()=>configureRenderScale(app,['preview','--force-device-scale-factor='+scale]),/invalid-render-scale/); +}); +test('Raw snapshots preserve Retina pixels without JPEG or PNG encoding',()=>{ + const image={isEmpty:()=>false,getScaleFactors:()=>[1,2],getSize:()=>({width:8,height:5}),toBitmap:options=>{assert.equal(options.scaleFactor,2);return Buffer.alloc(16*10*4);},toJPEG(){throw Error('Lossy intermediate');},toPNG(){throw Error('Unnecessary compression');}}; + const value=createSnapshot(image,{createFromBitmap(){throw Error('Unnecessary resize');}}); + assert.equal(value.width,16);assert.equal(value.height,10);assert.equal(value.format,'bgra'); +}); +test('Capture bounds apply to physical pixels, including high DPI',()=>{ + const image={isEmpty:()=>false,getScaleFactors:()=>[2],getSize:()=>({width:8,height:5}),toBitmap:()=>Buffer.alloc(16*10*4)}; + const value=createSnapshot(image,{createFromBitmap(pixels,options){assert.equal(options.scaleFactor,1);assert.equal(options.width,16);assert.equal(options.height,10);return {resize(size){assert.equal(size.width,8);assert.equal(size.height,5);return {toBitmap:()=>Buffer.alloc(8*5*4)};}};}},{maxWidth:8,maxHeight:5}); + assert.equal(value.width,8);assert.equal(value.height,5); +}); +test('Raw conversion preserves red/blue order and restores premultiplied alpha',()=>{ + const original=Uint8Array.from([0,0,255,255,255,0,0,255,10,20,40,128]); + assert.deepEqual([...rgba(original,3,1)],[255,0,0,255,0,0,255,255,80,40,20,128]);assert.equal(original[0],0); +}); +test('Malformed and unbounded pixel frames are rejected before canvas allocation',()=>{ + assert.throws(()=>rgba(new Uint8Array(4),2560,1600),/invalid-bitmap/); + assert.throws(()=>rgba(new Uint8Array(0),0,0),/invalid-bitmap/); + assert.throws(()=>rgba(new Uint8Array(2561*4),2561,1),/invalid-bitmap/); +}); diff --git a/integrations/canvaslink/tests/host-peer.test.cjs b/integrations/canvaslink/tests/host-peer.test.cjs new file mode 100644 index 000000000..7415f9597 --- /dev/null +++ b/integrations/canvaslink/tests/host-peer.test.cjs @@ -0,0 +1,67 @@ +const {test}=require('node:test'),assert=require('node:assert/strict'); +const fs=require('node:fs'),path=require('node:path'),vm=require('node:vm'); +const source=fs.readFileSync(path.join(__dirname,'../host/renderer.js'),'utf8'); +const tick=()=>new Promise(resolve=>setImmediate(resolve)); +async function host(){ + const elements=new Map(),captures=[],peers=[],signals=[],sessions=[],timers=[],fallbacks=[]; + const stream=()=>{const track={stopped:false,stop(){this.stopped=true;}};return {track,getVideoTracks:()=>[track],getTracks:()=>[track]};}; + const element=id=>{if(!elements.has(id))elements.set(id,{hidden:false,textContent:'',replaceChildren(){},append(){},getContext:()=>({}),captureStream:()=>{const s=stream();fallbacks.push(s);return s;}});return elements.get(id);}; + class Peer{ + constructor(){peers.push(this);this.iceGatheringState='complete';this.connectionState='new';} + addTrack(track){this.track=track;return {getParameters:()=>({encodings:[]}),replaceTrack:async value=>{this.track=value;}};} + async getStats(){return new Map([['video',{type:'outbound-rtp',kind:'video',framesEncoded:this.frames||0}]]);} + getTransceivers(){return [{setCodecPreferences(){}}];} + createDataChannel(){return this.channel={readyState:'connecting',close(){this.readyState='closed';},send(){}};} + async createOffer(){return {type:'offer',sdp:'owned-test-offer'};} + async setLocalDescription(value){this.localDescription=value;} + close(){this.closed=true;} + } + let event; + const context={window:{CanvasLinkFrames:require('../host/frames.js'),host:{state:async()=>({embedded:true,source:'Owned target',addresses:[],connected:[],width:100,height:100}),permissions:async()=>({platform:'linux'}),sources:async()=>[{id:'owned',name:'Owned target'}],select:async()=>{},onEvent:handler=>event=handler,report(){},captureFallback:async()=>{},inputSession:async(...args)=>sessions.push(args),signal:async(...args)=>signals.push(args)}},document:{getElementById:element,createElement:()=>({})},navigator:{mediaDevices:{getDisplayMedia:()=>new Promise((resolve,reject)=>captures.push({resolve,reject}))}},RTCPeerConnection:Peer,RTCRtpSender:{getCapabilities:()=>({codecs:[]})},setInterval(){},setTimeout:fn=>{const t={fn};timers.push(t);return t;},clearTimeout:t=>{if(t)t.cancelled=true;}}; + vm.runInNewContext(source,context);await tick(); + return {event,captures,peers,signals,sessions,stream,timers,fallbacks}; +} +test('Overlapping reconnect capture resolves only the newest generation',async()=>{ + const h=await host(),first=h.event({type:'signal',id:'device',message:{type:'ping'}});await tick(); + const second=h.event({type:'signal',id:'device',message:{type:'ping'}});await tick(); + assert.equal(h.captures.length,2);const old=h.stream(),current=h.stream(); + h.captures[1].resolve(current);await second;h.captures[0].resolve(old);await first; + assert.equal(h.peers.length,1,'a stale capture created a second peer'); + assert.equal(h.peers[0].track,current.track);assert.equal(old.track.stopped,true);assert.equal(current.track.stopped,false); + assert.equal(h.signals.length,1); +}); +test('A delayed ended event from the old track cannot close the new peer',async()=>{ + const h=await host();let run=h.event({type:'signal',id:'device',message:{type:'ping'}});await tick(); + const old=h.stream();h.captures[0].resolve(old);await run; + run=h.event({type:'signal',id:'device',message:{type:'ping'}});await tick(); + const current=h.stream();h.captures[1].resolve(current);await run; + old.track.onended();assert.equal(current.track.stopped,false);assert.equal(h.peers[1].closed,undefined); + assert.equal(h.signals.filter(([,value])=>value.type==='disconnect').length,0); +}); +test('A connected tab with no first frame switches to owned snapshot capture',async()=>{ + const h=await host(),run=h.event({type:'signal',id:'device',message:{type:'ping'}});await tick(); + const capture=h.stream();h.captures[0].resolve(capture);await run; + const p=h.peers[0];p.channel.onopen();await h.timers[0].fn(); + assert.equal(h.fallbacks.length,1);assert.equal(p.track,h.fallbacks[0].track); + assert.equal(capture.track.stopped,true);assert.equal(p.track.stopped,false); +}); +test('A tab that already encoded video keeps its original capture',async()=>{ + const h=await host(),run=h.event({type:'signal',id:'device',message:{type:'ping'}});await tick(); + const capture=h.stream();h.captures[0].resolve(capture);await run; + const p=h.peers[0];p.frames=1;p.channel.onopen();await h.timers[0].fn(); + assert.equal(h.fallbacks.length,0);assert.equal(p.track,capture.track);assert.equal(capture.track.stopped,false); +}); +test('Zero decoded-frame feedback recovers even when the sender encoded frames',async()=>{ + const h=await host(),run=h.event({type:'signal',id:'device',message:{type:'ping'}});await tick(); + const capture=h.stream();h.captures[0].resolve(capture);await run; + const p=h.peers[0];p.frames=8;p.channel.readyState='open';p.channel.onopen(); + p.channel.onmessage({data:JSON.stringify({type:'ping',at:1,videoFrames:0})});await h.timers[0].fn(); + assert.equal(h.fallbacks.length,1);assert.equal(capture.track.stopped,true); +}); +test('An old initial-frame watchdog cannot replace a reconnected capture',async()=>{ + const h=await host();let run=h.event({type:'signal',id:'device',message:{type:'ping'}});await tick();h.captures[0].resolve(h.stream());await run; + let resolve;h.peers[0].getStats=()=>new Promise(r=>resolve=r);h.peers[0].channel.onopen();const waiting=h.timers[0].fn(); + run=h.event({type:'signal',id:'device',message:{type:'ping'}});await tick();const capture=h.stream();h.captures[1].resolve(capture);await run; + resolve(new Map());await waiting; + assert.equal(h.timers[0].cancelled,true);assert.equal(h.fallbacks.length,0);assert.equal(h.peers[1].track,capture.track); +}); diff --git a/integrations/canvaslink/tests/icon.test.cjs b/integrations/canvaslink/tests/icon.test.cjs new file mode 100644 index 000000000..c135e1dd7 --- /dev/null +++ b/integrations/canvaslink/tests/icon.test.cjs @@ -0,0 +1,162 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const { spawnSync } = require('node:child_process'); +const root = path.resolve(__dirname, '..'); +const sharp = require(process.env.CANVASLINK_SHARP || 'sharp'); +const read = file => fs.readFileSync(path.join(root, file), 'utf8'); +const exists = file => fs.existsSync(path.join(root, file)); +const ns = 'http://schemas.android.com/apk/res/android'; + +function xml(text) { + const parsed = spawnSync('python3', ['-c', + 'import sys,json,xml.etree.ElementTree as E\nr=E.fromstring(sys.stdin.read())\nprint(json.dumps([{"tag":e.tag,"attrs":e.attrib,"text":e.text} for e in r.iter()]))'], + { input: text, encoding: 'utf8' }); + assert.equal(parsed.status, 0, `XML must be well formed: ${parsed.stderr}`); + return JSON.parse(parsed.stdout); +} + +function source() { + assert.ok(exists('design/canvaslink-icon.svg'), 'Original CanvasLink SVG is missing'); + const text = read('design/canvaslink-icon.svg'); + const nodes = xml(text); + return { text, nodes, paths: nodes.filter(node => node.tag.endsWith('}path')) }; +} + +function foregroundSvg(paths) { + return `${paths.map(p => ``).join('')}`; +} + +// Source geometry is checked independently of Android conversion and launcher masks. +test('icon: original two-window mark uses the UI palette and stays inside the 66dp safe circle', async () => { + const { text, nodes, paths } = source(); + assert.match(text, /SPDX-License-Identifier: MIT/); + assert.match(text, /Original geometry created for CanvasLink/); + assert.deepEqual(nodes[0].attrs.viewBox, '0 0 108 108'); + assert.deepEqual(paths.map(p => p.attrs.id), ['desktop-window', 'touch-window']); + assert.deepEqual(paths.map(p => p.attrs.fill.toLowerCase()), ['#edf1e6', '#d6ef88']); + const background = nodes.find(n => n.tag.endsWith('}rect')); + assert.equal(background.attrs.fill.toLowerCase(), '#121814'); + assert.equal(background.attrs.width, '108'); + assert.equal(background.attrs.height, '108'); + assert.ok(!/<(?:text|image|use|filter|linearGradient|radialGradient)\b|\b(?:stroke|transform)=/i.test(text), 'No fonts, imported art, effects or ambiguous stroked bounds'); + const { data, info } = await sharp(Buffer.from(foregroundSvg(paths)), { density: 720 }).ensureAlpha().raw().toBuffer({ resolveWithObject: true }); + let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity, maxRadius = 0, ink = 0; + const scale = info.width / 108; + for (let y = 0; y < info.height; y++) for (let x = 0; x < info.width; x++) { + if (data[(y * info.width + x) * 4 + 3] < 128) continue; + const px = (x + .5) / scale, py = (y + .5) / scale; + minX = Math.min(minX, px); maxX = Math.max(maxX, px); + minY = Math.min(minY, py); maxY = Math.max(maxY, py); + maxRadius = Math.max(maxRadius, Math.hypot(px - 54, py - 54)); + ink++; + } + assert.ok(ink > 0, 'Foreground must not be empty'); + assert.ok(maxRadius <= 33, `Critical mark exceeds 66dp safe circle: radius ${maxRadius}`); + assert.ok(maxX - minX >= 46 && maxX - minX <= 54, 'Two-window silhouette needs a strong, centered width'); + assert.ok(maxY - minY >= 44 && maxY - minY <= 50, 'Two-window silhouette needs a strong, centered height'); + assert.ok(Math.abs((minX + maxX) / 2 - 54) < 1, 'Horizontal optical center'); + assert.ok(Math.abs((minY + maxY) / 2 - 54) < 1, 'Vertical optical center'); +}); + +test('icon: API26 adaptive resources reproduce the source without changing existing identifiers or colors', () => { + const { paths } = source(); + const foreground = xml(read('android/res/drawable/ic_launcher_foreground.xml')); + const attr = key => `{${ns}}${key}`; + assert.equal(foreground[0].tag, 'vector'); + for (const dimension of ['width', 'height']) assert.equal(foreground[0].attrs[attr(dimension)], '108dp'); + for (const dimension of ['viewportWidth', 'viewportHeight']) assert.equal(foreground[0].attrs[attr(dimension)], '108'); + const vectorPaths = foreground.filter(n => n.tag === 'path'); + assert.equal(vectorPaths.length, 2, 'Adaptive foreground must contain both linked window rails'); + vectorPaths.forEach((p, index) => { + assert.equal(p.attrs[attr('pathData')], paths[index].attrs.d, 'Android and SVG geometry must be identical'); + assert.equal(p.attrs[attr('fillColor')].toLowerCase(), paths[index].attrs.fill.toLowerCase()); + assert.ok(!p.attrs[attr('strokeWidth')], 'Use unambiguous filled contours'); + }); + const adaptive = xml(read('android/res/mipmap-anydpi-v26/ic_launcher.xml')); + assert.equal(adaptive[0].tag, 'adaptive-icon'); + assert.equal(adaptive.find(n => n.tag === 'background').attrs[attr('drawable')], '@color/icon_background'); + assert.equal(adaptive.find(n => n.tag === 'foreground').attrs[attr('drawable')], '@drawable/ic_launcher_foreground'); + assert.ok(!adaptive.some(n => n.tag === 'monochrome'), 'API26 resource must not depend on API33 tags'); + const colors = xml(read('android/res/values/colors.xml')); + assert.equal(colors.find(n => n.attrs.name === 'icon_background').text.trim(), '#121814'); + const manifest = xml(read('android/AndroidManifest.xml')); + const app = manifest.find(n => n.tag === 'application'); + assert.equal(app.attrs[attr('icon')], '@mipmap/ic_launcher'); + assert.equal(app.attrs[attr('roundIcon')], '@mipmap/ic_launcher'); + +}); + +test('icon: API33 themed icon uses the same safe silhouette without a filled background', () => { + assert.ok(exists('android/res/mipmap-anydpi-v33/ic_launcher.xml'), 'API33 adaptive icon with monochrome layer is missing'); + assert.ok(exists('android/res/drawable/ic_launcher_monochrome.xml'), 'Themed foreground vector is missing'); + const attr = key => `{${ns}}${key}`; + const adaptive = xml(read('android/res/mipmap-anydpi-v33/ic_launcher.xml')); + assert.equal(adaptive[0].tag, 'adaptive-icon'); + assert.equal(adaptive.find(n => n.tag === 'background').attrs[attr('drawable')], '@color/icon_background'); + assert.equal(adaptive.find(n => n.tag === 'foreground').attrs[attr('drawable')], '@drawable/ic_launcher_foreground'); + assert.equal(adaptive.find(n => n.tag === 'monochrome').attrs[attr('drawable')], '@drawable/ic_launcher_monochrome'); + const monochrome = xml(read('android/res/drawable/ic_launcher_monochrome.xml')); + assert.equal(monochrome[0].tag, 'vector'); + assert.equal(monochrome[0].attrs[attr('viewportWidth')], '108'); + assert.equal(monochrome[0].attrs[attr('viewportHeight')], '108'); + const rails = monochrome.filter(n => n.tag === 'path'); + assert.equal(rails.length, 2, 'Themed icon must not add a solid-background path'); + rails.forEach((rail, index) => { + assert.equal(rail.attrs[attr('pathData')], source().paths[index].attrs.d); + assert.equal(rail.attrs[attr('fillColor')].toLowerCase(), '#ffffff'); + }); +}); + +test('icon: production-vector previews preserve every rail under square, circle and squircle masks at 24–512px', async () => { + assert.ok(exists('verification/icon-preview.cjs'), 'Reproducible Android-vector preview renderer is missing'); + const rendered = spawnSync(process.execPath, ['verification/icon-preview.cjs'], { cwd: root, encoding: 'utf8' }); + assert.equal(rendered.status, 0, rendered.stderr); + const manifest = JSON.parse(read('verification/icon-previews.json')); + assert.deepEqual(manifest.adaptiveViewport, { x: 18, y: 18, size: 72, layerSize: 108 }); + assert.equal(manifest.geometrySource, 'android/res/drawable/ic_launcher_foreground.xml'); + const expected = [24, 32, 48, 64, 128, 512].flatMap(size => ['square', 'circle', 'squircle'].map(mask => `${mask}-${size}`)); + assert.deepEqual(manifest.previews.map(p => `${p.mask}-${p.width}`), expected); + const crypto = require('node:crypto'); + for (const preview of manifest.previews) { + const bytes = fs.readFileSync(path.join(root, preview.file)); + assert.equal(crypto.createHash('sha256').update(bytes).digest('hex'), preview.sha256, preview.file); + const metadata = await sharp(bytes).metadata(); + assert.equal(metadata.width, preview.width); + assert.equal(metadata.height, preview.width); + const { data, info } = await sharp(bytes).ensureAlpha().raw().toBuffer({ resolveWithObject: true }); + const pixel = (x, y) => [...data.subarray((y * info.width + x) * 4, (y * info.width + x) * 4 + 4)]; + assert.equal(pixel(0, 0)[3], preview.mask === 'square' ? 255 : 0); + assert.equal(pixel(Math.floor(info.width / 2), Math.floor(info.width / 2))[3], 255); + if (preview.width >= 128) { + const diagonalAlpha = pixel(Math.floor(info.width * .1), Math.floor(info.width * .1))[3]; + assert.equal(diagonalAlpha, preview.mask === 'circle' ? 0 : 255, 'Mask must match circle vs exponent-4 squircle'); + } + if (preview.width === 24) { + let cream = 0, lime = 0; + for (let i = 0; i < data.length; i += 4) { + if (data[i] > 200 && data[i + 1] > 200 && data[i + 2] > 190 && data[i + 3] > 200) cream++; + if (data[i] > 170 && data[i + 1] > 200 && data[i + 2] < 170 && data[i + 3] > 200) lime++; + } + assert.ok(cream >= 15 && lime >= 15, `Both rails must remain visible at 24px: cream=${cream}, lime=${lime}`); + } + } + const raw = async mask => sharp(path.join(root, `verification/icon-${mask}-512.png`)).ensureAlpha().raw().toBuffer(); + const square = await raw('square'); + for (const mask of ['circle', 'squircle']) { + const masked = await raw(mask); + let checked = 0; + for (let i = 0; i < square.length; i += 4) { + if (square[i] === 18 && square[i + 1] === 24 && square[i + 2] === 20) continue; + assert.deepEqual(masked.subarray(i, i + 4), square.subarray(i, i + 4), `${mask} must not clip any foreground pixel`); + checked++; + } + assert.ok(checked > 1000); + } + const sheet = await sharp(path.join(root, 'verification/icon-contact-sheet.png')).metadata(); + assert.equal(sheet.width, 1320); + assert.equal(sheet.height, 1080); +}); diff --git a/integrations/canvaslink/tests/owned-focus.test.cjs b/integrations/canvaslink/tests/owned-focus.test.cjs new file mode 100644 index 000000000..789291011 --- /dev/null +++ b/integrations/canvaslink/tests/owned-focus.test.cjs @@ -0,0 +1,7 @@ +const {test}=require('node:test'),assert=require('node:assert/strict'); +const {focusOwnedWindow}=require('../host/owned-focus.cjs'); +function fixture(){let tick=0,focused=false,active=true,requests=0,renderer=0;return {app:{focus(){requests++;}},target:{isDestroyed:()=>false,isMinimized:()=>false,isFocused:()=>focused,show(){},focus(){},webContents:{focus(){renderer++;}}},active:()=>active,now:()=>tick,wait:async ms=>{tick+=ms;if(tick>=60)focused=true;},revoke(){active=false;},get requests(){return requests;},get renderer(){return renderer;}};} +test('Own window activation can settle asynchronously before renderer input is focused',async()=>{const f=fixture();await focusOwnedWindow(f);assert.equal(f.requests,1);assert.equal(f.renderer,1);}); +test('Revocation while awaiting activation cannot focus the input renderer',async()=>{const f=fixture();f.wait=async()=>{f.revoke();};await assert.rejects(focusOwnedWindow(f),/session-ended/);assert.equal(f.renderer,0);}); +test('An unactivatable own window fails within the bounded wait',async()=>{const f=fixture();let tick=0;f.now=()=>tick;f.wait=async ms=>{tick+=ms;};await assert.rejects(focusOwnedWindow({...f,timeout:40}),/canvas-focus-required/);assert.equal(f.renderer,0);assert.equal(f.requests,1);}); +test('A child-window resignation can be followed by another bounded own activation',async()=>{const f=fixture();let tick=0;f.now=()=>tick;f.target.isFocused=()=>f.requests>=2;f.wait=async ms=>{tick+=ms;};await focusOwnedWindow(f);assert.equal(f.requests,2);assert.equal(f.renderer,1);}); diff --git a/integrations/canvaslink/tests/owned-pointer.test.cjs b/integrations/canvaslink/tests/owned-pointer.test.cjs new file mode 100644 index 000000000..f7595688a --- /dev/null +++ b/integrations/canvaslink/tests/owned-pointer.test.cjs @@ -0,0 +1,16 @@ +const {test}=require('node:test'),assert=require('node:assert/strict'); +const {OwnedPointer}=require('../host/owned-pointer.cjs'); +test('revocation releases both held buttons at their latest owned-renderer position',()=>{ + const events=[],contents={isDestroyed:()=>false,sendInputEvent:e=>events.push(e)},p=new OwnedPointer(); + p.send(contents,{type:'mouseDown',button:'left',x:1,y:2});p.send(contents,{type:'mouseDown',button:'right',x:1,y:2}); + p.send(contents,{type:'mouseMove',button:'left',x:8,y:9,modifiers:['leftButtonDown']});p.release();p.release(); + assert.deepEqual(events.slice(3).map(e=>[e.type,e.button,e.x,e.y,e.modifiers]),[['mouseUp','left',8,9,[]],['mouseUp','right',8,9,[]]]); +}); +test('a released or destroyed target cannot receive a duplicate cancellation event',()=>{ + const events=[];let destroyed=false;const contents={isDestroyed:()=>destroyed,sendInputEvent:e=>events.push(e)},p=new OwnedPointer(); + p.send(contents,{type:'mouseDown',button:'left'});p.send(contents,{type:'mouseUp',button:'left'});p.release();assert.equal(events.length,2); + p.send(contents,{type:'mouseDown',button:'right'});destroyed=true;p.release();assert.equal(events.length,3);assert.equal(p.held.size,0); +}); +test('failed mouseDown is never recorded as an applied press',()=>{ + const p=new OwnedPointer();assert.throws(()=>p.send({sendInputEvent(){throw Error('closed');}},{type:'mouseDown',button:'left'}));assert.equal(p.held.size,0); +}); diff --git a/integrations/canvaslink/tests/remote-select.test.cjs b/integrations/canvaslink/tests/remote-select.test.cjs new file mode 100644 index 000000000..f316213f7 --- /dev/null +++ b/integrations/canvaslink/tests/remote-select.test.cjs @@ -0,0 +1,8 @@ +const {test}=require('node:test'); +const assert=require('node:assert/strict'); +const {openUI}=require('../scripts/ui-test-harness.cjs'); +const {selectPointerScript,closeSelectScript}=require('../host/remote-select.cjs'); +async function fixture(t){const ui=await openUI();t.after(()=>ui.browser.close());await ui.page.setContent('');await ui.page.evaluate(()=>{window.changes=0;document.querySelector('select').addEventListener('change',()=>changes++);});return ui;} +test('Remote select renders options in the captured page and actual clicks dispatch change',async t=>{const ui=await fixture(t);assert.equal(await ui.page.evaluate(selectPointerScript(50,40)),true);assert.equal(await ui.page.getByRole('listbox').getByRole('option',{name:'B',exact:true}).isDisabled(),true);assert.equal(await ui.page.getByRole('listbox').locator('button').nth(2).isDisabled(),true);await ui.page.getByRole('listbox').getByRole('option',{name:'',exact:true}).click();assert.equal(await ui.page.locator('select').inputValue(),'');assert.equal(await ui.page.evaluate(()=>changes),1);assert.equal(await ui.page.getByRole('listbox').count(),0);}); +test('Remote select keys skip disabled options; Escape, outside tap and disconnect dismiss safely',async t=>{const ui=await fixture(t);await ui.page.evaluate(selectPointerScript(50,40));await ui.page.keyboard.press('ArrowDown');await ui.page.keyboard.press('Enter');assert.equal(await ui.page.locator('select').inputValue(),'');await ui.page.evaluate(selectPointerScript(50,40));await ui.page.keyboard.press('ArrowUp');await ui.page.keyboard.press('Escape');assert.equal(await ui.page.locator('select').inputValue(),'');await ui.page.evaluate(selectPointerScript(50,40));await ui.page.locator('#outside').click();assert.equal(await ui.page.getByRole('listbox').count(),0);await ui.page.evaluate(selectPointerScript(50,40));await ui.page.evaluate(closeSelectScript);assert.equal(await ui.page.getByRole('listbox').count(),0);assert.equal(await ui.page.evaluate(()=>changes),1);}); +test('Ordinary pointers and disabled selects retain normal browser handling',async t=>{const ui=await fixture(t);assert.equal(await ui.page.evaluate(selectPointerScript(600,400)),false);await ui.page.locator('select').evaluate(e=>e.disabled=true);assert.equal(await ui.page.evaluate(selectPointerScript(50,40)),false);}); diff --git a/integrations/canvaslink/tests/security.test.cjs b/integrations/canvaslink/tests/security.test.cjs new file mode 100644 index 000000000..be3a59899 --- /dev/null +++ b/integrations/canvaslink/tests/security.test.cjs @@ -0,0 +1,55 @@ +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const srp = require('secure-remote-password/client'); +const crypto = require('node:crypto'); +const { Pairing, seal, open, USER, privateAddress } = require('../host/security.cjs'); +const { validateInput } = require('../host/native-input.cjs'); +function authenticate(p, pin=p.pin) { + const a=srp.generateEphemeral(); const start=p.start({A:a.public,name:'Synthetic Android'},'192.168.2.23'); + const client=srp.deriveSession(a.secret,start.B,start.salt,USER,srp.derivePrivateKey(start.salt,USER,pin)); + const finish=p.finish({id:start.id,proof:client.proof},'192.168.2.23');srp.verifySession(a.public,client,finish.proof); + const key=crypto.createHash('sha256').update(Buffer.from(client.key,'hex')).update('CanvasLink/channel/1').digest(); + return {id:finish.id,key}; +} +test('SRP mutual authentication, encrypted request/reply, one use PIN',()=>{ + const p=new Pairing(),pin=p.pin,c=authenticate(p); + assert.equal(p.pin,'');assert.throws(()=>authenticate(p,pin)); + const packet=seal(c.key,c.id,1,'request',{type:'poll'}); + const response=p.receive(packet,'192.168.2.23',()=>({ok:true})); + assert.deepEqual(open(c.key,c.id,response,'response'),{ok:true}); + assert.throws(()=>p.receive(packet,'192.168.2.23',()=>({ok:true})),/unauthorized/); +}); +test('wrong PIN, expiration, host revocation and address changes fail closed',()=>{ + let now=1000;const p=new Pairing({now:()=>now}); + assert.throws(()=>authenticate(p,p.pin==='000000'?'000001':'000000')); + now+=120001;assert.throws(()=>authenticate(p)); + p.rotate();const c=authenticate(p),packet=seal(c.key,c.id,1,'request',{type:'poll'}); + assert.throws(()=>p.receive(packet,'192.168.2.99',()=>({ok:true}))); + p.rotate();assert.throws(()=>p.receive(packet,'192.168.2.23',()=>({ok:true}))); +}); +test('tampering, reflection and untrusted input do not become actions',()=>{ + const p=new Pairing(),c=authenticate(p),packet=seal(c.key,c.id,1,'request',{type:'poll'}); + let calls=0;const corrupt={...packet,data:Buffer.alloc(80).toString('base64')}; + assert.throws(()=>p.receive(corrupt,'192.168.2.23',()=>{calls++;}));assert.equal(calls,0); + assert.throws(()=>open(c.key,c.id,packet,'response')); + for(const input of [{type:'exec',command:'sh'},{type:'text',text:'x'.repeat(8001)},{type:'pointer',x:2,y:0,button:0,action:'down'},{type:'key',key:'unknown',modifiers:[]}])assert.throws(()=>validateInput(input)); + validateInput({type:'text',text:'Проверка'}); +}); +test('only private IPv4 and loopback peers are accepted',()=>{ + for(const a of ['127.0.0.1','::1','::ffff:192.168.2.23','10.1.2.3','172.31.2.3'])assert.equal(privateAddress(a),true); + for(const a of ['8.8.8.8','192.168.999.1','example.com','172.32.2.3',''])assert.equal(privateAddress(a),false); +}); +test('pairing attempts are globally bounded, not reset by a new peer address',()=>{ + const p=new Pairing();for(let i=0;i<5;i++)assert.throws(()=>authenticate(p,p.pin==='000000'?'000001':'000000')); + assert.throws(()=>authenticate(p),/pairing-closed/); + p.rotate();const ephemeral=srp.generateEphemeral();for(let i=0;i<30;i++)p.start({A:ephemeral.public,name:'Synthetic'},'192.168.2.'+(i+1)); + assert.throws(()=>p.start({A:ephemeral.public,name:'Synthetic'},'192.168.2.99'),/pairing-closed/); +}); +test('counter gaps after Android crash are accepted; old replies and expired sessions are not',()=>{ + let now=1000;const p=new Pairing({now:()=>now}),c=authenticate(p); + const packet=seal(c.key,c.id,10001,'request',{type:'poll'}); + const response=p.receive(packet,'192.168.2.23',()=>({ok:true}));assert.equal(response.seq,10001); + assert.throws(()=>p.receive(seal(c.key,c.id,2,'request',{}),'192.168.2.23',()=>({})),/unauthorized/); + now+=12*3600000; + assert.throws(()=>p.receive(seal(c.key,c.id,10002,'request',{}),'192.168.2.23',()=>({})),/unauthorized/); +}); diff --git a/integrations/canvaslink/tests/settings-host.test.cjs b/integrations/canvaslink/tests/settings-host.test.cjs new file mode 100644 index 000000000..20a06e076 --- /dev/null +++ b/integrations/canvaslink/tests/settings-host.test.cjs @@ -0,0 +1,26 @@ +const {test}=require('node:test'),assert=require('node:assert/strict'); +const {installSettingsHost}=require('../host/settings-host.cjs'); +function fixture(command=async action=>action){ + const handlers=new Map(),mainFrame={},contents={mainFrame},target={webContents:contents,isDestroyed:()=>false}; + installSettingsHost({ipcMain:{handle:(name,handler)=>handlers.set(name,handler)},getWindow:()=>target,snapshot:()=>({enabled:false}),command}); + return {handlers,target,event:{sender:contents,senderFrame:mainFrame}}; +} +test('Settings state and commands reject other renderers, child frames and unknown actions',()=>{ + const f=fixture();for(const channel of ['canvaslink-settings:state','canvaslink-settings:command']){ + for(const event of [{sender:{},senderFrame:f.event.senderFrame},{sender:f.event.sender,senderFrame:{}}])assert.throws(()=>f.handlers.get(channel)(event,'enable'),/untrusted-settings-renderer/); + } + assert.throws(()=>f.handlers.get('canvaslink-settings:command')(f.event,'shell'),/invalid-settings-command/); + assert.deepEqual(f.handlers.get('canvaslink-settings:state')(f.event),{enabled:false}); +}); +test('Settings operations are serialized and a failed enable does not block later commands',async()=>{ + const order=[];let release;const gate=new Promise(resolve=>release=resolve); + const f=fixture(async action=>{order.push(action);if(action==='enable'){await gate;throw Error('bind failed');}return action;}); + const command=f.handlers.get('canvaslink-settings:command');const first=command(f.event,'enable'),second=command(f.event,'disable'); + await new Promise(resolve=>setImmediate(resolve));assert.deepEqual(order,['enable']);release();await assert.rejects(first,/bind failed/);assert.equal(await second,'disable');assert.deepEqual(order,['enable','disable']); +}); +test('A queued settings operation cannot execute after its owning window closes',async()=>{ + let release;const gate=new Promise(resolve=>release=resolve),order=[]; + const f=fixture(async action=>{order.push(action);if(action==='enable')await gate;});const command=f.handlers.get('canvaslink-settings:command'); + const first=command(f.event,'enable'),second=command(f.event,'disable');await new Promise(resolve=>setImmediate(resolve)); + f.target.isDestroyed=()=>true;release();await first;await assert.rejects(second,/untrusted-settings-renderer/);assert.deepEqual(order,['enable']); +}); diff --git a/integrations/canvaslink/tests/settings-touch.test.cjs b/integrations/canvaslink/tests/settings-touch.test.cjs new file mode 100644 index 000000000..99dd465b0 --- /dev/null +++ b/integrations/canvaslink/tests/settings-touch.test.cjs @@ -0,0 +1,39 @@ +const {test}=require('node:test'); +const assert=require('node:assert/strict'); +const {openUI}=require('../scripts/ui-test-harness.cjs'); +const {focusScript}=require('../host/focus.cjs'); +test('Only actual text inputs are editable; Settings controls do not summon a text editor',async t=>{ + const ui=await openUI();t.after(()=>ui.browser.close()); + await ui.page.setContent('
x
'); + for(const [id,editable]of Object.entries({range:false,check:false,color:false,file:false,select:false,button:false,text:true,password:true,area:true,readonly:false,rich:true})){ + await ui.page.locator('#'+id).focus();const value=await ui.page.evaluate(focusScript);assert.equal(value.editable,editable,id);assert.equal(value.surface,'settings');assert.ok(value.dragRects.length===1); + } +}); +test('Screen arrows, Enter, Esc and Ctrl work after selecting a nontext control',async t=>{ + const ui=await openUI({connected:true,touch:true,width:390,height:844});t.after(()=>ui.browser.close()); + await ui.page.evaluate(()=>__uiFixture.configure({focus:{kind:'control',editable:false,surface:'settings'}}));await ui.page.locator('#viewport').click(); + assert.equal(await ui.page.evaluate(()=>CanvasLinkInput.active()),false); + for(const key of ['ArrowDown','Enter','Escape'])await ui.page.locator('[data-key='+key+']').click(); + await ui.page.locator('#ctrl').click();await ui.page.locator('[data-key=c]').click(); + const keys=await ui.page.evaluate(()=>__uiFixture.calls.filter(c=>c.type==='input'&&c.input.type==='key').map(c=>c.input)); + assert.deepEqual(keys.map(c=>c.key),['ArrowDown','Enter','Escape','c']);assert.deepEqual(keys[3].modifiers,['control']); + assert.equal(await ui.page.evaluate(()=>__uiFixture.calls.filter(c=>c.type==='show-keyboard').length),0); +}); +test('Ctrl+V remains visible without horizontal scrolling on a narrow phone',async t=>{ + const ui=await openUI({connected:true,width:320,height:640});t.after(()=>ui.browser.close()); + const r=await ui.page.locator('#paste').boundingBox();assert.ok(r.x>=0&&r.x+r.width<=320);assert.equal(await ui.page.locator('#paste').textContent(),'Ctrl+V'); + await ui.page.locator('#paste').click();assert.match(await ui.page.locator('#input-status').textContent(),/коснитесь.*поля/);assert.equal(await ui.page.evaluate(()=>__uiFixture.calls.filter(c=>c.type==='input').length),0); +}); +test('A Settings text field preserves context instead of terminal-style zoom',async t=>{ + const ui=await openUI({connected:true,touch:true});t.after(()=>ui.browser.close()); + await ui.page.evaluate(()=>__uiFixture.configure({focus:{editable:true,kind:'text-field',surface:'settings',rect:{x:.7,y:.5,width:.08,height:.03}}})); + await ui.page.locator('#viewport').click();const zoom=parseInt(await ui.page.locator('#zoom-label').textContent());assert.ok(zoom>=100&&zoom<=400);assert.equal(await ui.page.evaluate(()=>CanvasLinkInput.active()),true); +}); +test('Text fields remain readable with IME and restore overview when editing ends',async t=>{ + const ui=await openUI({connected:true,touch:true,width:1280,height:800});t.after(()=>ui.browser.close()); + await ui.page.evaluate(()=>__uiFixture.configure({focus:{editable:true,kind:'text-field',surface:'settings',fontSize:12,rect:{x:.35,y:.68,width:.21,height:.033},contextRect:{x:.34,y:.67,width:.45,height:.14}}})); + await ui.page.locator('#viewport').click();await ui.page.setViewportSize({width:1280,height:320});await ui.page.waitForTimeout(100); + const geometry=await ui.page.evaluate(()=>{const v=document.getElementById('video'),r=v.getBoundingClientRect();return {font:12*Math.min(r.width/v.videoWidth,r.height/v.videoHeight),scale:new DOMMatrix(getComputedStyle(v).transform).a};}); + assert.ok(geometry.font>=12,JSON.stringify(geometry));assert.ok(geometry.scale<=4); + await ui.page.evaluate(()=>CanvasLinkInput.stop());assert.equal(await ui.page.locator('#zoom-label').textContent(),'100%'); +}); diff --git a/integrations/canvaslink/tests/transport-recovery.test.cjs b/integrations/canvaslink/tests/transport-recovery.test.cjs new file mode 100644 index 000000000..d428492b4 --- /dev/null +++ b/integrations/canvaslink/tests/transport-recovery.test.cjs @@ -0,0 +1,41 @@ +const {test}=require('node:test'),assert=require('node:assert/strict'),vm=require('node:vm'),fs=require('node:fs'),path=require('node:path'); +const source=fs.readFileSync(path.join(__dirname,'../android/assets/transport.js'),'utf8'); +function client(){ + const signals=[],sent=[],peers=[],intervals=[]; + class Peer{ + constructor(){peers.push(this);this.connectionState='new';this.iceGatheringState='complete';} + async setRemoteDescription(){} async createAnswer(){return {type:'answer',sdp:'local'};} async setLocalDescription(value){this.localDescription=value;} close(){} + } + const context={window:{},document:{hidden:false,addEventListener(){}},Native:{signal:json=>signals.push(JSON.parse(json)),diagnostics(){},discover(){},restore(){},loadDraft(){}},CanvasLinkDelivery:{Delivery:class{close(){}send(value){sent.push(value);return Promise.resolve();}receive(){}}},RTCPeerConnection:Peer,setInterval:fn=>intervals.push(fn),setTimeout(){},performance:{now:()=>1}}; + vm.runInNewContext(source,context);return {...context,signals,sent,peers,intervals,receive:context.window.CanvasLink.receive,api:context.window.CanvasLinkClient}; +} +test('Successful encrypted polling after an outage retries signalling without replaying input',()=>{ + const c=client();c.receive({type:'paired',value:{host:'192.168.2.22:3497'}});c.receive({type:'network',value:'offline'}); + c.receive({type:'network-restored',value:true});assert.deepEqual(c.signals,[{type:'ping'}]);assert.deepEqual(c.sent,[]); +}); +test('Network recovery cannot resume an unpaired or background client',()=>{ + const c=client();c.receive({type:'network-restored',value:true});c.receive({type:'paired',value:{host:'local'}});c.document.hidden=true;c.receive({type:'network-restored',value:true});assert.deepEqual(c.signals,[]); +}); +test('An existing open data channel that recovers clears reconnecting status',async()=>{ + const c=client();c.receive({type:'paired',value:{host:'local'}});c.receive({type:'signal',value:{type:'offer',sdp:'remote'}});await new Promise(resolve=>setImmediate(resolve)); + const p=c.peers[0],channel={readyState:'open'};p.ondatachannel({channel});channel.onopen();c.receive({type:'network',value:'offline'});assert.equal(c.api.getState().connection,'reconnecting'); + p.connectionState='connected';p.onconnectionstatechange();assert.equal(c.api.getState().connection,'connected');assert.equal(c.api.getState().error,''); +}); +test('Closing a peer clears its video counters and target status before reconnect',async()=>{ + const c=client();c.receive({type:'paired',value:{host:'local'}});c.receive({type:'signal',value:{type:'offer',sdp:'remote'}});await new Promise(resolve=>setImmediate(resolve)); + const peer=c.peers[0],channel={readyState:'open',send(){}};peer.ondatachannel({channel});channel.onopen(); + channel.onmessage({data:JSON.stringify({type:'source-state',capture:'active',windowFocused:true,target:'terminal'})}); + peer.getStats=async()=>new Map([['video',{type:'inbound-rtp',kind:'video',framesDecoded:17}]]);await c.intervals[0](); + assert.equal(c.api.getState().stats.framesDecoded,17);assert.equal(c.api.getState().source.target,'terminal'); + c.receive({type:'signal',value:{type:'disconnect'}}); + assert.equal(c.api.getState().source,null);assert.equal(Object.keys(c.api.getState().stats).length,0); + assert.equal(Object.keys(c.window.CanvasLink.stats).length,0); +}); +test('Video feedback reports zero before decoding and the current peer frame count after decoding',async()=>{ + const c=client();c.receive({type:'paired',value:{host:'local'}});c.receive({type:'signal',value:{type:'offer',sdp:'remote'}});await new Promise(resolve=>setImmediate(resolve)); + const packets=[],p=c.peers[0],channel={readyState:'open',bufferedAmount:0,send:s=>packets.push(JSON.parse(s))};p.ondatachannel({channel});channel.onopen(); + p.getStats=async()=>new Map([['v',{type:'inbound-rtp',kind:'video',framesDecoded:7}]]); + await c.intervals[0]();await c.intervals[0]();assert.equal(packets[0].videoFrames,0);assert.equal(packets[1].videoFrames,7); + c.receive({type:'signal',value:{type:'offer',sdp:'new'}});await new Promise(resolve=>setImmediate(resolve)); + const fresh=c.peers[1];fresh.ondatachannel({channel});fresh.getStats=async()=>new Map();await c.intervals[0]();assert.equal(packets.at(-1).videoFrames,0); +}); diff --git a/integrations/canvaslink/verification/icon-preview.cjs b/integrations/canvaslink/verification/icon-preview.cjs new file mode 100644 index 000000000..ba5aa6f52 --- /dev/null +++ b/integrations/canvaslink/verification/icon-preview.cjs @@ -0,0 +1,128 @@ +'use strict'; +// SPDX-License-Identifier: MIT +// Offline renderer. Uses only the supplied installed sharp; does not start a browser or device. +const fs = require('node:fs'); +const path = require('node:path'); +const crypto = require('node:crypto'); +const { spawnSync } = require('node:child_process'); +const sharp = require(process.env.CANVASLINK_SHARP || 'sharp'); +const root = path.resolve(__dirname, '..'); +const out = __dirname; +const geometrySource = 'android/res/drawable/ic_launcher_foreground.xml'; +const read = file => fs.readFileSync(path.join(root, file), 'utf8'); +const sha256 = bytes => crypto.createHash('sha256').update(bytes).digest('hex'); + +function parse(text) { + const result = spawnSync('python3', ['-c', + 'import sys,json,xml.etree.ElementTree as E\nr=E.fromstring(sys.stdin.read())\nprint(json.dumps([{"tag":e.tag,"attrs":e.attrib,"text":e.text} for e in r.iter()]))'], + { input: text, encoding: 'utf8' }); + if (result.status !== 0) throw new Error(result.stderr); + return JSON.parse(result.stdout); +} + +const android = name => `{http://schemas.android.com/apk/res/android}${name}`; +const rails = parse(read(geometrySource)).filter(n => n.tag === 'path').map(n => ({ + fill: n.attrs[android('fillColor')], d: n.attrs[android('pathData')] +})); +const background = parse(read('android/res/values/colors.xml')).find(n => n.attrs.name === 'icon_background').text.trim(); +const pathTags = (tint = null) => rails.map(p => ``).join(''); + +function maskShape(mask) { + if (mask === 'circle') return ''; + if (mask === 'square') return ''; + if (mask !== 'squircle') throw new Error(`Unknown mask: ${mask}`); + const points = []; + for (let i = 0; i <= 256; i++) { + const t = i / 256 * Math.PI * 2, c = Math.cos(t), s = Math.sin(t); + const x = 54 + 36 * Math.sign(c) * Math.sqrt(Math.abs(c)); + const y = 54 + 36 * Math.sign(s) * Math.sqrt(Math.abs(s)); + points.push(`${i ? 'L' : 'M'}${x.toFixed(6)} ${y.toFixed(6)}`); + } + return ``; +} + +function maskedSvg(size, mask, tint = null, bg = background) { + return Buffer.from(`${maskShape(mask)}${pathTags(tint)}`); +} + +async function safeZoneMetrics() { + const image = Buffer.from(`${pathTags()}`); + const { data, info } = await sharp(image).ensureAlpha().raw().toBuffer({ resolveWithObject: true }); + let radius = 0, minX = 108, minY = 108, maxX = 0, maxY = 0; + for (let y = 0; y < info.height; y++) for (let x = 0; x < info.width; x++) { + if (data[(y * info.width + x) * 4 + 3] < 128) continue; + const px = (x + .5) / 10, py = (y + .5) / 10; + radius = Math.max(radius, Math.hypot(px - 54, py - 54)); + minX = Math.min(minX, px); maxX = Math.max(maxX, px); + minY = Math.min(minY, py); maxY = Math.max(maxY, py); + } + return { samplePixelsPerDp: 10, alphaThreshold: 128, maxRadiusDp: +radius.toFixed(4), safeRadiusDp: 33, + boundsDp: { minX, minY, maxX, maxY } }; +} + +async function main() { + const previews = []; + const images = new Map(); + for (const size of [24, 32, 48, 64, 128, 512]) for (const mask of ['square', 'circle', 'squircle']) { + const png = await sharp(maskedSvg(size, mask)).png().toBuffer(); + const file = `verification/icon-${mask}-${size}.png`; + fs.writeFileSync(path.join(root, file), png); + images.set(`${mask}-${size}`, png); + previews.push({ file, mask, width: size, height: size, sha256: sha256(png) }); + } + const themed = await sharp(maskedSvg(128, 'circle', '#213216', '#d6ef88')).png().toBuffer(); + fs.writeFileSync(path.join(out, 'icon-monochrome-128.png'), themed); + const sourcePreview = await sharp(path.join(root, 'design/canvaslink-icon.svg')).resize(512, 512).png().toBuffer(); + fs.writeFileSync(path.join(out, 'icon-source-square-512.png'), sourcePreview); + const safeSvg = Buffer.from(`${pathTags()}`); + const safeZone = await sharp(safeSvg).png().toBuffer(); + fs.writeFileSync(path.join(out, 'icon-safe-zone.png'), safeZone); + + const labels = []; + const overlays = []; + const text = (x, y, value, size = 16, color = '#95a592', weight = 400) => `${value}`; + labels.push(text(60, 65, 'CanvasLink', 36, '#edf1e6', 600)); + labels.push(text(60, 101, 'LINKED WINDOWS / ORIGINAL VECTOR MARK', 13, '#a9bc9c', 500)); + for (const [index, mask] of ['square', 'circle', 'squircle'].entries()) { + const left = 60 + index * 416; + labels.push(text(left, 145, `${String(index + 1).padStart(2, '0')} / ${mask.toUpperCase()}`, 13, '#d6ef88', 600)); + overlays.push({ input: await sharp(images.get(`${mask}-512`)).resize(320, 320).toBuffer(), left: left + 16, top: 170 }); + labels.push(text(left, 530, mask === 'square' ? 'Remote window + touch surface' : mask === 'circle' ? 'Circle mask / no critical clipping' : 'Exponent-4 squircle / no clipping', 15, '#edf1e6')); + labels.push(text(left, 621, 'SMALL-SIZE CHECK / 1x', 12, '#a9bc9c', 500)); + for (const [i, size] of [24, 32, 48, 64].entries()) { + const center = left + [36, 104, 188, 292][i]; + overlays.push({ input: images.get(`${mask}-${size}`), left: center - size / 2, top: 688 - size / 2 }); + labels.push(text(center - 9, 750, String(size), 13)); + } + } + labels.push(''); + overlays.push({ input: themed, left: 60, top: 844 }); + labels.push(text(216, 878, 'API 33+ / THEMED SILHOUETTE', 13, '#d6ef88', 600)); + labels.push(text(216, 910, 'Same geometry; launcher chooses the tint.', 16, '#edf1e6')); + labels.push(text(216, 940, 'Example colors, not a device capture.', 13)); + overlays.push({ input: safeZone, left: 1044, top: 819 }); + labels.push(text(696, 873, '108dp layer / 72dp viewport', 16, '#edf1e6')); + labels.push(text(696, 905, '66dp critical safe circle', 16, '#d6ef88')); + labels.push(text(696, 937, 'Filled rails / no text / no effects', 13)); + labels.push(text(60, 1039, 'OFFLINE ANDROID-VECTOR RENDERS / 24–512px / NO DEVICE INSTALL', 12)); + const sheetSvg = Buffer.from(`${labels.join('')}`); + const sheet = await sharp(sheetSvg).composite(overlays).png().toBuffer(); + fs.writeFileSync(path.join(out, 'icon-contact-sheet.png'), sheet); + const manifest = { + geometrySource, + sourceSvg: 'design/canvaslink-icon.svg', + sourceSha256: sha256(fs.readFileSync(path.join(root, 'design/canvaslink-icon.svg'))), + vectorSha256: sha256(fs.readFileSync(path.join(root, geometrySource))), + adaptiveViewport: { x: 18, y: 18, size: 72, layerSize: 108 }, + safeZone: await safeZoneMetrics(), + maskNote: 'Circle and exponent-4 superellipse are geometric preview masks, not OEM launcher captures.', + license: 'MIT; original project-specific geometry, no third-party artwork or fonts embedded.', + renderer: { sharp: sharp.versions.sharp, librsvg: sharp.versions.rsvg }, + previews, + contactSheet: { file: 'verification/icon-contact-sheet.png', width: 1320, height: 1080, sha256: sha256(sheet) } + }; + fs.writeFileSync(path.join(out, 'icon-previews.json'), JSON.stringify(manifest, null, 2) + '\n'); + console.log(JSON.stringify({ previews: previews.length, sheet: manifest.contactSheet.file, safeZone: manifest.safeZone })); +} + +main().catch(error => { console.error(error.message); process.exitCode = 1; }); diff --git a/package-lock.json b/package-lock.json index d8b21c057..5a4575779 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,10 +10,12 @@ "hasInstallScript": true, "license": "MIT", "workspaces": [ - "integrations/even-g2" + "integrations/even-g2", + "integrations/canvaslink" ], "dependencies": { "@xterm/headless": "6.0.0", + "bonjour-service": "1.3.0", "electron-updater": "6.8.9", "hast-util-to-jsx-runtime": "2.3.6", "lowlight": "3.3.0", @@ -48,6 +50,19 @@ "vite": "7.3.6" } }, + "integrations/canvaslink": { + "name": "@canvastty/canvaslink", + "version": "0.3.0-alpha.1", + "license": "MIT", + "dependencies": { + "bonjour-service": "1.3.0", + "secure-remote-password": "0.3.1" + }, + "devDependencies": { + "playwright": "1.64.0", + "sharp": "0.35.5" + } + }, "integrations/even-g2": { "name": "canvastty-even-g2", "version": "0.5.6", @@ -527,6 +542,10 @@ "node": ">=6.9.0" } }, + "node_modules/@canvastty/canvaslink": { + "resolved": "integrations/canvaslink", + "link": true + }, "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", @@ -844,6 +863,17 @@ "node": ">=14.14" } }, + "node_modules/@emnapi/runtime": { + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", + "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", @@ -1313,6 +1343,533 @@ "url": "https://github.com/sponsors/ayuhito" } }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.5.tgz", + "integrity": "sha512-QRUlFQ0WxvdWyqqG/WtI3iupfD5rBzmCHXSdPsY91sAtVtTo7Q4cb6zOccZ3gqEqkr0f1As1ehLqmEpDsRf+lg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.3.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.5.tgz", + "integrity": "sha512-+BR255RhDlpygUpOc/Jdt1nT6DQ3XG/ERo5wbcdOf5Q320dKtPCKPLR1LJs9VGXRaMa8l1uUa0tkCNOXiAxZUw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.3.4" + } + }, + "node_modules/@img/sharp-freebsd-wasm32": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.5.tgz", + "integrity": "sha512-Y/z91nEZ4uIBX5X3nfTovjU9lHNKFYbL2lpHCLVNmXQK03VIZvXBBt0KxbPGp2SdGSF+2mQU4e+hQaWOt86iAw==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "dependencies": { + "@img/sharp-wasm32": "0.35.5" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.4.tgz", + "integrity": "sha512-5R89nBYiRdUlSWJxPhO+GVtaXzXSxKnRu/xqMn3KTA3L9EB9Oy/P+Nn2f2vlhPuUdy/Zusb2DarbyTpGCfEDuw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.4.tgz", + "integrity": "sha512-iR2OKH80yi0U+dUplyh3/xdpFvps6YkCwsXenIJxqxR1v9o+xtKTGbS9H7cps+2Vxjc8B1j96p75NmTGjIhtpQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.4.tgz", + "integrity": "sha512-LmRtTsOHuvM2+wlO2Db37dx5MiZhB0FvSunciw48YjdOkZz9KAiRbm8ujeMOA1INqmei5NapFxYEK1D1ZSidmw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.4.tgz", + "integrity": "sha512-Y3dgX/6lE2QhQb+Gxy0WZxfg9MEm/JBjamZpS2IklP7xIQoKN4hzAm7KcMVGtaVDt3neE9OKBC7vAfonA/Lr1A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.4.tgz", + "integrity": "sha512-Le6boB8Tai0Nis+gIxIpKx68UDVVIqdR8Tin5Yf1z2LJJQLDJvCDRqRu+jC2qCoD+eIomonmOwB4smBRxfVpYQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.4.tgz", + "integrity": "sha512-aHkkIEHPRdQEegJN20MLmGtxYD9R2wQr3Cwpddnu5+YKMt6Uzax7S9h5gpZTo8wyrGuZSlfQ63OevL5mTyOC7Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.4.tgz", + "integrity": "sha512-ra/mB6MikESDUO7Yg+Mi95bFBb9GsObURuhnOv3OqknjGe9sZrG8tCe9q0xSIGrtLgvgw0gKnFWcK4blSgQOuQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.4.tgz", + "integrity": "sha512-GJ//SSXbnwSDes02umB3nDJLFcQzw8a18V8fyhqr6tV515tOEMdImjjxj1AoafMRz56F3PHgftnj1QEKSU1zkw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.4.tgz", + "integrity": "sha512-hvulFwtjUcagsis6BBxHwGFwWoNZjgYmULGVrZcyfNbjA8hKILbRxGg15/7w5HDyXHXUos/j6baAWqnCyQ2DWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.4.tgz", + "integrity": "sha512-6zXKeE/p39I1AmA3cJG35eyBGNqNddLnUXjhwBnsGjFPWqf5VKkDBEqaEkPDoTEtkxwi2vv8Tcr2mDyP4So7Fg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.5.tgz", + "integrity": "sha512-LEaXK2WdXVK5ykcw0buWyPMsmLLL2vpHLD6yrNSW+JGEL3BZPA4tpKN6iaMc4AxTTAoaX/sU1rOL51lcIz48ZQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.3.4" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.5.tgz", + "integrity": "sha512-LYVx5JTsOM2CBzmxreh+nl64/3H6Xb09iSLknqH47z2T2DFFxDeFLP5y4dJwe6H7uGQlHPyEEtIqyo3DYsRwdQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.3.4" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.5.tgz", + "integrity": "sha512-QVxAAq8evVRI9ia2vqgwrmWucn5Dfv+JdWzj75pD8omHLPSP7f8p20O8jxzjCcuCEQEOtYOZUmX1hkiZ0kdevA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.3.4" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.5.tgz", + "integrity": "sha512-LtdreXguaavKODPIfzJ4kffx7UNt1omwtK0rch4EBbbSTXPnxWmYSayXdLJw0fJzQ97kHt1gL/yh4tvU+nCyRQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.3.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.5.tgz", + "integrity": "sha512-UZasTOFiYzotTsGOCu42BfUzP6Tu6Do/947iRm1RsLKvlllxwGcn4RN27LibGWceix4Y+Pmw3jsnTcCQIgWjqA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.3.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.5.tgz", + "integrity": "sha512-SxFtLTeJInhAA9Q836kux2vZNeOBQEx658qvbboZScr0wIARym3IcGmW7KpVD5sbVg0Ojy+udFQdayYIZyoNog==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.3.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.5.tgz", + "integrity": "sha512-9HbMclmI1zlNkFRs3z9/eBtDjfD0sGlrX1z6b1qwmiFY5ElDLh4BC0LPBdVp7z1DXFiKlIcznf+ZlsuZzLxQqg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.3.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.5.tgz", + "integrity": "sha512-4KOphqB035HrVdqLZfCgMzzERrQkkzOwRhl4OAkRO1YCldbaFjySXMaK534Mo0V+LndnlJk+sbUyLeU0ULyD1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.3.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.5.tgz", + "integrity": "sha512-Ptsga1su4tQx+LLF1ECS9U6nz5kmrXKo6XVbtR48Ke3ZRxxgaWBu7IDtEe1quo8hiupwm6WFqxVlXaSf7IINGQ==", + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.11.3" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-webcontainers-wasm32": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.5.tgz", + "integrity": "sha512-hfhF/FmoQyTUkA0bIKFOtw536BQSeBMe6BF6QyWlrPxT754+TFLaZ7sKKTfvvM0yJgKgaYTwnFCIZ/GuDw5SUA==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/sharp-wasm32": "0.35.5" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.5.tgz", + "integrity": "sha512-X4t7g+7ZA5DKblCBEXGjUqqemj4vczING/5viFwAL8h4N3qYeyjwdCvRLHi4EdOUI+2Z7UFlp1VM+p/AuEtm6Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.5.tgz", + "integrity": "sha512-5Zm82LoBc43nhwNybZlG7Y1KO//Zhsn306fQl29ZOuStHLGTo3BWL83q3cznX0poxSAMuYL1On/BHBxkBeKr6A==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.5.tgz", + "integrity": "sha512-x76eH0vEiHlcMQu8Y8IenntaACtddpT6W0wmXtWrnKcnKI7ME5DdgqhAD6SEWOEl1v2zDvkZDhFA9KnURwpfqg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, "node_modules/@inquirer/ansi": { "version": "2.0.8", "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-2.0.8.tgz", @@ -2133,6 +2690,12 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@leichtgewicht/ip-codec": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@leichtgewicht/ip-codec/-/ip-codec-2.0.5.tgz", + "integrity": "sha512-Vo+PSpZG2/fmgmiNzYK9qWRh8h/CHrwD0mo1h1DzL4yzHNSfWYujGTYsWGreD000gcgmZ7K4Ys6Tx9TxtsKdDw==", + "license": "MIT" + }, "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", @@ -3907,6 +4470,16 @@ "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==", "license": "MIT" }, + "node_modules/bonjour-service": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/bonjour-service/-/bonjour-service-1.3.0.tgz", + "integrity": "sha512-3YuAUiSkWykd+2Azjgyxei8OWf8thdn8AITIog2M4UICzoqfjlqr64WIjEXZllf/W6vK1goqleSR6brGomxQqA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "multicast-dns": "^7.2.5" + } + }, "node_modules/boolean": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/boolean/-/boolean-3.2.0.tgz", @@ -4689,6 +5262,18 @@ "js-yaml": "^4.1.0" } }, + "node_modules/dns-packet": { + "version": "5.6.1", + "resolved": "https://registry.npmjs.org/dns-packet/-/dns-packet-5.6.1.tgz", + "integrity": "sha512-l4gcSouhcgIKRvyy99RNVOgxXiicE+2jZoNmaNmZ6JXiGajBOJAesk1OBlJuM5k2c+eudGdLxDqXuPCKIj6kpw==", + "license": "MIT", + "dependencies": { + "@leichtgewicht/ip-codec": "^2.0.1" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/dotenv": { "version": "16.6.1", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", @@ -5169,7 +5754,6 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true, "license": "MIT" }, "node_modules/fast-string-truncated-width": { @@ -7651,6 +8235,19 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/multicast-dns": { + "version": "7.2.5", + "resolved": "https://registry.npmjs.org/multicast-dns/-/multicast-dns-7.2.5.tgz", + "integrity": "sha512-2eznPJP8z2BFLX50tf0LuODrpINqP1RVIm/CObbTcBRITQgmC/TjcREF1NeTBzIcR5XO/ukWo+YHOjBbFwIupg==", + "license": "MIT", + "dependencies": { + "dns-packet": "^5.2.2", + "thunky": "^1.0.2" + }, + "bin": { + "multicast-dns": "cli.js" + } + }, "node_modules/mute-stream": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-3.0.0.tgz", @@ -8163,6 +8760,35 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/playwright": { + "version": "1.64.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.64.0.tgz", + "integrity": "sha512-kVzTnYFYEyQ9RGzBjNCd2+yEqWmVbxl3AHPynv3qQ6JTjv4a+kwRcnqQwIpuP3NnrnIrE/Q6wWAIAvMfIE2/Yg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.64.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright-core": { + "version": "1.64.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.64.0.tgz", + "integrity": "sha512-T9r+MZkTECl2+oUcZ26YgZzTrYidTsIhYYkJix+6iDOymOb1FB2RLLSOxRGnZGv5DStt+aduIVbt+E8WygXYpg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/plist": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/plist/-/plist-3.1.0.tgz", @@ -8885,6 +9511,69 @@ "node": ">= 0.4" } }, + "node_modules/sharp": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.5.tgz", + "integrity": "sha512-Ywn4OnzGukp7CDMrp08RQ50YKmuwG47brZgIVPTvBaaAfQlRlygrRqSrxdCiL9M+LlzLBiJ68IR1QqvzHyjC7g==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@img/colour": "^1.1.0", + "detect-libc": "^2.1.2", + "semver": "^7.8.5" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.35.5", + "@img/sharp-darwin-x64": "0.35.5", + "@img/sharp-freebsd-wasm32": "0.35.5", + "@img/sharp-libvips-darwin-arm64": "1.3.4", + "@img/sharp-libvips-darwin-x64": "1.3.4", + "@img/sharp-libvips-linux-arm": "1.3.4", + "@img/sharp-libvips-linux-arm64": "1.3.4", + "@img/sharp-libvips-linux-ppc64": "1.3.4", + "@img/sharp-libvips-linux-riscv64": "1.3.4", + "@img/sharp-libvips-linux-s390x": "1.3.4", + "@img/sharp-libvips-linux-x64": "1.3.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.3.4", + "@img/sharp-libvips-linuxmusl-x64": "1.3.4", + "@img/sharp-linux-arm": "0.35.5", + "@img/sharp-linux-arm64": "0.35.5", + "@img/sharp-linux-ppc64": "0.35.5", + "@img/sharp-linux-riscv64": "0.35.5", + "@img/sharp-linux-s390x": "0.35.5", + "@img/sharp-linux-x64": "0.35.5", + "@img/sharp-linuxmusl-arm64": "0.35.5", + "@img/sharp-linuxmusl-x64": "0.35.5", + "@img/sharp-webcontainers-wasm32": "0.35.5", + "@img/sharp-win32-arm64": "0.35.5", + "@img/sharp-win32-ia32": "0.35.5", + "@img/sharp-win32-x64": "0.35.5" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/sharp/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/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -9158,6 +9847,12 @@ "fs-extra": "^10.0.0" } }, + "node_modules/thunky": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/thunky/-/thunky-1.1.0.tgz", + "integrity": "sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA==", + "license": "MIT" + }, "node_modules/tiny-async-pool": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/tiny-async-pool/-/tiny-async-pool-1.3.0.tgz", diff --git a/package.json b/package.json index 524b53037..a491f565a 100644 --- a/package.json +++ b/package.json @@ -46,10 +46,13 @@ "build:helpers": "node scripts/build-native-helpers.mjs", "pack:even": "npm run pack --workspace integrations/even-g2", "smoke:terminal-hidden": "node scripts/smoke-hidden-terminal-electron.mjs", - "bench:terminal-hidden-renderer": "node scripts/smoke-hidden-terminal-electron.mjs --benchmark" + "bench:terminal-hidden-renderer": "node scripts/smoke-hidden-terminal-electron.mjs --benchmark", + "test:canvaslink": "npm test --workspace @canvastty/canvaslink", + "build:canvaslink:android": "npm run build:android --workspace @canvastty/canvaslink" }, "dependencies": { "@xterm/headless": "6.0.0", + "bonjour-service": "1.3.0", "electron-updater": "6.8.9", "hast-util-to-jsx-runtime": "2.3.6", "lowlight": "3.3.0", @@ -92,6 +95,7 @@ "node-pty@1.1.0": true }, "workspaces": [ - "integrations/even-g2" + "integrations/even-g2", + "integrations/canvaslink" ] } diff --git a/scripts/smoke-canvaslink.mjs b/scripts/smoke-canvaslink.mjs new file mode 100644 index 000000000..89d39899d --- /dev/null +++ b/scripts/smoke-canvaslink.mjs @@ -0,0 +1,68 @@ +// Real Electron + built preload/host wiring, using hidden disposable windows. +import assert from 'node:assert/strict'; +import { spawn, spawnSync } from 'node:child_process'; +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { createRequire } from 'node:module'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { electronSmokeLaunchBlockReason } from './lib/electron-smoke-launch-guard.mjs'; +const require = createRequire(import.meta.url), electron = require('electron'); +const root = resolve(fileURLToPath(new URL('..', import.meta.url))); +if (typeof electron === 'string') { + const reason = electronSmokeLaunchBlockReason(process.platform, process.env); + if (reason) throw Error(reason); + const profile = await mkdtemp(join(process.platform === 'win32' ? tmpdir() : '/tmp', 'ct-link-')); + const env = Object.fromEntries(Object.entries(process.env).filter(([name]) => !name.startsWith('CANVASTTY_') && !['ELECTRON_RUN_AS_NODE', 'ELECTRON_RENDERER_URL'].includes(name))); + const log = join(profile, 'electron.log'); + const ownedPids = () => process.platform !== 'darwin' ? [] : spawnSync('/bin/ps', ['-axo', 'pid=,comm='], { encoding: 'utf8' }).stdout.split('\n').flatMap(line => { const match = line.match(/^\s*(\d+)\s+(.*)$/); return match && match[2] === electron ? [Number(match[1])] : []; }); + const before = new Set(ownedPids()); + const args = [fileURLToPath(import.meta.url), `--user-data-dir=${profile}`, '--canvaslink-port', '0']; + if (process.platform === 'linux' && process.env.CI === 'true') args.push('--no-sandbox'); + try { + const child = process.platform === 'darwin' + ? spawn('/usr/bin/open', ['-n', '-W', '-g', '-j', resolve(electron, '../../..'), '--stdout', log, '--stderr', log, '--args', ...args], { env, stdio: ['ignore', 'pipe', 'pipe'] }) + : spawn(electron, args, { env, stdio: ['ignore', 'pipe', 'pipe'] }); + let output = ''; child.stdout.on('data', chunk => output += chunk); + child.stderr.on('data', chunk => { output += chunk; }); + const timer = setTimeout(() => child.kill('SIGTERM'), 30000); + const code = await new Promise((done, reject) => { child.on('error', reject); child.on('exit', done); }); + clearTimeout(timer); + if (process.platform === 'darwin') output += await readFile(log, 'utf8').catch(() => ''); + if (code !== 0 || !output.includes('CANVASTTY_CANVASLINK_SMOKE_OK')) throw Error(`Electron exit=${code}; output: ${output.slice(-5000) || '(no output before timeout)'}`); + console.log('CANVASTTY_CANVASLINK_SMOKE_OK (real built preload, IPC, enable/revoke/disable, untrusted-window rejection)'); + } finally { + for (const pid of ownedPids()) if (!before.has(pid)) { try { process.kill(pid, 'SIGTERM'); } catch {} } + await rm(profile, { recursive: true, force: true }); + } +} else { + const { app, BrowserWindow } = electron; + app.setName('CanvasTTY CanvasLink isolated smoke'); + const profileArg = process.argv.find(arg => arg.startsWith('--user-data-dir=')); + assert.ok(profileArg); app.setPath('userData', profileArg.split('=').slice(1).join('=')); + app.on('window-all-closed', () => {}); + let owned; + const { install } = require(join(root, 'out/main/canvaslink/host/embedded.cjs')); + install({ getWindow: () => owned }); + try { + await app.whenReady(); app.dock?.hide(); + const page = join(app.getPath('userData'), 'fixture.html'); + await writeFile(page, 'CanvasLink isolated fixture'); + const create = () => new BrowserWindow({ show: false, webPreferences: { preload: join(root, 'out/preload/index.cjs'), contextIsolation: true, sandbox: true, nodeIntegration: false } }); + owned = create(); await owned.loadFile(page); + const call = script => owned.webContents.executeJavaScript(script); + assert.equal((await call('window.canvasTTY.canvasLink.state()')).enabled, false); + let state = await call('window.canvasTTY.canvasLink.command("enable")'); + assert.equal(state.enabled, true); assert.match(state.pin, /^\d{6}$/); + const stranger = create(); await stranger.loadFile(page); + const rejected = await stranger.webContents.executeJavaScript('window.canvasTTY.canvasLink.command("disable").then(()=>false,()=>true)'); + assert.equal(rejected, true); stranger.destroy(); + state = await call('window.canvasTTY.canvasLink.command("revoke")'); + assert.equal(state.enabled, true); assert.equal(state.devices.length, 0); + state = await call('window.canvasTTY.canvasLink.command("disable")'); + assert.equal(state.enabled, false); assert.equal(state.pin, ''); + assert.equal(await call('window.canvasTTY.canvasLink.command("new-code").then(()=>false,()=>true)'), true); + console.log('CANVASTTY_CANVASLINK_SMOKE_OK'); + owned.destroy(); app.quit(); + } catch (error) { console.error(error); app.exit(1); } +} diff --git a/src/main/index.ts b/src/main/index.ts index b9832dcee..9df9e089e 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -101,6 +101,9 @@ import { mainWindowChromeOptions } from "./windowChrome"; import { markMainBoot, mainBootMarks } from "./bootMarks"; import { attachEditContextMenu } from "./editContextMenu"; import { macApplicationMenuTemplate } from "./macApplicationMenu"; +import { configureCanvasLinkStartup, installCanvasLink } from "./services/canvasLink"; + +configureCanvasLinkStartup(app); 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"); @@ -1210,6 +1213,7 @@ function attentionStatusLabel(status: "needs_approval" | "failed", locale: Local } if (hasSingleInstanceLock) { + installCanvasLink(() => mainWindow); void app.whenReady() .then(() => { markMainBoot("appReady"); diff --git a/src/main/services/canvasLink.ts b/src/main/services/canvasLink.ts new file mode 100644 index 000000000..922156702 --- /dev/null +++ b/src/main/services/canvasLink.ts @@ -0,0 +1,19 @@ +import { createRequire } from "node:module"; +import type { App, BrowserWindow } from "electron"; + +// Static CJS/HTML assets are copied by electron.vite.config.ts into out/main. +// Keeping these relative to the bundle works in dev and inside app.asar. +const require = createRequire(import.meta.url); + +export function configureCanvasLinkStartup(app: App): void { + if (!process.argv.includes("--canvaslink-high-density")) return; + const { configureRenderScale } = require("./canvaslink/host/display-scale.cjs"); + configureRenderScale(app); +} + +export function installCanvasLink(getWindow: () => BrowserWindow | null): void { + const { install } = require("./canvaslink/host/embedded.cjs"); + // Registers the owning-frame-only settings IPC. No listener, capture or + // discovery starts until the user explicitly enables CanvasLink in Settings. + install({ getWindow }); +} diff --git a/src/preload/index.ts b/src/preload/index.ts index 32f38cf65..49843cd41 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -65,6 +65,10 @@ ipcRenderer.on(IPC.windowOpenUpdates, () => { }); const api: CanvasTTYApi = { + canvasLink: { + state: () => ipcRenderer.invoke("canvaslink-settings:state"), + command: (action) => ipcRenderer.invoke("canvaslink-settings:command", action) + }, mascots: { list: () => ipcRenderer.invoke(IPC.mascotsList), start: (image) => ipcRenderer.invoke(IPC.mascotsStart, image), diff --git a/src/renderer/src/features/settings/CanvasLinkSettings.css b/src/renderer/src/features/settings/CanvasLinkSettings.css new file mode 100644 index 000000000..59d6dd33c --- /dev/null +++ b/src/renderer/src/features/settings/CanvasLinkSettings.css @@ -0,0 +1,9 @@ +.canvaslink-settings{max-width:54rem;color:var(--text);line-height:1.5} +.canvaslink-settings__intro{padding:.3rem 0 1.6rem}.canvaslink-settings__eyebrow{font:600 .7rem var(--font-mono,monospace);letter-spacing:.1em;color:var(--primary)} +.canvaslink-settings h3{font-size:1.05em;margin:0 0 .4rem}.canvaslink-settings__intro h3{font-size:1.55em;letter-spacing:-.025em}.canvaslink-settings p{margin:.4rem 0;color:var(--text-muted-on-dark)} +.canvaslink-settings__card{padding:1.4rem;margin-bottom:1rem;border:1px solid color-mix(in srgb,var(--text) 13%,transparent);border-radius:1rem;background:color-mix(in srgb,var(--surface-soft) 65%,var(--surface))} +.canvaslink-settings__row{display:flex;gap:1.2rem;align-items:center;justify-content:space-between}.canvaslink-settings__status{font-size:.85em}.canvaslink-settings__connection{margin-top:1.2rem;border-top:1px solid color-mix(in srgb,var(--text) 10%,transparent);padding-top:.8rem} +.canvaslink-settings ol{padding-left:1.3rem;font-size:.9em;color:var(--text-muted-on-dark)}.canvaslink-settings li+li{margin-top:.45rem}.canvaslink-settings__code-block{display:flex;flex-direction:column;align-items:flex-start;gap:.35rem;margin:1.2rem 0}.canvaslink-settings__code-block>span,.canvaslink-settings small{font-size:.8em;color:var(--text-muted-on-dark)} +.canvaslink-settings__code{font:600 clamp(1.8rem,4vw,2.8rem)/1.3 var(--font-mono,monospace);letter-spacing:.14em;color:var(--text);font-variant-numeric:tabular-nums}.canvaslink-settings__button{min-height:48px;padding:.65rem 1rem;border:1px solid color-mix(in srgb,var(--text) 25%,transparent);border-radius:.65rem;color:var(--text);background:transparent;font:inherit;font-size:.85em;cursor:pointer}.canvaslink-settings__button:hover{background:color-mix(in srgb,var(--text) 8%,transparent)}.canvaslink-settings__button--primary{border-color:var(--primary);background:var(--primary);color:var(--text-dark)}.canvaslink-settings__button--primary:hover{background:var(--primary-hover)}.canvaslink-settings__button:focus-visible{outline:2px solid var(--primary);outline-offset:3px}.canvaslink-settings__button:disabled{opacity:.55;cursor:wait} +.canvaslink-settings__card--quiet p{font-size:.85em}.canvaslink-settings__note{font-size:.9em}.canvaslink-settings__devices h4{margin:.8rem 0 .4rem}.canvaslink-settings__error{color:var(--danger)!important} +@media(max-width:760px){.canvaslink-settings__row{align-items:flex-start;flex-direction:column}.canvaslink-settings__card{padding:1rem}} diff --git a/src/renderer/src/features/settings/CanvasLinkSettings.tsx b/src/renderer/src/features/settings/CanvasLinkSettings.tsx new file mode 100644 index 000000000..01c79ae2b --- /dev/null +++ b/src/renderer/src/features/settings/CanvasLinkSettings.tsx @@ -0,0 +1,85 @@ +import { useEffect, useRef, useState } from "react"; +import type { LocaleId } from "../../../../shared/contracts"; +import type { CanvasLinkCommand, CanvasLinkState } from "../../../../shared/canvasLink"; +import "./CanvasLinkSettings.css"; + +export function CanvasLinkSettings({ locale, open }: { locale: LocaleId; open: boolean }) { + const [state, setState] = useState(null); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(""); + const revision = useRef(0); + const text = (ru: string, en: string) => locale === "ru" ? ru : en; + + useEffect(() => { + if (!open) return; + let active = true; + let timer: ReturnType; + async function poll() { + const current = revision.current; + try { + const next = await window.canvasTTY.canvasLink.state(); + if (active && current === revision.current) setState(next); + } catch { + if (active) setError(locale === "ru" ? "Не удалось получить состояние подключения." : "Could not read connection status."); + } finally { + if (active) timer = setTimeout(poll, 1000); + } + } + void poll(); + return () => { active = false; clearTimeout(timer); }; + }, [open, locale]); + + async function run(action: CanvasLinkCommand) { + revision.current++; + setBusy(true); + setError(""); + try { setState(await window.canvasTTY.canvasLink.command(action)); } + catch { setError(text("Действие не выполнено. Попробуйте ещё раз.", "The action failed. Try again.")); } + finally { revision.current++; setBusy(false); } + } + const button = (label: string, action: CanvasLinkCommand, primary = false) => ( + + ); + const enabled = !!state?.enabled; + const devices = state?.devices ?? []; + const seconds = Math.max(0, Math.ceil(((state?.expires ?? 0) - Date.now()) / 1000)); + const status = !enabled ? text("Выключено", "Off") : devices.length + ? state?.phase === "streaming" ? text("Подключено · видео и управление", "Connected · video and control") + : text("Устройство связано · подключение", "Device paired · connecting") + : text("Ожидает подключения", "Waiting for connection"); + + return
+
+

{text("ТЕЛЕФОН И ПЛАНШЕТ", "PHONE AND TABLET")}

+

{text("Ваш CanvasTTY на Android", "Your CanvasTTY on Android")}

+

{text("Работайте в этом окне с планшета или телефона. Оба устройства должны быть в одной локальной сети.", "Use this window from your tablet or phone. Both devices must be on the same local network.")}

+
+
+
+

{text("Доступ с Android", "Android access")}

{status}

+ {state && (enabled ? button(text("Выключить доступ", "Turn off access"), "disable") : button(text("Включить доступ", "Enable access"), "enable", true))} +
+ {!state &&

{text("Проверяем состояние…", "Checking status…")}

} + {enabled &&
+
  1. {text("Откройте CanvasLink на Android.", "Open CanvasLink on Android.")}
  2. +
  3. {text("Выберите этот компьютер и введите код. IP-адрес вводить не нужно.", "Select this computer and enter the code. No IP address is needed.")}
+ {state?.pin && seconds > 0 ?
+ {text("Одноразовый код", "One-time code")} + {state.pin.slice(0, 3)} {state.pin.slice(3)} + {text(`Действует ещё ${seconds} с`, `Valid for ${seconds} s`)} +
:

{devices.length ? text("Устройство связано. Код больше не нужен до завершения сессии.", "Device paired. No code is needed until this session ends.") : text("Срок действия кода истёк. Получите новый код.", "The code expired. Get a new code.")}

} + {!devices.length && button(text("Новый код", "New code"), "new-code")} + {devices.length > 0 &&

{text("Связанное устройство", "Paired device")}

+ {devices.map((device, index) =>

{device.name || "Android"}

)} + {button(text("Забыть устройство", "Forget device"), "revoke")}
} +
} +
+
+

{text("Управление с планшета", "Tablet controls")}

+

{text("Коснитесь терминала, чтобы открыть клавиатуру. Ввод передаётся напрямую; Enter запускает команду. При потере связи ввод автоматически не повторяется. Доступ распространяется на всё это окно CanvasTTY.", "Tap a terminal to open the keyboard. Input is sent directly; Enter runs the command. Input is never automatically replayed after a disconnect. Access covers this entire CanvasTTY window.")}

+

{text("Очки Even G2 подключаются отдельно — в разделе «Внешние интеграции». Коды и доступы независимы.", "Even G2 glasses connect separately under External integrations. Their codes and access are independent.")}

+
+ {error &&

{error}

} +
; +} diff --git a/src/renderer/src/features/settings/SettingsPanel.tsx b/src/renderer/src/features/settings/SettingsPanel.tsx index db4619189..d9b73f777 100644 --- a/src/renderer/src/features/settings/SettingsPanel.tsx +++ b/src/renderer/src/features/settings/SettingsPanel.tsx @@ -1,3 +1,4 @@ +import { CanvasLinkSettings } from "./CanvasLinkSettings"; import { EvenG2Controls } from "./EvenG2Controls"; import { useEffect, useLayoutEffect, useRef, useState } from "react"; import type { @@ -121,7 +122,7 @@ import { } from "../../../../shared/autoMode"; import { MascotsSettings } from "./MascotsSettings"; -type SettingsSection = "general" | "keyboardShortcuts" | "appearance" | "agents" | "controls" | "externalIntegrations" | "browser" | "plugins" | "mascots" | "updates" | "about"; +type SettingsSection = "general" | "keyboardShortcuts" | "appearance" | "agents" | "controls" | "externalIntegrations" | "canvasLink" | "browser" | "plugins" | "mascots" | "updates" | "about"; const SHORTCUT_LABELS = { home: "homeShortcut", renameWindow: "renameWindow", toggleFullscreen: "toggleFullscreen", @@ -143,6 +144,7 @@ const SETTINGS_SECTIONS: ReadonlyArray<{ { id: "agents", icon: "terminal" }, { id: "controls", icon: "sliders-horizontal" }, { id: "externalIntegrations", icon: "blocks" }, + { id: "canvasLink", icon: "app-window" }, { id: "browser", icon: "browser" }, { id: "plugins", icon: "blocks" }, { id: "mascots", icon: "image-plus" }, @@ -1157,6 +1159,8 @@ export function SettingsPanel({ )} + {section === "canvasLink" && } + {section === "externalIntegrations" && ( )} diff --git a/src/renderer/src/lib/i18n.ts b/src/renderer/src/lib/i18n.ts index b1a05d81e..d440fd8c3 100644 --- a/src/renderer/src/lib/i18n.ts +++ b/src/renderer/src/lib/i18n.ts @@ -189,6 +189,7 @@ const ru = { pluginHooksInstallWarning: "Установка не включает их автоматически. После проверки кода их можно отдельно включить в Настройки → Агенты → Хуки; они выполняются с правами вашей учётной записи.", controls: "Управление", externalIntegrations: "Внешние интеграции", + canvasLink: "CanvasLink", plugins: "Плагины", mascots: "Маскоты", installPlugin: "Установить плагин", @@ -1221,6 +1222,7 @@ const en: Record = { pluginHooksInstallWarning: "Installation does not enable them automatically. After reviewing the code, enable them separately in Settings → Agents → Hooks; they run with your user account privileges.", controls: "Controls", externalIntegrations: "External integrations", + canvasLink: "CanvasLink", plugins: "Plugins", mascots: "Mascots", installPlugin: "Install plugin", diff --git a/src/shared/canvasLink.ts b/src/shared/canvasLink.ts new file mode 100644 index 000000000..48da2c505 --- /dev/null +++ b/src/shared/canvasLink.ts @@ -0,0 +1,14 @@ +export interface CanvasLinkState { + enabled: boolean; + phase: string; + pin: string; + expires: number; + devices: Array<{ name: string }>; +} + +export type CanvasLinkCommand = "enable" | "disable" | "new-code" | "revoke"; + +export interface CanvasLinkApi { + state(): Promise; + command(action: CanvasLinkCommand): Promise; +} diff --git a/src/shared/contracts.ts b/src/shared/contracts.ts index e406aa1c2..414ca0d72 100644 --- a/src/shared/contracts.ts +++ b/src/shared/contracts.ts @@ -1877,6 +1877,7 @@ export interface CanvasTTYApi { onStatus(listener: (status: UpdateStatus) => void): () => void; }; evenG2: import('./evenG2.ts').EvenG2Api; + canvasLink: import('./canvasLink.ts').CanvasLinkApi; appVersion(): Promise; clipboard: { readText(): Promise; From eba7baf1f3db716b6600a605f1fda07c39e9119f Mon Sep 17 00:00:00 2001 From: Teo | Nexcore Date: Mon, 26 Oct 2026 00:31:53 +0300 Subject: [PATCH 2/4] ci(canvaslink): avoid retired Android tools package --- .github/workflows/canvaslink.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/canvaslink.yml b/.github/workflows/canvaslink.yml index bf64d4ab3..8737deb76 100644 --- a/.github/workflows/canvaslink.yml +++ b/.github/workflows/canvaslink.yml @@ -35,6 +35,9 @@ jobs: distribution: temurin java-version: '17' - uses: android-actions/setup-android@v3 + with: + packages: platform-tools + log-accepted-android-sdk-licenses: 'false' - run: sdkmanager 'platforms;android-35' 'build-tools;35.0.0' - run: node integrations/canvaslink/scripts/build-android.cjs - name: Upload alpha APK (ephemeral CI development signature) From f643b31c476e490904437c95c13f0db33ad7c310 Mon Sep 17 00:00:00 2001 From: Teo | Nexcore <314265484+teo-nex@users.noreply.github.com> Date: Mon, 26 Oct 2026 02:07:08 +0300 Subject: [PATCH 3/4] feat(canvaslink): add verified Android installation prompt --- docs/canvaslink-verification.md | 31 +++++++++- docs/canvaslink.md | 19 +++++- docs/canvaslink.ru.md | 13 ++++- .../features/settings/CanvasLinkSettings.css | 1 + .../features/settings/CanvasLinkSettings.tsx | 22 +++++++ src/shared/canvasLinkInstall.ts | 20 +++++++ tests/canvaslink-install.test.mjs | 58 +++++++++++++++++++ 7 files changed, 158 insertions(+), 6 deletions(-) create mode 100644 src/shared/canvasLinkInstall.ts create mode 100644 tests/canvaslink-install.test.mjs diff --git a/docs/canvaslink-verification.md b/docs/canvaslink-verification.md index 3557f2630..cf7f953f5 100644 --- a/docs/canvaslink-verification.md +++ b/docs/canvaslink-verification.md @@ -57,11 +57,38 @@ is **unverified**; the installed prototype remains the only user-accepted runtim Real packaged-build acceptance for this source port remains pending. CI and synthetic fixtures do not prove GPU capture, Android IME, TalkBack, rotation, -host firewalls or real device input on Windows/Linux. Public release signing, -store distribution and a 30 FPS guarantee are outside this alpha. +host firewalls or real device input on Windows/Linux. Official desktop release +signing, store distribution and a 30 FPS guarantee are outside this alpha. The `CanvasLink alpha` workflow runs the contracts and builds a downloadable development APK. Its CI signature is ephemeral; it is not an update channel. Run `node scripts/smoke-canvaslink.mjs` after the desktop build to exercise real Electron preload/IPC and host enable/revoke/disable against a disposable hidden fixture. It does not send input to user terminals or provide video acceptance. + +## Community APK distribution and onboarding (2026-10-26) + +- [CanvasLink](https://github.com/teo-nex/CanvasLink) is a separate public + distribution repository; application source remains in this PR. +- Version `0.3.0-alpha.1` is the APK from successful CI run `40235936427`, source + commit `eba7baf1f3db716b6600a605f1fda07c39e9119f`, re-signed locally with a + persistent distribution key. All ZIP entries outside `META-INF` match the CI + input byte for byte. Normal APKs contain no acceptance instrumentation. +- Distribution APK SHA-256: + `0534857896b2e22d7e832a8aaa6146b3fc6acb19f3df857cc9d3e8c1caf5a760`. + Public signing certificate SHA-256: + `0235b1aee591e4f0e24e59484927ead112889521a1b92e40959076d03635b3cb`. + The private key and password remain outside both repositories. +- Settings can copy a bilingual, pinned installation prompt and open the guide. + Copying does not enable the host, mutate pairing, install an APK or expose the + pairing code/device name. Three handler tests cover both locales and clipboard + failure; all passed. They use the repository's React test stand-in, not a live + desktop clipboard or physical Android device. +- The 103 integration tests, TypeScript checks, production build and source/built + output secret audit passed again after the settings change. +- The agent prompt selects one ADB device, verifies the APK hash, preserves app + data and stops on signature/downgrade conflicts. USB debugging and the initial + Android RSA confirmation belong to the device owner. +- Earlier debug signatures may conflict with the new distribution signature. + No APK was installed on the accepted tablet and no installed desktop was replaced. + Publication is distinct from physical acceptance of this exact APK/desktop port. diff --git a/docs/canvaslink.md b/docs/canvaslink.md index bfbec58ad..18802ef5c 100644 --- a/docs/canvaslink.md +++ b/docs/canvaslink.md @@ -11,7 +11,13 @@ and Even G2 integration retain their separate pairing and permissions. ## Connect -1. Install the development APK from a trusted build of this branch (Android 8+). +1. Install the [pinned Android alpha](https://github.com/teo-nex/CanvasLink#installation) + (Android 8+). **Settings → CanvasLink → Copy prompt for agent** copies USB/ADB + installation instructions with the exact APK URL and checksum. Enable USB + debugging and approve the RSA dialog on Android yourself. The agent installs + on one selected device and preserves data; it must stop on signing conflicts. + The desktop must be built from this PR; the official stable release does not + contain this integration yet. 2. Open Settings → CanvasLink on the desktop and choose **Enable access**. 3. Open CanvasLink on Android, select the discovered computer and enter the six digits shown on the desktop. The code expires after two minutes and is used @@ -73,8 +79,15 @@ Java dependencies are downloaded by the build script. A local development key is generated under the ignored `.runtime` directory. CI produces an APK artifact with an ephemeral development signature. Different builds can have different keys and may not install as an update over one another. Keep your existing app -and its data until you have planned the transition. Release signing/distribution -is not configured by this PR. +and its data until you have planned the transition. + +The separate community [CanvasLink distribution repository](https://github.com/teo-nex/CanvasLink) +publishes a pinned alpha APK and bilingual installation prompts. Its APK is the +verified CI payload re-signed with a persistent local distribution key; provenance, +SHA-256 and the public certificate fingerprint are recorded there. No private +signing key enters either repository. Source CI still produces ephemeral debug +signatures; the distribution release is a separate maintained publication, not +an official CanvasTTY release or automatic stable update channel. ## Quality and platform status diff --git a/docs/canvaslink.ru.md b/docs/canvaslink.ru.md index 08647bdd4..ce691e400 100644 --- a/docs/canvaslink.ru.md +++ b/docs/canvaslink.ru.md @@ -4,7 +4,12 @@ и управляет им. Доступ включает терминалы, настройки и видимые секреты. Подключайте только своё доверенное устройство. -1. Установите APK доверенной сборки на Android 8 или новее. +1. Установите [APK alpha на Android 8+](https://github.com/teo-nex/CanvasLink/blob/main/README.ru.md#установка). + В **Настройки → CanvasLink** есть кнопка **«Скопировать промпт агенту»**: + она копирует инструкцию по USB/ADB с точной ссылкой и SHA-256 APK. + Владелец включает USB-отладку и подтверждает RSA-запрос на Android; + агент устанавливает приложение на выбранное устройство с сохранением данных. + Нужна сборка компьютера из этого PR: в официальной стабильной версии раздела пока нет. 2. На компьютере откройте **Настройки → CanvasLink → Включить доступ**. 3. Откройте CanvasLink на планшете, выберите найденный компьютер и введите код. Оба устройства должны быть в одной локальной сети без изоляции клиентов. @@ -27,4 +32,10 @@ Windows и Linux пока не подтверждена; плавность 30 F Отладочные APK разных сборок могут иметь разные подписи и не устанавливаться поверх друг друга. Рабочую установленную версию сохраняйте до проверки новой. +Отдельный репозиторий [CanvasLink](https://github.com/teo-nex/CanvasLink) содержит +APK-релизы и инструкции; исходники остаются в CanvasTTY. Опубликованная alpha +получает постоянную локальную подпись, её SHA-256 и открытый отпечаток сертификата +записаны в манифесте релиза. Это публикация сообщества, а не официальный релиз CanvasTTY. +При несовместимой подписи агент останавливается: удаление приложения и данных не выполняется. + Команды сборки, устройство защиты и границы проверки: [canvaslink.md](canvaslink.md). diff --git a/src/renderer/src/features/settings/CanvasLinkSettings.css b/src/renderer/src/features/settings/CanvasLinkSettings.css index 59d6dd33c..982d321ce 100644 --- a/src/renderer/src/features/settings/CanvasLinkSettings.css +++ b/src/renderer/src/features/settings/CanvasLinkSettings.css @@ -6,4 +6,5 @@ .canvaslink-settings ol{padding-left:1.3rem;font-size:.9em;color:var(--text-muted-on-dark)}.canvaslink-settings li+li{margin-top:.45rem}.canvaslink-settings__code-block{display:flex;flex-direction:column;align-items:flex-start;gap:.35rem;margin:1.2rem 0}.canvaslink-settings__code-block>span,.canvaslink-settings small{font-size:.8em;color:var(--text-muted-on-dark)} .canvaslink-settings__code{font:600 clamp(1.8rem,4vw,2.8rem)/1.3 var(--font-mono,monospace);letter-spacing:.14em;color:var(--text);font-variant-numeric:tabular-nums}.canvaslink-settings__button{min-height:48px;padding:.65rem 1rem;border:1px solid color-mix(in srgb,var(--text) 25%,transparent);border-radius:.65rem;color:var(--text);background:transparent;font:inherit;font-size:.85em;cursor:pointer}.canvaslink-settings__button:hover{background:color-mix(in srgb,var(--text) 8%,transparent)}.canvaslink-settings__button--primary{border-color:var(--primary);background:var(--primary);color:var(--text-dark)}.canvaslink-settings__button--primary:hover{background:var(--primary-hover)}.canvaslink-settings__button:focus-visible{outline:2px solid var(--primary);outline-offset:3px}.canvaslink-settings__button:disabled{opacity:.55;cursor:wait} .canvaslink-settings__card--quiet p{font-size:.85em}.canvaslink-settings__note{font-size:.9em}.canvaslink-settings__devices h4{margin:.8rem 0 .4rem}.canvaslink-settings__error{color:var(--danger)!important} +.canvaslink-settings__install-actions{display:flex;flex-wrap:wrap;gap:.6rem;margin:.9rem 0} @media(max-width:760px){.canvaslink-settings__row{align-items:flex-start;flex-direction:column}.canvaslink-settings__card{padding:1rem}} diff --git a/src/renderer/src/features/settings/CanvasLinkSettings.tsx b/src/renderer/src/features/settings/CanvasLinkSettings.tsx index 01c79ae2b..1caa5e0c2 100644 --- a/src/renderer/src/features/settings/CanvasLinkSettings.tsx +++ b/src/renderer/src/features/settings/CanvasLinkSettings.tsx @@ -1,15 +1,27 @@ import { useEffect, useRef, useState } from "react"; import type { LocaleId } from "../../../../shared/contracts"; import type { CanvasLinkCommand, CanvasLinkState } from "../../../../shared/canvasLink"; +import { CANVASLINK_ANDROID_RELEASE, canvasLinkInstallPrompt } from "../../../../shared/canvasLinkInstall"; import "./CanvasLinkSettings.css"; export function CanvasLinkSettings({ locale, open }: { locale: LocaleId; open: boolean }) { const [state, setState] = useState(null); const [busy, setBusy] = useState(false); const [error, setError] = useState(""); + const [promptCopied, setPromptCopied] = useState(false); const revision = useRef(0); const text = (ru: string, en: string) => locale === "ru" ? ru : en; + function copyInstallPrompt() { + try { + window.canvasTTY.clipboard.writeText(canvasLinkInstallPrompt(locale)); + setPromptCopied(true); + } catch { + setPromptCopied(false); + setError(text("Не удалось скопировать промпт. Откройте инструкцию.", "Could not copy the prompt. Open the installation guide.")); + } + } + useEffect(() => { if (!open) return; let active = true; @@ -55,6 +67,16 @@ export function CanvasLinkSettings({ locale, open }: { locale: LocaleId; open: b

{text("Ваш CanvasTTY на Android", "Your CanvasTTY on Android")}

{text("Работайте в этом окне с планшета или телефона. Оба устройства должны быть в одной локальной сети.", "Use this window from your tablet or phone. Both devices must be on the same local network.")}

+
+

{text("Установить на Android", "Install on Android")}

+

{text("Подключите телефон или планшет по USB, включите USB-отладку и подтвердите запрос на Android. Скопируйте промпт своему агенту — он проверит APK и установит приложение. После установки USB не нужен.", "Connect your phone or tablet over USB, enable USB debugging and approve the Android prompt. Copy the prompt to your agent: it will verify the APK and install the app. USB is not needed after installation.")}

+
+ + +
+ {promptCopied &&

{text("Промпт скопирован. Вставьте его в чат с агентом на этом компьютере.", "Prompt copied. Paste it into your agent chat on this computer.")}

} +

{text(`Android 8+ · ${CANVASLINK_ANDROID_RELEASE.version} · экспериментальная версия`, `Android 8+ · ${CANVASLINK_ANDROID_RELEASE.version} · experimental version`)}

+

{text("Доступ с Android", "Android access")}

{status}

diff --git a/src/shared/canvasLinkInstall.ts b/src/shared/canvasLinkInstall.ts new file mode 100644 index 000000000..34cb7509d --- /dev/null +++ b/src/shared/canvasLinkInstall.ts @@ -0,0 +1,20 @@ +import type { LocaleId } from "./contracts"; + +// Pinned public alpha artifact. Never add pairing codes or runtime state here. +export const CANVASLINK_ANDROID_RELEASE = { + version: "0.3.0-alpha.1", + versionCode: 3, + applicationId: "io.nexcore.canvaslink", + url: "https://github.com/teo-nex/CanvasLink/releases/download/v0.3.0-alpha.1/CanvasLink-0.3.0-alpha.1.apk", + sha256: "0534857896b2e22d7e832a8aaa6146b3fc6acb19f3df857cc9d3e8c1caf5a760", + signerCertificateSha256: "0235b1aee591e4f0e24e59484927ead112889521a1b92e40959076d03635b3cb", + guideUrl: "https://github.com/teo-nex/CanvasLink#installation", + guideRuUrl: "https://github.com/teo-nex/CanvasLink/blob/main/README.ru.md#установка", +} as const; + +export function canvasLinkInstallPrompt(locale: LocaleId): string { + const release = CANVASLINK_ANDROID_RELEASE; + const artifact = `CanvasLink ${release.version}; Android 8+; ${release.applicationId}; versionCode ${release.versionCode}\nAPK: ${release.url}\nSHA-256: ${release.sha256}\nSigning certificate SHA-256: ${release.signerCertificateSha256}`; + if (locale === "ru") return `Установи CanvasLink на мой Android по USB с этого компьютера. Это экспериментальная alpha; компьютер должен запускать сборку CanvasTTY с Settings → CanvasLink (PR: https://github.com/howdeploy/CanvasTTY/pull/166).\n\n${artifact}\n\nИспользуй ADB. Если его нет, установи Google Android SDK Platform-Tools только из официального источника: https://developer.android.com/tools/releases/platform-tools. Выполни adb devices -l. Если устройств несколько, сначала уточни нужное; во всех командах указывай adb -s SERIAL. Если статус unauthorized, попроси меня подтвердить RSA-запрос на Android; не обходи его. USB-отладку включает владелец устройства.\n\nСкачай именно указанный APK и до установки проверь SHA-256. При несовпадении остановись. Если доступен apksigner, также проверь подпись и отпечаток сертификата. Установи через adb -s SERIAL install -r PATH_TO_APK, сохраняя данные. При несовместимой подписи или запрете понижения версии остановись и объясни причину. Не удаляй старое приложение, не очищай данные и не используй обходные флаги.\n\nПосле успешной установки открой adb -s SERIAL shell am start -n io.nexcore.canvaslink/.MainActivity. Проверь, что приложение запустилось. Для работы нужны одна локальная сеть и включённый доступ в Settings → CanvasLink на компьютере; затем компьютер выбирается в Android и вводится одноразовый код. USB нужен только для установки. Не меняй firewall и не открывай порты в Интернет, не перезапускай CanvasTTY с активными терминалами. Не отправляй код подключения или содержимое терминалов в чат. Сообщи результат установки и оставшиеся ручные шаги.\n\nИнструкция: ${release.guideRuUrl}`; + return `Install CanvasLink on my Android over USB from this computer. This is an experimental alpha; the computer must run a CanvasTTY build with Settings → CanvasLink (PR: https://github.com/howdeploy/CanvasTTY/pull/166).\n\n${artifact}\n\nUse ADB. If missing, install Google Android SDK Platform-Tools only from the official source: https://developer.android.com/tools/releases/platform-tools. Run adb devices -l. If multiple devices are present, ask which one to use; specify adb -s SERIAL in every device command. For unauthorized status, ask me to approve the RSA dialog on Android; do not bypass it. The device owner enables USB debugging.\n\nDownload the exact APK above and verify its SHA-256 before installing. Stop on mismatch. If apksigner is available, also verify the signature and certificate fingerprint. Install with adb -s SERIAL install -r PATH_TO_APK, preserving data. Stop and explain incompatible signatures or version downgrade errors. Do not uninstall the existing app, clear data, or use bypass flags.\n\nAfter successful installation, launch adb -s SERIAL shell am start -n io.nexcore.canvaslink/.MainActivity and verify that the app started. Use the same local network and enable access under Settings → CanvasLink on the computer; select the computer in Android and enter the one-time code. USB is only needed for installation. Do not change the firewall, expose ports to the Internet, or restart CanvasTTY with active terminals. Do not send pairing codes or terminal contents to chat. Report installation results and remaining manual steps.\n\nGuide: ${release.guideUrl}`; +} diff --git a/tests/canvaslink-install.test.mjs b/tests/canvaslink-install.test.mjs new file mode 100644 index 000000000..39f25312d --- /dev/null +++ b/tests/canvaslink-install.test.mjs @@ -0,0 +1,58 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { importWithFakeReact, findAll, tick } from "./helpers/fake-react.mjs"; +import { CANVASLINK_ANDROID_RELEASE, canvasLinkInstallPrompt } from "../src/shared/canvasLinkInstall.ts"; + +const ui = await importWithFakeReact("src/renderer/src/features/settings/CanvasLinkSettings.tsx", "CanvasLinkSettings"); +const content = (tree) => findAll(tree, (node) => typeof node.props?.children === "string") + .map((node) => node.props.children).join("\n"); +const button = (tree, label) => findAll(tree, (node) => node.type === "button" && node.props.children === label)[0]; + +for (const locale of ["ru", "en"]) test(`Android onboarding copies a pinned prompt without session data or host changes (${locale})`, async () => { + const previousWindow = globalThis.window; + const copied = [], opened = [], commands = []; + const pairingCode = "987654"; + globalThis.window = { canvasTTY: { + canvasLink: { + state: async () => ({ enabled: true, phase: "streaming", pin: pairingCode, + expires: Date.now() + 120000, devices: [{ name: "Private test tablet" }] }), + command: async (action) => { commands.push(action); throw Error("unexpected host mutation"); }, + }, + clipboard: { writeText: (value) => copied.push(value) }, + external: { openUrl: async (value) => { opened.push(value); } }, + } }; + ui.__reset(); + const render = () => ui.__render(ui.CanvasLinkSettings, { locale, open: true }); + try { + render(); await tick(); ui.__flush(); + let tree = render(); + button(tree, locale === "ru" ? "Скопировать промпт агенту" : "Copy prompt for agent").props.onClick(); + ui.__flush(); tree = render(); + assert.deepEqual(copied, [canvasLinkInstallPrompt(locale)]); + assert.match(content(tree), locale === "ru" ? /Промпт скопирован/ : /Prompt copied/); + assert.ok(copied[0].includes(CANVASLINK_ANDROID_RELEASE.url)); + assert.ok(copied[0].includes(CANVASLINK_ANDROID_RELEASE.sha256)); + assert.ok(!copied[0].includes(pairingCode)); + assert.ok(!copied[0].includes("Private test tablet")); + assert.match(copied[0], /adb -s SERIAL install -r PATH_TO_APK/); + assert.match(copied[0], /unauthorized/); + button(tree, locale === "ru" ? "Инструкция и APK" : "Guide and APK").props.onClick(); + assert.deepEqual(opened, [locale === "ru" ? CANVASLINK_ANDROID_RELEASE.guideRuUrl : CANVASLINK_ANDROID_RELEASE.guideUrl]); + assert.deepEqual(commands, []); + } finally { ui.__unmount(); globalThis.window = previousWindow; } +}); + +test("clipboard failure offers the guide without changing Android access", () => { + const previousWindow = globalThis.window; + globalThis.window = { canvasTTY: { clipboard: { writeText() { throw Error("clipboard unavailable"); } } } }; + ui.__reset(); + try { + const props = { locale: "en", open: false }; + const tree = ui.__render(ui.CanvasLinkSettings, props); + button(tree, "Copy prompt for agent").props.onClick(); + ui.__flush(); + const next = ui.__render(ui.CanvasLinkSettings, props); + assert.match(content(next), /Could not copy the prompt. Open the installation guide/); + assert.doesNotMatch(content(next), /Prompt copied/); + } finally { ui.__unmount(); globalThis.window = previousWindow; } +}); From 19b46df9ad636d785840c9652d8da9c949c403de Mon Sep 17 00:00:00 2001 From: Teo | Nexcore <314265484+teo-nex@users.noreply.github.com> Date: Mon, 26 Oct 2026 14:30:21 +0300 Subject: [PATCH 4/4] fix(canvaslink): enforce session expiry and stop stale connections --- docs/canvaslink-security-audit-2026-10-26.md | 101 ++++++++++++++++++ docs/canvaslink-verification.md | 5 + .../canvaslink/android/assets/direct-input.js | 8 +- .../canvaslink/android/assets/transport.js | 20 ++-- integrations/canvaslink/host/embedded.cjs | 2 +- integrations/canvaslink/host/renderer.js | 7 +- integrations/canvaslink/host/security.cjs | 16 ++- .../tests/embedded-lifecycle.test.cjs | 58 ++++++++-- .../canvaslink/tests/frontend.test.cjs | 9 ++ .../canvaslink/tests/host-peer.test.cjs | 11 +- .../canvaslink/tests/security.test.cjs | 16 +++ .../tests/transport-recovery.test.cjs | 47 +++++++- 12 files changed, 275 insertions(+), 25 deletions(-) create mode 100644 docs/canvaslink-security-audit-2026-10-26.md diff --git a/docs/canvaslink-security-audit-2026-10-26.md b/docs/canvaslink-security-audit-2026-10-26.md new file mode 100644 index 000000000..1fee75ffe --- /dev/null +++ b/docs/canvaslink-security-audit-2026-10-26.md @@ -0,0 +1,101 @@ +# CanvasLink: bounded security and failure-path audit + +Reviewed on 2026-10-26, starting at `f643b31c476e490904437c95c13f0db33ad7c310`. +This report covers the embedded host, Android transport/direct-input assets, +pairing/channel implementation, IPC ownership and installation contract. It is +not a penetration-test certification or acceptance of an installed release. + +## Confirmed findings and fixes + +Severity below is a local assessment of the demonstrated behavior, not a CVSS score. + +| Finding | Preconditions and impact | Fix and regression evidence | +| --- | --- | --- | +| Medium: session expiry did not revoke an established video peer | An already paired client could stop polling and keep receiving video after the advertised 12-hour session lifetime. Request/input checks rejected expired credentials, but no passive expiry path stopped media. This did not bypass the PIN. | `Pairing.expire()` removes credentials before invoking revocation; requests and outgoing signals invoke it, and the host invokes it every 200 ms independently of client traffic and UI health polling. Tests cover the exact expiry boundary, one-time revocation, silent clients and rejected expired requests/queues. Timer delivery still depends on the Electron main event loop. | +| Medium: revocation waited for an asynchronous UI refresh | A stalled or rejected settings-state read delayed or prevented closing a live peer after a valid revoke event. | The capture worker closes tracks, peer and input ownership synchronously before refreshing its UI. A deferred/rejected refresh test proves media and control stop first. | +| Low: delayed signalling could undo client suspension/disconnect | In-flight offers could create a new peer while hidden or locally disconnected. A delayed native pairing callback could also re-arm the client after explicit disconnect. These were stale messages from an existing authenticated connection, not unauthenticated remote pairing. | Ignore offers/disconnect signals unless paired and visible; ignore pairing completions after explicit disconnect until a new connect request. Tests cover unpaired, hidden and disconnected clients, delayed pairing completion and negotiation completion after disconnect. | +| Reliability: stale SDP failure overwrote current connection state | Failure of a superseded offer could mark a healthy new connection as failed. | Peer generations and checks after asynchronous SDP steps isolate stale completion/errors. | +| Reliability: buffered pointer motion survived session close | A queued animation-frame callback could submit old pointer movement to the next connection. | Close discards buffered movement and cancels its frame callback. No clicks, text or keys are replayed. | +| Input safety: one-shot Ctrl leaked through other commits | A Unicode/multi-character IME commit or native key could leave Ctrl armed, turning the next letter into an unexpected shortcut such as Ctrl+C. | Consume Ctrl on each committed text/key event. Browser fixture covers Cyrillic, emoji, multi-character text and native arrow input. | + +Before fixes, the new regressions failed in seven cases in the first targeted +run and two further cases in the revocation-focused run. These were application +failures. A separate HTTP fixture initially lacked Node's `Buffer` global; that +test-harness error was corrected and is not an application finding. + +## Boundaries exercised + +- Real SRP mutual authentication and AES-GCM request/response code, using + synthetic keys and messages. Wrong PIN, used/expired code, attempt limits, + wrong address, tampering, reflection and replay are rejected. +- The actual HTTP request handler, driven by in-memory Node request streams: + public source addresses, browser origins (including `null`), unsupported + routes/methods, non-JSON content types, invalid JSON and oversized bodies are + rejected. An authenticated signal reaches the renderer once; a forged, + replayed, unsupported or revoked signal does not. +- IPC owner/main-frame checks and serialized settings operations, including a + window closing while a command waits. Capture is scoped to the owned window; + no global input injector or shell execution was added. +- Android navigation/resource allowlists, disabled file/content access, denied + WebView media permissions and encrypted app-private session storage were + inspected. Those native boundaries were not penetration-tested on hardware. +- Installation remains pinned to the published APK hash/certificate and stops + on signing conflicts. No private signing files or pairing credentials were + copied into the source tree or report. + +## Verification of this patch + +- **115/115 CanvasLink tests passed**, no skips; 12 tests added to the previous 103. +- **3/3 desktop installation-prompt tests passed**. +- TypeScript checks, production desktop bundles and the source/built-output + secret audit passed. +- The Android development APK compiled with SDK 35 / JDK 17; apksigner verified + its v2/v3 signatures. This is a local development artifact, not a release APK. +- Tests use simulated WebRTC/Electron objects and browser fixtures; HTTP tests + do not open a network listener. They prove the demonstrated control flow, + not real GPU capture or physical input on Windows/Linux/Android. + +The installed desktop/tablet and public `0.3.0-alpha.1` APK were not replaced. +The client-side fixes require a subsequent APK build/release; desktop fixes +require a CanvasTTY build containing this patch. No new release is claimed. + +## Open dependency issues + +Fresh `npm audit --package-lock-only --json` reports **6 vulnerable packages: +5 high, 1 moderate**, matching an audit of the unchanged upstream baseline +`0fa6895d3675b40ec4dc75886826dd2db234c234` with its own manifest/lockfile. +The package names are Electron, Vite, undici, zod, source-map-js and fast-uri. +An installed-tree audit reports 18 affected packages including dependent parent +packages; this is a different audit tree/count, not 12 newly introduced root +advisories. CanvasLink's bonjour/SRP dependencies are not listed by these audits. + +`npm audit --omit=dev` reports zero, but that **does not establish runtime safety**: +Electron is declared as a development dependency and is shipped in the desktop. +Its pinned version here is 43.7.7. The maintainer's +[Electron advisory](https://github.com/electron/electron/security/advisories/GHSA-jr5w-w6x2-84p9) +describes compromised intermediate TLS certificates; 43.8.1 contains the fix +for this release line. Exploitation requires intercepted TLS traffic and a +relevant certificate. This audit did not demonstrate exploitation in CanvasTTY. +Vite's +[file-serving advisory](https://github.com/vitejs/vite/security/advisories/GHSA-rq7h-c2jc-7f22) +requires an exposed development server and additional path/import conditions; +it is not evidence that CanvasLink's separate JSON listener serves files. + +Dependency updates and their desktop/native packaging validation remain open; +this patch does not silently change the upstream Electron/build toolchain. + +## Remaining acceptance and review gaps + +- Physical source-port acceptance on macOS, Windows, Linux and Android remains + pending. Prior prototype acceptance is not acceptance of this patch or APK. +- Native Android logout clears persisted credentials only after its queued + network revoke attempt. Abrupt process death during an offline logout and + pairing/restore cancellation need a native lifecycle regression and follow-up; + the JavaScript disconnect guard does not prove durable native logout. +- No independent cryptographic review of the SRP implementation or WebRTC stack + was performed. Passing protocol tests is not such a review. +- A paired device controls the entire owned CanvasTTY window, including its + terminals and settings. Pairing is a broad trust grant, not terminal sandboxing. + LAN/source-address checks do not protect against a compromised paired device. +- No claim of denial-of-service resistance under adversarial LAN load, + dependency remediation, zero vulnerabilities or release readiness is made. diff --git a/docs/canvaslink-verification.md b/docs/canvaslink-verification.md index cf7f953f5..660dcdbed 100644 --- a/docs/canvaslink-verification.md +++ b/docs/canvaslink-verification.md @@ -1,5 +1,10 @@ # CanvasLink alpha: verification scope +Follow-up: [2026-10-26 security/failure-path audit](canvaslink-security-audit-2026-10-26.md) +records six fixed findings, 115 passing integration tests, dependency issues and +the remaining native/runtime acceptance gaps. The older counts below describe +the earlier source-port and distribution snapshots. + The source port is based on upstream commit `0fa6895d3675b40ec4dc75886826dd2db234c234` (package version 1.8.1). It does not replace the user's installed CanvasTTY 1.8.2 or its active preview. diff --git a/integrations/canvaslink/android/assets/direct-input.js b/integrations/canvaslink/android/assets/direct-input.js index 56f1ccf15..ec04faa66 100644 --- a/integrations/canvaslink/android/assets/direct-input.js +++ b/integrations/canvaslink/android/assets/direct-input.js @@ -26,10 +26,14 @@ if(value.type==='notice'){window.dispatchEvent(new CustomEvent('canvaslink:notice',{detail:value.text}));return;} if(value.type==='paste'){control=false;notify();send({type:'text',text:value.text}).catch(()=>{});return;} if(value.type==='text'){ - if(control&&[...value.text].length===1&&/^[a-z]$/i.test(value.text)){control=false;notify();send({type:'key',key:value.text.toLowerCase(),modifiers:['control']}).catch(()=>{});} + const useControl=control;control=false;notify(); + if(useControl&&[...value.text].length===1&&/^[a-z]$/i.test(value.text)){send({type:'key',key:value.text.toLowerCase(),modifiers:['control']}).catch(()=>{});} else send({type:'text',text:value.text}).catch(()=>{}); } - if(value.type==='key')send({type:'key',key:value.key,modifiers:value.modifiers||[]}).catch(()=>{}); + if(value.type==='key'){ + let modifiers=value.modifiers||[];if(control&&!modifiers.includes('control'))modifiers=[...modifiers,'control']; + control=false;notify();send({type:'key',key:value.key,modifiers}).catch(()=>{}); + } }, active:()=>!!token }); diff --git a/integrations/canvaslink/android/assets/transport.js b/integrations/canvaslink/android/assets/transport.js index 2325ed8c0..448058606 100644 --- a/integrations/canvaslink/android/assets/transport.js +++ b/integrations/canvaslink/android/assets/transport.js @@ -1,6 +1,6 @@ /* Public designer API v2. This file owns secure transport and delivery state. */ (()=>{ - let pc,dc,video,paired=false,move,moveFrame,stats={},lastFrames=0,lastFrameAt=0,draftTouched=false; + let pc,dc,video,paired=false,acceptPairing=true,move,moveFrame,peerEpoch=0,stats={},lastFrames=0,lastFrameAt=0,draftTouched=false; const listeners=new Set(),computers=new Map(); let state={version:2,connection:'idle',discovery:'searching',computers:[],computer:'',error:'',pending:0,delivery:null,stats:{},source:null,draft:''}; const snapshot=()=>JSON.parse(JSON.stringify(state)); @@ -8,9 +8,11 @@ const signal=message=>Native.signal(JSON.stringify(message)); const delivery=new CanvasLinkDelivery.Delivery({channel:()=>dc,onChange:value=>{if(['pointer','scroll'].includes(value.kind)){state.pending=value.pending;return;}update({delivery:value,pending:value.pending});}}); const raw=value=>{if(dc?.readyState==='open'&&dc.bufferedAmount<65536)dc.send(JSON.stringify(value));}; - function close(){delivery.close();dc=null;pc?.close();pc=null;stats={};lastFrames=0;lastFrameAt=0;window.CanvasLink.stats={};update({stats:{},source:null});if(video)video.srcObject=null;} + function close(){peerEpoch++;if(moveFrame)cancelAnimationFrame(moveFrame);moveFrame=null;move=null;delivery.close();dc=null;const previous=pc;pc=null;previous?.close();stats={};lastFrames=0;lastFrameAt=0;window.CanvasLink.stats={};update({stats:{},source:null});if(video)video.srcObject=null;} async function offer(message){ close();update({connection:'connecting',error:''}); + const epoch=peerEpoch; + try{ const current=pc=new RTCPeerConnection({iceServers:[]}); current.ontrack=event=>{if(current!==pc)return;if(video){video.srcObject=event.streams[0];video.play().catch(()=>{});}}; current.ondatachannel=event=>{ @@ -34,15 +36,18 @@ setTimeout(()=>{if(paired&¤t===pc&¤t.connectionState!=='connected')signal({type:'ping'});},2500); } }; - await current.setRemoteDescription(message);await current.setLocalDescription(await current.createAnswer()); + await current.setRemoteDescription(message);if(current!==pc)return; + const answer=await current.createAnswer();if(current!==pc)return; + await current.setLocalDescription(answer);if(current!==pc)return; await new Promise((resolve,reject)=>{if(current.iceGatheringState==='complete')return resolve();const timer=setTimeout(()=>reject(Error('ice-timeout')),12000);current.onicegatheringstatechange=()=>{if(current.iceGatheringState==='complete'){clearTimeout(timer);resolve();}};}); if(current===pc)signal({type:'answer',sdp:current.localDescription.sdp}); + }catch(error){if(epoch===peerEpoch){close();update({connection:'error',error:error.message});}} } function connect({code,computerId,host}={}){ const address=host||computers.get(computerId)?.host||(computers.size===1?[...computers.values()][0].host:''); if(!address){update({error:computers.size?'choose-computer':'computer-not-found'});return false;} if(!/^\d{6}$/.test(code||'')){update({error:'invalid-code'});return false;} - update({connection:'pairing',computer:address,error:''});Native.connect(address,code);return true; + acceptPairing=true;update({connection:'pairing',computer:address,error:''});Native.connect(address,code);return true; } function sendInput(input){ if(input.type==='pointer'&&input.action==='move'){ @@ -55,7 +60,7 @@ const api=window.CanvasLinkClient=Object.freeze({ version:2,getState:snapshot,subscribe(listener){listeners.add(listener);listener(snapshot());return()=>listeners.delete(listener);}, attachVideo(element){video=element;},connect,discover(){update({discovery:'searching'});Native.discover();}, - disconnect(){paired=false;close();Native.disconnect();update({connection:'idle'});}, + disconnect(){acceptPairing=false;paired=false;close();Native.disconnect();update({connection:'idle'});}, sendInput,sendText(text){if(typeof text!=='string'||!text.length||text.length>8000)return Promise.reject(Error('invalid-text'));return sendInput({type:'text',text});}, sendKey(key,modifiers=[]){return sendInput({type:'key',key,modifiers});}, setDraft(text){draftTouched=true;state.draft=String(text).slice(0,8000);return Native.saveDraft(state.draft);}, @@ -67,9 +72,10 @@ if(event.type==='lost'){computers.delete(value);update({computers:[...computers.values()]});} if(event.type==='discovery')update({discovery:value}); if(event.type==='draft'&&!draftTouched)update({draft:value||''}); - if(event.type==='paired'){paired=true;update({connection:'connecting',computer:value.host,error:''});} + if(event.type==='paired'){if(!acceptPairing)return;paired=true;update({connection:'connecting',computer:value.host,error:''});} if(event.type==='signal'){ - if(value.type==='offer')offer(value).catch(error=>update({connection:'error',error:error.message})); + if(!paired||document.hidden)return; + if(value.type==='offer')void offer(value); if(value.type==='disconnect'){close();update({connection:'reconnecting'});} } if(event.type==='error')update({connection:paired?'reconnecting':'error',error:String(value)}); diff --git a/integrations/canvaslink/host/embedded.cjs b/integrations/canvaslink/host/embedded.cjs index 9ce0164e5..de0b0f449 100644 --- a/integrations/canvaslink/host/embedded.cjs +++ b/integrations/canvaslink/host/embedded.cjs @@ -127,7 +127,7 @@ function install({getWindow,glasses}={}){ target.webContents.beginFrameSubscription(false,frame); frameTimer=setInterval(async()=>{if(busy||pendingFrame||!pairing.sessions.size||Date.now()-lastFrame<100||target.isDestroyed())return;busy=true;try{frame(await target.webContents.capturePage(undefined,{stayHidden:true,stayAwake:false}));}catch{}finally{busy=false;}},100); }; - healthTimer=setInterval(async()=>{if(healthBusy||!pairing.sessions.size||target.isDestroyed()||sourceSurface!=='settings'&&Date.now()-lastHealth<1000)return;healthBusy=true;lastHealth=Date.now();try{const focus=await target.webContents.executeJavaScript(focusScript);sourceSurface=focus.surface;events({type:'health',value:{capture:captureMode==='tab'?(target.webContents.isBeingCaptured()?'active':'stalled'):(Date.now()-lastFrame<3000?'active':'stalled'),windowFocused:target.isFocused(),target:focus.kind,surface:focus.surface,viewport:focus.viewport,terminalView:focus.terminalView,dragRects:focus.dragRects,rect:focus.rect,caret:focus.caret}});}catch{}finally{healthBusy=false;}},200); + healthTimer=setInterval(async()=>{pairing.expire();if(healthBusy||!pairing.sessions.size||target.isDestroyed()||sourceSurface!=='settings'&&Date.now()-lastHealth<1000)return;healthBusy=true;lastHealth=Date.now();try{const focus=await target.webContents.executeJavaScript(focusScript);sourceSurface=focus.surface;events({type:'health',value:{capture:captureMode==='tab'?(target.webContents.isBeingCaptured()?'active':'stalled'):(Date.now()-lastFrame<3000?'active':'stalled'),windowFocused:target.isFocused(),target:focus.kind,surface:focus.surface,viewport:focus.viewport,terminalView:focus.terminalView,dragRects:focus.dragRects,rect:focus.rect,caret:focus.caret}});}catch{}finally{healthBusy=false;}},200); target.removeListener('closed',disable);target.once('closed',disable); }catch(error){disable();throw error;} } diff --git a/integrations/canvaslink/host/renderer.js b/integrations/canvaslink/host/renderer.js index 87fcd8dac..e599fcb66 100644 --- a/integrations/canvaslink/host/renderer.js +++ b/integrations/canvaslink/host/renderer.js @@ -125,11 +125,16 @@ window.host.onEvent(async event => { if (e.type === 'pointer') { fixturePointer = e; if (e.action === 'down') fixtureClicks++; } return; } + // Revocation must stop media and input before a fallible asynchronous UI read. + if (event.type === 'revoke') { + if (activeId === event.id) { closePeer(); report('waiting'); } + try { await refreshState(); } catch (error) { $('error').textContent = error.message; } + return; + } await refreshState(); if (event.type === 'paired' || event.type === 'source') { if (state.connected[0] && state.source) await start(state.connected[0].id); } - if (event.type === 'revoke' && activeId === event.id) closePeer(); if (event.type === 'signal') { if (event.message.type === 'answer' && peer && activeId === event.id) await peer.setRemoteDescription(event.message); if (event.message.type === 'ping') await start(event.id); diff --git a/integrations/canvaslink/host/security.cjs b/integrations/canvaslink/host/security.cjs index 54dad7140..7ca4f562c 100644 --- a/integrations/canvaslink/host/security.cjs +++ b/integrations/canvaslink/host/security.cjs @@ -37,13 +37,23 @@ class Pairing { this.sessions = new Map(); this.rotate(); } rotate() { - for (const id of this.sessions.keys()) this.onRevoke(id); - this.sessions.clear(); this.pending = new Map(); this.attempts = 0; this.starts = 0; + const revoked = [...this.sessions.keys()]; + this.sessions.clear(); + for (const id of revoked) this.onRevoke(id); + this.pending = new Map(); this.attempts = 0; this.starts = 0; this.pin = String(crypto.randomInt(0, 1000000)).padStart(6, '0'); this.expires = this.now() + 120000; this.salt = client.generateSalt(); this.verifier = client.deriveVerifier(client.derivePrivateKey(this.salt, USER, this.pin)); } + expire() { + const now = this.now(); + for (const [id, session] of this.sessions) { + if (session.expires > now) continue; + this.sessions.delete(id); this.onRevoke(id); + } + for (const [id, pending] of this.pending) if (pending.until <= now) this.pending.delete(id); + } start(body, address) { if (this.now() >= this.expires || this.attempts >= 5 || this.starts >= 30) throw Error('pairing-closed'); if (!body || !/^[0-9a-f]{512}$/.test(body.A) || typeof body.name !== 'string' || body.name.length > 80) @@ -70,6 +80,7 @@ class Pairing { return { id, proof: result.proof }; } receive(packet, address, handle) { + this.expire(); const session = this.sessions.get(packet?.id); if (!session || session.expires <= this.now() || session.address !== address || packet.seq <= session.incoming) throw Error('unauthorized'); @@ -79,6 +90,7 @@ class Pairing { return seal(session.key, session.id, packet.seq, 'response', result); } queue(id, message) { + this.expire(); const session = this.sessions.get(id); if (session && session.queue.length < 16) session.queue.push(message); } diff --git a/integrations/canvaslink/tests/embedded-lifecycle.test.cjs b/integrations/canvaslink/tests/embedded-lifecycle.test.cjs index 94b56a917..3dc8e3f98 100644 --- a/integrations/canvaslink/tests/embedded-lifecycle.test.cjs +++ b/integrations/canvaslink/tests/embedded-lifecycle.test.cjs @@ -5,6 +5,10 @@ const path = require('node:path'); const vm = require('node:vm'); const { createRequire } = require('node:module'); const { EventEmitter } = require('node:events'); +const { Readable } = require('node:stream'); +const crypto = require('node:crypto'); +const srp = require('secure-remote-password/client'); +const { seal, open, USER } = require('../host/security.cjs'); const file = path.join(__dirname, '../host/embedded.cjs'); function fixture({ failLoad = false } = {}) { const counters = { listeners: 0, discovery: 0, panels: 0, close: 0, destroy: 0 }; @@ -13,9 +17,10 @@ function fixture({ failLoad = false } = {}) { const target = new EventEmitter(); target.isDestroyed = () => false; target.webContents = { mainFrame: {}, executeJavaScript: async () => {}, endFrameSubscription() {} }; target.getContentSize = () => [1000, 700]; target.getTitle = () => 'Owned fixture'; - let panel, captureHandler; + let panel, captureHandler, pairing, serve; + const events = []; class Panel extends EventEmitter { - constructor() { super(); counters.panels++; panel = this; this.webContents = new EventEmitter(); this.webContents.mainFrame = {}; this.webContents.setWindowOpenHandler = () => {}; } + constructor() { super(); counters.panels++; panel = this; this.webContents = new EventEmitter(); this.webContents.mainFrame = {}; this.webContents.setWindowOpenHandler = () => {}; this.webContents.send = (channel, value) => events.push(value); } isDestroyed() { return !!this.destroyed; } destroy() { this.destroyed = true; counters.destroy++; } hide() {} @@ -27,14 +32,20 @@ function fixture({ failLoad = false } = {}) { const replacements = { electron, 'node:fs': { mkdirSync() {} }, - 'node:http': { createServer() { return { requestTimeout: 0, once() {}, listen(port, host, done) { counters.listeners++; done(); }, address: () => ({ port: 3499 }), close() { counters.close++; } }; } }, - './discovery.cjs': { publish() { counters.discovery++; return { destroy() {} }; } } + 'node:http': { createServer(handler) { serve=handler;return { requestTimeout: 0, once() {}, listen(port, host, done) { counters.listeners++; done(); }, address: () => ({ port: 3499 }), close() { counters.close++; } }; } }, + './discovery.cjs': { publish() { counters.discovery++; return { destroy() {} }; } }, + './security.cjs': { ...localRequire('./security.cjs'), Pairing: class extends localRequire('./security.cjs').Pairing { constructor(options) { super(options); pairing = this; } } } }; - const context = { require: name => Object.hasOwn(replacements, name) ? replacements[name] : localRequire(name), module: { exports: {} }, __dirname: path.dirname(file), process, setInterval(fn) { const token = {}; timers.add(token); return token; }, clearInterval(token) { timers.delete(token); } }; + const context = { require: name => Object.hasOwn(replacements, name) ? replacements[name] : localRequire(name), module: { exports: {} }, __dirname: path.dirname(file), process, Buffer, setInterval(fn) { const token = { fn }; timers.add(token); return token; }, clearInterval(token) { timers.delete(token); } }; vm.runInNewContext(fs.readFileSync(file, 'utf8'), context); context.module.exports.install({ getWindow: () => target }); const event = { sender: target.webContents, senderFrame: target.webContents.mainFrame }; - return { counters, handlers, event, target, timers, captureSession, get panel() { return panel; }, capture: request => { let result; captureHandler(request, value => result = value); return result; }, state: () => handlers.get('canvaslink-settings:state')(event), command: action => handlers.get('canvaslink-settings:command')(event, action) }; + const request=async ({route='/channel',method='POST',address='192.168.2.23',headers={},body={}}={})=>{ + const req=Readable.from([Buffer.from(typeof body==='string'?body:JSON.stringify(body))]); + Object.assign(req,{method,url:route,socket:{remoteAddress:address},headers:{'content-type':'application/json',...headers}}); + const result={};await serve(req,{headersSent:false,writeHead(status,headers){result.status=status;result.headers=headers;},end(body){result.body=JSON.parse(body);}});return result; + }; + return { counters, handlers, event, target, timers, captureSession, events, request, get pairing() { return pairing; }, get panel() { return panel; }, capture: request => { let result; captureHandler(request, value => result = value); return result; }, state: () => handlers.get('canvaslink-settings:state')(event), command: action => handlers.get('canvaslink-settings:command')(event, action) }; } test('Installing CanvasLink is inert until an owning settings command enables access', async () => { const f = fixture(); @@ -62,3 +73,38 @@ test('A failed capture-worker load closes the listener and allows a clean retry' await assert.rejects(f.command('enable'), /fixture-load-failed/); assert.equal(f.counters.listeners, 2); assert.equal(f.counters.close, 2); }); +test('The host revokes an expired video session even when the client sends nothing', async () => { + const f=fixture();await f.command('enable'); + f.pairing.sessions.set('expired',{id:'expired',name:'Silent peer',expires:0,queue:[]}); + for(const timer of f.timers)await timer.fn(); + assert.equal(f.pairing.sessions.has('expired'),false); + assert.equal(f.events.filter(e=>e.type==='revoke'&&e.id==='expired').length,1); + for(const timer of f.timers)await timer.fn(); + assert.equal(f.events.filter(e=>e.type==='revoke').length,1); + await f.command('disable'); +}); +test('The HTTP boundary rejects browser origins, public peers, unsupported routes and oversized bodies',async()=>{ + const f=fixture();await f.command('enable'); + for(const options of [{address:'8.8.8.8'},{headers:{origin:'https://attacker.invalid'}},{headers:{origin:'null'}},{method:'GET'},{route:'/exec'},{body:'{'},{body:' '.repeat(131073)}]){ + const response=await f.request(options);assert.equal(response.status,403,JSON.stringify(options).slice(0,150)); + assert.equal(response.headers['Cache-Control'],'no-store'); + } + assert.equal((await f.request({headers:{'content-type':'text/plain'}})).status,415); + assert.equal(f.events.length,0);assert.equal(f.pairing.sessions.size,0);await f.command('disable'); +}); +test('Only mutually authenticated, fresh and address-bound HTTP signals reach the renderer',async()=>{ + const f=fixture();await f.command('enable'); + const a=srp.generateEphemeral(),challenge=(await f.request({route:'/pair/start',body:{A:a.public,name:'Synthetic security test'}})).body; + const session=srp.deriveSession(a.secret,challenge.B,challenge.salt,USER,srp.derivePrivateKey(challenge.salt,USER,f.pairing.pin)); + const finish=await f.request({route:'/pair/finish',body:{id:challenge.id,proof:session.proof}});assert.equal(finish.status,200);srp.verifySession(a.public,session,finish.body.proof); + const id=finish.body.id,key=crypto.createHash('sha256').update(Buffer.from(session.key,'hex')).update('CanvasLink/channel/1').digest(); + const signal=seq=>seal(key,id,seq,'request',{type:'signal',message:{type:'ping'}}); + const first=signal(1);assert.equal((await f.request({body:first,address:'192.168.2.24'})).status,403); + const response=await f.request({body:first});assert.equal(response.status,200);assert.deepEqual(open(key,id,response.body,'response'),{ok:true}); + assert.equal((await f.request({body:first})).status,403); + assert.equal((await f.request({body:{...signal(2),data:Buffer.alloc(32).toString('base64')}})).status,403); + assert.equal((await f.request({body:seal(key,id,2,'request',{type:'signal',message:{type:'exec',command:'must-not-run'}})})).status,403); + assert.equal(f.events.filter(e=>e.type==='signal').length,1); + await f.command('revoke');assert.equal((await f.request({body:signal(3)})).status,403); + assert.equal(f.events.filter(e=>e.type==='signal').length,1);await f.command('disable'); +}); diff --git a/integrations/canvaslink/tests/frontend.test.cjs b/integrations/canvaslink/tests/frontend.test.cjs index 447a1b269..4a756f9c1 100644 --- a/integrations/canvaslink/tests/frontend.test.cjs +++ b/integrations/canvaslink/tests/frontend.test.cjs @@ -31,6 +31,15 @@ test('Cursor following never overrides a manual view or opens a new keyboard',as }); test('Explicit paste is literal even when one-shot Ctrl was latched',async t=>{const {page}=await typing(t);await page.locator('#ctrl').click();await page.evaluate(()=>__uiFixture.configure({clipboard:'a'}));await page.locator('#paste').click();assert.deepEqual(await commands(page),[{type:'text',text:'a'}]);assert.equal(await page.locator('#ctrl').getAttribute('aria-pressed'),'false');}); +test('One-shot Ctrl cannot leak through a Unicode commit or native key into the next letter',async t=>{ + const {page}=await typing(t); + for(const text of ['Ж','🙂','word']){ + await page.locator('#ctrl').click();await page.evaluate(text=>{__uiFixture.ime({type:'text',text});__uiFixture.ime({type:'text',text:'c'});},text); + assert.equal(await page.locator('#ctrl').getAttribute('aria-pressed'),'false'); + } + await page.locator('#ctrl').click();await page.evaluate(()=>{__uiFixture.ime({type:'key',key:'ArrowLeft',modifiers:[]});__uiFixture.ime({type:'text',text:'c'});}); + assert.deepEqual(await commands(page),['Ж','c','🙂','c','word','c'].map(text=>({type:'text',text})).concat([{type:'key',key:'ArrowLeft',modifiers:['control']},{type:'text',text:'c'}])); +}); test('Initial terminal framing is visual only and overview stays under user control',async t=>{ const ui=await openUI({connected:true,width:1024,height:768,touch:true});t.after(()=>ui.browser.close()); diff --git a/integrations/canvaslink/tests/host-peer.test.cjs b/integrations/canvaslink/tests/host-peer.test.cjs index 7415f9597..70a6e56a1 100644 --- a/integrations/canvaslink/tests/host-peer.test.cjs +++ b/integrations/canvaslink/tests/host-peer.test.cjs @@ -19,7 +19,7 @@ async function host(){ let event; const context={window:{CanvasLinkFrames:require('../host/frames.js'),host:{state:async()=>({embedded:true,source:'Owned target',addresses:[],connected:[],width:100,height:100}),permissions:async()=>({platform:'linux'}),sources:async()=>[{id:'owned',name:'Owned target'}],select:async()=>{},onEvent:handler=>event=handler,report(){},captureFallback:async()=>{},inputSession:async(...args)=>sessions.push(args),signal:async(...args)=>signals.push(args)}},document:{getElementById:element,createElement:()=>({})},navigator:{mediaDevices:{getDisplayMedia:()=>new Promise((resolve,reject)=>captures.push({resolve,reject}))}},RTCPeerConnection:Peer,RTCRtpSender:{getCapabilities:()=>({codecs:[]})},setInterval(){},setTimeout:fn=>{const t={fn};timers.push(t);return t;},clearTimeout:t=>{if(t)t.cancelled=true;}}; vm.runInNewContext(source,context);await tick(); - return {event,captures,peers,signals,sessions,stream,timers,fallbacks}; + return {event,captures,peers,signals,sessions,stream,timers,fallbacks,bridge:context.window.host}; } test('Overlapping reconnect capture resolves only the newest generation',async()=>{ const h=await host(),first=h.event({type:'signal',id:'device',message:{type:'ping'}});await tick(); @@ -65,3 +65,12 @@ test('An old initial-frame watchdog cannot replace a reconnected capture',async( resolve(new Map());await waiting; assert.equal(h.timers[0].cancelled,true);assert.equal(h.fallbacks.length,0);assert.equal(h.peers[1].track,capture.track); }); +test('Revocation stops tracks and control before any settings refresh, even if refresh fails',async()=>{ + const h=await host(),run=h.event({type:'signal',id:'device',message:{type:'ping'}});await tick(); + const capture=h.stream();h.captures[0].resolve(capture);await run; + let refresh;h.bridge.state=()=>new Promise((resolve,reject)=>refresh={reject}); + const revoke=h.event({type:'revoke',id:'device'}).catch(error=>error); + assert.equal(capture.track.stopped,true,'revocation waited for UI state');assert.equal(h.peers[0].closed,true); + assert.equal(h.sessions.at(-1)[1],null); + refresh.reject(Error('state-unavailable'));await revoke; +}); diff --git a/integrations/canvaslink/tests/security.test.cjs b/integrations/canvaslink/tests/security.test.cjs index be3a59899..2007e7932 100644 --- a/integrations/canvaslink/tests/security.test.cjs +++ b/integrations/canvaslink/tests/security.test.cjs @@ -53,3 +53,19 @@ test('counter gaps after Android crash are accepted; old replies and expired ses now+=12*3600000; assert.throws(()=>p.receive(seal(c.key,c.id,10002,'request',{}),'192.168.2.23',()=>({})),/unauthorized/); }); +test('Expired sessions revoke live access once, including without a client request',()=>{ + let now=1000;const revoked=[]; + const p=new Pairing({now:()=>now,onRevoke:id=>{assert.equal(p.sessions.has(id),false);revoked.push(id);}}),c=authenticate(p); + now+=12*3600000-1;p.expire();assert.deepEqual(revoked,[]); + now++;p.expire();p.expire();p.rotate(); + assert.deepEqual(revoked,[c.id]);assert.equal(p.sessions.size,0); +}); +test('Neither requests nor queued signals retain expired sessions',()=>{ + for(const operation of ['request','queue']){ + let now=1000;const revoked=[],p=new Pairing({now:()=>now,onRevoke:id=>revoked.push(id)}),c=authenticate(p); + now+=12*3600000; + if(operation==='request')assert.throws(()=>p.receive(seal(c.key,c.id,1,'request',{}),'192.168.2.23',()=>assert.fail('expired operation executed')),/unauthorized/); + else p.queue(c.id,{type:'offer',sdp:'expired'}); + assert.deepEqual(revoked,[c.id]);assert.equal(p.sessions.size,0); + } +}); diff --git a/integrations/canvaslink/tests/transport-recovery.test.cjs b/integrations/canvaslink/tests/transport-recovery.test.cjs index d428492b4..76d91cff0 100644 --- a/integrations/canvaslink/tests/transport-recovery.test.cjs +++ b/integrations/canvaslink/tests/transport-recovery.test.cjs @@ -1,13 +1,13 @@ const {test}=require('node:test'),assert=require('node:assert/strict'),vm=require('node:vm'),fs=require('node:fs'),path=require('node:path'); const source=fs.readFileSync(path.join(__dirname,'../android/assets/transport.js'),'utf8'); -function client(){ - const signals=[],sent=[],peers=[],intervals=[]; +function client({remoteDescription}={}){ + const signals=[],sent=[],peers=[],intervals=[],events=new Map(),frames=new Map();let frameId=0; class Peer{ constructor(){peers.push(this);this.connectionState='new';this.iceGatheringState='complete';} - async setRemoteDescription(){} async createAnswer(){return {type:'answer',sdp:'local'};} async setLocalDescription(value){this.localDescription=value;} close(){} + async setRemoteDescription(value){if(remoteDescription)await remoteDescription(value);} async createAnswer(){return {type:'answer',sdp:'local'};} async setLocalDescription(value){this.localDescription=value;} close(){this.closed=true;} } - const context={window:{},document:{hidden:false,addEventListener(){}},Native:{signal:json=>signals.push(JSON.parse(json)),diagnostics(){},discover(){},restore(){},loadDraft(){}},CanvasLinkDelivery:{Delivery:class{close(){}send(value){sent.push(value);return Promise.resolve();}receive(){}}},RTCPeerConnection:Peer,setInterval:fn=>intervals.push(fn),setTimeout(){},performance:{now:()=>1}}; - vm.runInNewContext(source,context);return {...context,signals,sent,peers,intervals,receive:context.window.CanvasLink.receive,api:context.window.CanvasLinkClient}; + const context={window:{},document:{hidden:false,addEventListener:(type,handler)=>events.set(type,handler)},Native:{signal:json=>signals.push(JSON.parse(json)),disconnect(){},diagnostics(){},discover(){},restore(){},loadDraft(){}},CanvasLinkDelivery:{Delivery:class{close(){}send(value){sent.push(value);return Promise.resolve();}receive(){}}},RTCPeerConnection:Peer,setInterval:fn=>intervals.push(fn),setTimeout(){},requestAnimationFrame:fn=>{frames.set(++frameId,fn);return frameId;},cancelAnimationFrame:id=>frames.delete(id),performance:{now:()=>1}}; + vm.runInNewContext(source,context);return {...context,signals,sent,peers,intervals,events,frames,receive:context.window.CanvasLink.receive,api:context.window.CanvasLinkClient}; } test('Successful encrypted polling after an outage retries signalling without replaying input',()=>{ const c=client();c.receive({type:'paired',value:{host:'192.168.2.22:3497'}});c.receive({type:'network',value:'offline'}); @@ -39,3 +39,40 @@ test('Video feedback reports zero before decoding and the current peer frame cou c.receive({type:'signal',value:{type:'offer',sdp:'new'}});await new Promise(resolve=>setImmediate(resolve)); const fresh=c.peers[1];fresh.ondatachannel({channel});fresh.getStats=async()=>new Map();await c.intervals[0]();assert.equal(packets.at(-1).videoFrames,0); }); +test('Late offers cannot reopen an unpaired, explicitly disconnected or hidden client',async()=>{ + for(const status of ['unpaired','disconnected','hidden']){ + const c=client();if(status!=='unpaired')c.receive({type:'paired',value:{host:'local'}}); + if(status==='disconnected')c.api.disconnect(); + if(status==='hidden'){c.document.hidden=true;c.events.get('visibilitychange')();} + const before=c.api.getState().connection; + c.receive({type:'signal',value:{type:'offer',sdp:'late'}});await new Promise(resolve=>setImmediate(resolve)); + assert.equal(c.peers.length,0,status);assert.equal(c.api.getState().connection,before); + c.receive({type:'signal',value:{type:'disconnect'}});assert.equal(c.api.getState().connection,before); + } +}); +test('A rejected old negotiation cannot replace the new connected peer status',async()=>{ + let rejectOld;const c=client({remoteDescription:value=>value.sdp==='old'?new Promise((resolve,reject)=>rejectOld=reject):undefined}); + c.receive({type:'paired',value:{host:'local'}});c.receive({type:'signal',value:{type:'offer',sdp:'old'}}); + c.receive({type:'signal',value:{type:'offer',sdp:'new'}});await new Promise(resolve=>setImmediate(resolve)); + const fresh=c.peers[1],channel={readyState:'open'};fresh.ondatachannel({channel});channel.onopen(); + rejectOld(Error('old-peer-closed'));await new Promise(resolve=>setImmediate(resolve)); + assert.equal(c.api.getState().connection,'connected');assert.equal(c.api.getState().error,'');assert.equal(fresh.closed,undefined); + assert.equal(c.signals.filter(value=>value.type==='answer').length,1); +}); +test('Closing a session discards buffered pointer motion before any new peer',()=>{ + const c=client();c.receive({type:'paired',value:{host:'local'}}); + c.api.sendInput({type:'pointer',action:'move',x:.5,y:.5,button:0});assert.equal(c.frames.size,1); + c.api.disconnect();for(const fn of c.frames.values())fn(); + assert.deepEqual(c.sent,[]);assert.equal(c.frames.size,0); +}); +test('A delayed native pairing callback cannot undo explicit disconnect',async()=>{ + const c=client();c.api.disconnect(); + c.receive({type:'paired',value:{host:'old-host'}});c.receive({type:'signal',value:{type:'offer',sdp:'old'}}); + await new Promise(resolve=>setImmediate(resolve));assert.equal(c.peers.length,0);assert.equal(c.api.getState().connection,'idle'); +}); +test('A negotiation completing after disconnect sends no answer',async()=>{ + let resolveOld;const c=client({remoteDescription:()=>new Promise(resolve=>resolveOld=resolve)}); + c.receive({type:'paired',value:{host:'local'}});c.receive({type:'signal',value:{type:'offer',sdp:'old'}}); + c.api.disconnect();resolveOld();await new Promise(resolve=>setImmediate(resolve)); + assert.deepEqual(c.signals,[]);assert.equal(c.api.getState().connection,'idle'); +});