diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 522e0a5..a1e2480 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,8 +1,8 @@ blank_issues_enabled: false contact_links: - name: 💬 Questions & Discussion - url: https://github.com/XxMinor/mykvm/discussions + url: https://github.com/aceleisureman/mykvm/discussions about: Ask questions, share setups, and talk about ideas. Not sure if it's a bug? Start here. - name: 📖 Documentation (README) - url: https://github.com/XxMinor/mykvm#readme + url: https://github.com/aceleisureman/mykvm#readme about: Quick start, permissions, and limitations before filing an issue. diff --git a/.github/ISSUE_TEMPLATE/setup_help.yml b/.github/ISSUE_TEMPLATE/setup_help.yml index 7722bed..8573fdc 100644 --- a/.github/ISSUE_TEMPLATE/setup_help.yml +++ b/.github/ISSUE_TEMPLATE/setup_help.yml @@ -5,7 +5,7 @@ body: - type: markdown attributes: value: | - For general "how do I…" questions, the [Discussions board](https://github.com/XxMinor/mykvm/discussions) is often faster. Use this template when setup isn't working and you want help debugging. + For general "how do I…" questions, the [Discussions board](https://github.com/aceleisureman/mykvm/discussions) is often faster. Use this template when setup isn't working and you want help debugging. - type: textarea id: goal attributes: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 939f57c..23c13f3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -3,8 +3,7 @@ name: Release on: push: branches: - - main - - bate + - dev workflow_dispatch: inputs: channel: @@ -66,7 +65,7 @@ jobs: subject="$(git log -1 --pretty=%s)" channel="${REQUESTED_CHANNEL:-}" if [[ -z "$channel" ]]; then - if [[ "$REF_NAME" == "bate" ]]; then + if [[ "$REF_NAME" == "dev" ]]; then channel="beta" else channel="stable" @@ -322,8 +321,9 @@ jobs: data.version = version; if (file === 'src-tauri/tauri.conf.json' && channel === 'beta') { data.plugins.updater.endpoints = [ - 'https://github.com/XxMinor/mykvm/releases/download/beta/latest.json', + 'https://github.com/aceleisureman/mykvm/releases/download/beta/latest.json', ]; + data.bundle.macOS.signingIdentity = '-'; } fs.writeFileSync(file, `${JSON.stringify(data, null, 2)}\n`); } @@ -336,7 +336,7 @@ jobs: NODE - name: Validate Apple signing secrets - if: matrix.name == 'macos' + if: matrix.name == 'macos' && needs.prepare.outputs.channel != 'beta' shell: bash env: APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} @@ -380,7 +380,7 @@ jobs: exit "$missing" - name: Configure Apple notarization - if: matrix.name == 'macos' + if: matrix.name == 'macos' && needs.prepare.outputs.channel != 'beta' shell: bash env: APPLE_API_KEY_PRIVATE_KEY: ${{ secrets.APPLE_API_KEY_PRIVATE_KEY }} @@ -424,7 +424,7 @@ jobs: run: cargo check --manifest-path src-tauri/Cargo.toml - name: Set up self-signed signing keychain - if: matrix.name == 'macos' + if: matrix.name == 'macos' && needs.prepare.outputs.channel != 'beta' shell: bash env: APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} @@ -474,7 +474,6 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} - APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} # Notarization credentials are NOT wired in here: passing unset secrets # injects empty strings, which Tauri reads as "present" and tries to # notarize with blank creds. The "Configure Apple notarization" step @@ -502,6 +501,7 @@ jobs: if: matrix.name == 'macos' shell: bash env: + RELEASE_CHANNEL: ${{ needs.prepare.outputs.channel }} APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} APPLE_ID: ${{ secrets.APPLE_ID }} run: | @@ -528,12 +528,19 @@ jobs: # The signature itself must always be valid. codesign --verify --deep --strict --verbose=2 "$app_path" - codesign --verify --verbose=2 "$dmg_path" + + # Preview DMGs wrap an ad-hoc signed app but are not themselves + # signed by Apple. Developer ID releases still require DMG signing. + if [ "$RELEASE_CHANNEL" != "beta" ]; then + codesign --verify --verbose=2 "$dmg_path" + else + echo "Ad-hoc signed beta: skipping DMG codesign verification." + fi # spctl asserts Gatekeeper acceptance, which only holds for notarized # builds. A free self-signed release is expected to fail it, so only # run it when notarization credentials were provided. - if [ -n "${APPLE_API_KEY_ID:-}${APPLE_ID:-}" ]; then + if [ "$RELEASE_CHANNEL" != "beta" ] && [ -n "${APPLE_API_KEY_ID:-}${APPLE_ID:-}" ]; then spctl -a -t exec -vv "$app_path" spctl -a -t open --context context:primary-signature -vv "$dmg_path" else diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 3c8f8b9..84766c2 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -32,6 +32,22 @@ "args": ["${workspaceFolder}/scripts/run-tauri-dev.sh"] } }, + { + "label": "tauri:dev-build", + "type": "cargo", + "command": "build", + "args": [ + "--manifest-path", + "${workspaceFolder}/src-tauri/Cargo.toml" + ], + "problemMatcher": ["$rustc"], + "group": "build", + "presentation": { + "panel": "dedicated", + "reveal": "always", + "clear": true + } + }, { "label": "check-dev-env", "type": "shell", @@ -77,6 +93,38 @@ "command": "npm.cmd", "args": ["run", "tauri:build"] } + }, + { + "label": "cargo:check", + "type": "cargo", + "command": "check", + "args": [ + "--manifest-path", + "${workspaceFolder}/src-tauri/Cargo.toml" + ], + "problemMatcher": ["$rustc"], + "group": "build", + "presentation": { + "panel": "dedicated", + "reveal": "always", + "clear": true + } + }, + { + "label": "cargo:test", + "type": "cargo", + "command": "test", + "args": [ + "--manifest-path", + "${workspaceFolder}/src-tauri/Cargo.toml" + ], + "problemMatcher": ["$rustc"], + "group": "test", + "presentation": { + "panel": "dedicated", + "reveal": "always", + "clear": true + } } ] } diff --git a/README.md b/README.md index cf0ba86..7e0ddb5 100644 --- a/README.md +++ b/README.md @@ -4,10 +4,10 @@ Move your cursor off the edge of one screen and it lands on the next machine. Your keyboard follows, and the clipboard (text and images) syncs automatically. No KVM hardware, no cables. -[![Download](https://img.shields.io/github/v/release/XxMinor/mykvm?label=Download&style=for-the-badge)](https://github.com/XxMinor/mykvm/releases/latest) -[![Stars](https://img.shields.io/github/stars/XxMinor/mykvm?label=Stars&logo=github&style=for-the-badge)](https://github.com/XxMinor/mykvm/stargazers) -[![Forks](https://img.shields.io/github/forks/XxMinor/mykvm?label=Forks&logo=github&style=for-the-badge)](https://github.com/XxMinor/mykvm/forks) -[![Platforms](https://img.shields.io/badge/platforms-macOS%20%7C%20Windows%20%7C%20Linux-2786ff?style=for-the-badge)](https://github.com/XxMinor/mykvm/releases/latest) +[![Download](https://img.shields.io/github/v/release/aceleisureman/mykvm?label=Download&style=for-the-badge)](https://github.com/aceleisureman/mykvm/releases/latest) +[![Stars](https://img.shields.io/github/stars/aceleisureman/mykvm?label=Stars&logo=github&style=for-the-badge)](https://github.com/aceleisureman/mykvm/stargazers) +[![Forks](https://img.shields.io/github/forks/aceleisureman/mykvm?label=Forks&logo=github&style=for-the-badge)](https://github.com/aceleisureman/mykvm/forks) +[![Platforms](https://img.shields.io/badge/platforms-macOS%20%7C%20Windows%20%7C%20Linux-2786ff?style=for-the-badge)](https://github.com/aceleisureman/mykvm/releases/latest) [![License: MIT](https://img.shields.io/badge/license-MIT-green?style=for-the-badge)](./LICENSE) [中文说明](./README.zh-CN.md) @@ -22,7 +22,7 @@ Move your cursor off the edge of one screen and it lands on the next machine. Yo ## Quick Start -1. **Install on both machines.** Download the installer for each OS from the [latest release](https://github.com/XxMinor/mykvm/releases/latest). +1. **Install on both machines.** Download the installer for each OS from the [latest release](https://github.com/aceleisureman/mykvm/releases/latest). 2. **Pick roles.** On the machine whose keyboard and mouse you want to share, open MyKVM and keep **Server** mode (the default). On the other machine, open MyKVM and switch to **Client** mode in Settings. 3. **Connect.** On the same LAN the two find each other automatically. Otherwise open **Devices**, type the other machine's IP (optionally `IP:port`), and click **Add**. Only devices that report their screen info join the layout. 4. **Arrange screens.** Open **Layout** and drag the monitors so their touching edges match how they sit on your desk. @@ -60,7 +60,7 @@ Move your cursor off the edge of one screen and it lands on the next machine. Yo ## Current Status -MyKVM is an experimental early release. It is useful for local testing and iteration, but it is not hardened for untrusted networks. See the [Releases page](https://github.com/XxMinor/mykvm/releases) for the current version and installers. +MyKVM is an experimental early release. It is useful for local testing and iteration, but it is not hardened for untrusted networks. See the [Releases page](https://github.com/aceleisureman/mykvm/releases) for the current version and installers. - License: MIT - Default ports: UDP `47833` (discovery) and UDP `47834` (QUIC transport) diff --git a/README.zh-CN.md b/README.zh-CN.md index 02d343c..504679d 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -4,10 +4,10 @@ 把光标移出一台屏幕的边缘,它就落到下一台机器上;键盘随之切换,剪贴板(文本和图片)自动同步。不需要 KVM 硬件,也不用插线。 -[![下载](https://img.shields.io/github/v/release/XxMinor/mykvm?label=%E4%B8%8B%E8%BD%BD&style=for-the-badge)](https://github.com/XxMinor/mykvm/releases/latest) -[![Stars](https://img.shields.io/github/stars/XxMinor/mykvm?label=Stars&logo=github&style=for-the-badge)](https://github.com/XxMinor/mykvm/stargazers) -[![Forks](https://img.shields.io/github/forks/XxMinor/mykvm?label=Forks&logo=github&style=for-the-badge)](https://github.com/XxMinor/mykvm/forks) -[![平台](https://img.shields.io/badge/平台-macOS%20%7C%20Windows%20%7C%20Linux-2786ff?style=for-the-badge)](https://github.com/XxMinor/mykvm/releases/latest) +[![下载](https://img.shields.io/github/v/release/aceleisureman/mykvm?label=%E4%B8%8B%E8%BD%BD&style=for-the-badge)](https://github.com/aceleisureman/mykvm/releases/latest) +[![Stars](https://img.shields.io/github/stars/aceleisureman/mykvm?label=Stars&logo=github&style=for-the-badge)](https://github.com/aceleisureman/mykvm/stargazers) +[![Forks](https://img.shields.io/github/forks/aceleisureman/mykvm?label=Forks&logo=github&style=for-the-badge)](https://github.com/aceleisureman/mykvm/forks) +[![平台](https://img.shields.io/badge/平台-macOS%20%7C%20Windows%20%7C%20Linux-2786ff?style=for-the-badge)](https://github.com/aceleisureman/mykvm/releases/latest) [![许可证: MIT](https://img.shields.io/badge/license-MIT-green?style=for-the-badge)](./LICENSE) [English README](./README.md) @@ -22,7 +22,7 @@ ## 快速开始 -1. **两台机器都安装。** 从 [最新发布](https://github.com/XxMinor/mykvm/releases/latest) 下载各自系统的安装包。 +1. **两台机器都安装。** 从 [最新发布](https://github.com/aceleisureman/mykvm/releases/latest) 下载各自系统的安装包。 2. **选择角色。** 在要共享键鼠的那台机器上打开 MyKVM,保持 **服务端**(默认)。在另一台机器上打开 MyKVM,到设置里切换为 **客户端**。 3. **建立连接。** 同一局域网下两台会自动发现。否则打开 **设备**,输入对方 IP(可加 `IP:端口`),点 **添加**。只有上报了屏幕信息的设备才会加入布局。 4. **排列屏幕。** 打开 **布局**,拖动各显示器,让相邻边缘和它们在桌面上的实际位置一致。 @@ -60,7 +60,7 @@ ## 当前状态 -MyKVM 是一个实验性的早期版本,适合在本地可信网络中测试和迭代,但还没有面向不可信网络做生产级加固。当前版本和安装包见 [Releases 页面](https://github.com/XxMinor/mykvm/releases)。 +MyKVM 是一个实验性的早期版本,适合在本地可信网络中测试和迭代,但还没有面向不可信网络做生产级加固。当前版本和安装包见 [Releases 页面](https://github.com/aceleisureman/mykvm/releases)。 - 许可证:MIT - 默认端口:UDP `47833`(发现)和 UDP `47834`(QUIC 传输) diff --git a/docs/superpowers/plans/2026-07-13-balanced-input-sync-reliability.md b/docs/superpowers/plans/2026-07-13-balanced-input-sync-reliability.md new file mode 100644 index 0000000..67b9d39 --- /dev/null +++ b/docs/superpowers/plans/2026-07-13-balanced-input-sync-reliability.md @@ -0,0 +1,57 @@ +# Balanced Input and Sync Reliability Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Prevent bulk sync work and one failed peer from degrading real-time input while making file retries safe. + +**Architecture:** Keep the existing QUIC wire packets. Add peer-scoped datagram health, bound queued reliable streams, dispatch reliable sends without blocking the transport command loop, and accept already-written duplicate file chunks. + +**Tech Stack:** Rust, Tokio, Quinn, Tauri + +--- + +### Task 1: Isolate datagram health by peer + +**Files:** +- Modify: `src-tauri/src/quic_transport.rs` + +- [x] Add tests proving a failure recorded for peer A does not fast-fail peer B and success clears only the matching peer. +- [x] Run the focused QUIC tests and verify they fail before implementation. +- [x] Replace the global failure counter with a bounded peer-keyed health tracker. +- [x] Ensure enqueue failure rolls back pending counters. +- [x] Run focused QUIC tests and verify they pass. + +### Task 2: Keep reliable streams from blocking input scheduling + +**Files:** +- Modify: `src-tauri/src/quic_transport.rs` + +- [x] Add tests for the reliable-stream pending budget. +- [x] Run the focused test and verify it fails before implementation. +- [x] Add a bounded stream pending counter. +- [x] Move stream send/retry work into spawned Tokio tasks using a shared connection registry. +- [x] Keep connection lookup locks outside stream ACK waits. +- [x] Run all QUIC transport tests. + +### Task 3: Make file chunk retries idempotent + +**Files:** +- Modify: `src-tauri/src/lib.rs` + +- [x] Add a test that submits the same valid chunk twice and verifies the file contains one copy. +- [x] Run the focused test and verify it fails before implementation. +- [x] Accept a duplicate of the immediately previous fully-written chunk without writing it again. +- [x] Reject duplicates with mismatched offset or payload length. +- [x] Run the file-transfer tests and full Rust test suite. + +### Task 4: Final verification + +**Files:** +- Verify: Rust backend and frontend + +- [x] Run `cargo fmt --manifest-path src-tauri/Cargo.toml -- --check`. +- [x] Run `cargo test --manifest-path src-tauri/Cargo.toml`. +- [x] Run `npm run lint`. +- [x] Run `npm run build`. +- [x] Run `git diff --check` and review the final diff. +- [x] Commit the implementation without `claude_auto_continue.py`. diff --git a/docs/superpowers/plans/2026-07-13-bate-preview-updater.md b/docs/superpowers/plans/2026-07-13-bate-preview-updater.md new file mode 100644 index 0000000..d6f623f --- /dev/null +++ b/docs/superpowers/plans/2026-07-13-bate-preview-updater.md @@ -0,0 +1,40 @@ +# Dev Preview Updater Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Publish and update `dev` preview builds from `aceleisureman/mykvm` without operating on `main`. + +**Architecture:** Keep the existing Tauri updater and GitHub Actions release pipeline. Trigger automatic releases only from `dev`, using the beta `beta/latest.json` channel in the user's repository. + +**Tech Stack:** GitHub Actions, Tauri Updater v2, TypeScript, JSON + +--- + +### Task 1: Point application links and updater endpoints to the owned repository + +**Files:** +- Modify: `src/constants.ts` +- Modify: `src-tauri/tauri.conf.json` +- Modify: `src-tauri/src/lib.rs` + +- [x] Replace `https://github.com/XxMinor/mykvm` with `https://github.com/aceleisureman/mykvm`. +- [x] Verify no runtime repository URL still references `XxMinor/mykvm` with `rg -n "XxMinor/mykvm" src src-tauri`. + +### Task 2: Point beta build artifacts to the owned beta release channel + +**Files:** +- Modify: `.github/workflows/release.yml` + +- [x] Change the beta updater endpoint to `https://github.com/aceleisureman/mykvm/releases/download/beta/latest.json`. +- [x] Confirm the workflow triggers only for `dev` pushes. +- [x] Validate the YAML parses successfully. + +### Task 3: Verify and commit + +**Files:** +- Test: application and workflow configuration + +- [x] Run `npm run build` and expect a successful Vite production build. +- [x] Run `cargo test --manifest-path src-tauri/Cargo.toml` and expect all tests to pass. +- [x] Run `git diff --check` and verify no whitespace errors. +- [ ] Commit the updater and release configuration changes. diff --git a/docs/superpowers/specs/2026-07-13-balanced-input-sync-reliability-design.md b/docs/superpowers/specs/2026-07-13-balanced-input-sync-reliability-design.md new file mode 100644 index 0000000..da69c60 --- /dev/null +++ b/docs/superpowers/specs/2026-07-13-balanced-input-sync-reliability-design.md @@ -0,0 +1,32 @@ +# Balanced Input and Sync Reliability Design + +## Goal + +Keep input latency low while preserving reliable clipboard and file transfer behavior under disconnects, congestion, and multiple peers. + +## Architecture + +The QUIC transport will schedule real-time datagrams independently from reliable streams. Datagram health and cooldown state will be tracked per peer so one unreachable device cannot poison another. Mouse movement may be coalesced or dropped under pressure, while keyboard, button, clipboard, and file data retain explicit success/failure semantics. + +Reliable streams will use bounded priority classes. Clipboard/control traffic takes priority over bulk file chunks. File transfer receive handling will acknowledge safe duplicate chunks so an ACK loss does not abort an otherwise valid transfer. + +## Behavior + +- Input datagrams remain non-blocking and have a bounded pending budget. +- Each peer has independent consecutive-failure and cooldown state. +- Successful traffic clears only that peer's failure state. +- Stream sends run concurrently with datagram scheduling instead of blocking the transport command loop. +- Reliable stream concurrency is bounded to control memory and connection pressure. +- Duplicate file chunks already written at the expected previous index are accepted without writing twice. +- Queue pressure and peer failures remain visible through logs and focused unit tests. + +## Compatibility + +No wire protocol version change is required. Existing peers continue to decode the same packets. Duplicate-chunk acceptance only relaxes receiver behavior and remains compatible with older senders. + +## Validation + +- Unit tests cover peer-isolated health, bounded stream scheduling, and duplicate file chunks. +- Existing Rust tests must remain green. +- Frontend lint and production build must pass. +- No changes include the untracked `claude_auto_continue.py` file. diff --git a/docs/superpowers/specs/2026-07-13-bate-preview-updater-design.md b/docs/superpowers/specs/2026-07-13-bate-preview-updater-design.md new file mode 100644 index 0000000..9e49f16 --- /dev/null +++ b/docs/superpowers/specs/2026-07-13-bate-preview-updater-design.md @@ -0,0 +1,21 @@ +# Bate Preview Updater Design + +## Goal + +Publish the current feature work through the `dev` preview branch and make preview builds update only from `aceleisureman/mykvm`'s beta release channel. + +## Design + +- Do not trigger releases from `main`; it remains the untouched upstream-sync branch. +- Treat pushes to `dev` as beta releases with versions such as `0.9.9-beta.`. +- Build signed macOS, Windows, and Linux updater artifacts with the existing release workflow. +- Publish the preview updater manifest at `https://github.com/aceleisureman/mykvm/releases/download/beta/latest.json`. +- Configure beta build artifacts to check that manifest. Stable builds continue using this repository's latest stable release manifest. +- Move the current branch's completed changes onto `dev` without including the untracked `claude_auto_continue.py` file. + +## Safety and Validation + +- Preserve Tauri updater signature verification and the existing signing public key. +- Validate workflow YAML and repository URLs. +- Run Rust tests and the frontend production build before handing off. +- Do not push unless explicitly authorized; local branch preparation and commits are allowed. diff --git a/package.json b/package.json index f6f6d8e..bdd426e 100644 --- a/package.json +++ b/package.json @@ -1,10 +1,10 @@ { "name": "mykvm", - "version": "0.1.0", + "version": "0.9.8", "license": "MIT", "repository": { "type": "git", - "url": "https://github.com/XxMinor/mykvm.git" + "url": "https://github.com/aceleisureman/mykvm.git" }, "type": "module", "scripts": { diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 1194651..03d6c2b 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -2330,12 +2330,13 @@ dependencies = [ [[package]] name = "mykvm" -version = "0.1.0" +version = "0.9.8" dependencies = [ "arboard", "base64 0.22.1", "core-foundation", "core-graphics", + "flate2", "if-addrs", "image", "log", @@ -2360,7 +2361,7 @@ dependencies = [ [[package]] name = "mykvm-input-helper" -version = "0.1.0" +version = "0.9.8" dependencies = [ "mykvm", "windows-service", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 72eb6e8..06f44fa 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,10 +1,10 @@ [package] name = "mykvm" -version = "0.1.0" +version = "0.9.8" description = "A cross-platform software KVM prototype" authors = ["XxMinor"] license = "MIT" -repository = "https://github.com/XxMinor/mykvm" +repository = "https://github.com/aceleisureman/mykvm" edition = "2021" rust-version = "1.77.2" default-run = "mykvm" @@ -46,6 +46,7 @@ socket2 = "0.6" if-addrs = "0.13" tokio = { version = "1.52", features = ["rt-multi-thread", "io-util", "sync", "time"] } tauri-plugin-global-shortcut = "2" +flate2 = "1" [target.'cfg(target_os = "macos")'.dependencies] core-foundation = "0.10" diff --git a/src-tauri/input-helper/Cargo.toml b/src-tauri/input-helper/Cargo.toml index 7014690..b286878 100644 --- a/src-tauri/input-helper/Cargo.toml +++ b/src-tauri/input-helper/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mykvm-input-helper" -version = "0.1.0" +version = "0.9.8" edition = "2021" rust-version = "1.77.2" publish = false diff --git a/src-tauri/src/clipboard.rs b/src-tauri/src/clipboard.rs index 6aa2663..901bd94 100644 --- a/src-tauri/src/clipboard.rs +++ b/src-tauri/src/clipboard.rs @@ -1,4 +1,8 @@ +use flate2::read::DeflateDecoder; +use flate2::write::DeflateEncoder; +use flate2::Compression; use serde::{Deserialize, Serialize}; +use std::io::{Read, Write}; const CLIPBOARD_MAX_TEXT_BYTES: usize = 256 * 1024; // Raw RGBA can be large (a 2560x1440 frame is ~14 MB); cap it so a stray huge @@ -11,6 +15,10 @@ pub(crate) struct ClipboardImage { pub(crate) width: u32, pub(crate) height: u32, pub(crate) rgba_base64: String, + /// When true, `rgba_base64` contains DEFLATE-compressed RGBA bytes. + /// Older peers that lack this field default to false (uncompressed). + #[serde(default)] + pub(crate) compressed: bool, } /// One unit of clipboard content read from (or written to) the local system. @@ -19,6 +27,18 @@ pub(crate) enum ClipboardContent { Image(ClipboardImage), } +#[cfg(target_os = "windows")] +pub(crate) fn change_token() -> Option { + let sequence = + unsafe { windows_sys::Win32::System::DataExchange::GetClipboardSequenceNumber() }; + (sequence != 0).then_some(sequence as u64) +} + +#[cfg(not(target_os = "windows"))] +pub(crate) fn change_token() -> Option { + None +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum ClipboardContentHint { Image, @@ -143,6 +163,7 @@ mod tests { width: 1, height: 1, rgba_base64: "AAAAAA==".into(), + compressed: false, })) }, ); @@ -188,6 +209,12 @@ mod tests { } } +fn compress_rgba(raw: &[u8]) -> Option> { + let mut encoder = DeflateEncoder::new(Vec::new(), Compression::fast()); + encoder.write_all(raw).ok()?; + encoder.finish().ok() +} + fn read_image() -> Option { use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _}; @@ -200,10 +227,20 @@ fn read_image() -> Option { return None; } + // Compress RGBA data before base64 encoding to reduce transfer size. + // Typical screenshots compress 5-10x with DEFLATE. + let (data, compressed) = match compress_rgba(image.bytes.as_ref()) { + Some(compressed_bytes) if compressed_bytes.len() < image.bytes.len() => { + (compressed_bytes, true) + } + _ => (image.bytes.to_vec(), false), + }; + Some(ClipboardImage { width: image.width as u32, height: image.height as u32, - rgba_base64: BASE64.encode(image.bytes.as_ref()), + rgba_base64: BASE64.encode(&data), + compressed, }) }); @@ -223,9 +260,21 @@ fn read_image() -> Option { fn write_image(image: &ClipboardImage) -> Result<(), String> { use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _}; - let bytes = BASE64 + let decoded = BASE64 .decode(image.rgba_base64.as_bytes()) .map_err(|error| format!("failed to decode clipboard image: {error}"))?; + + let bytes = if image.compressed { + let mut decoder = DeflateDecoder::new(decoded.as_slice()); + let mut decompressed = Vec::new(); + decoder + .read_to_end(&mut decompressed) + .map_err(|error| format!("failed to decompress clipboard image: {error}"))?; + decompressed + } else { + decoded + }; + let width = image.width as usize; let height = image.height as usize; if width == 0 || height == 0 || bytes.len() != width.saturating_mul(height).saturating_mul(4) { @@ -336,10 +385,16 @@ fn decode_windows_dib_image(data: &[u8]) -> Option { return None; } + let (data, compressed) = match compress_rgba(&bytes) { + Some(compressed_bytes) if compressed_bytes.len() < bytes.len() => (compressed_bytes, true), + _ => (bytes, false), + }; + Some(ClipboardImage { width, height, - rgba_base64: BASE64.encode(bytes), + rgba_base64: BASE64.encode(&data), + compressed, }) } diff --git a/src-tauri/src/input.rs b/src-tauri/src/input.rs index 8cba7e7..25e9b18 100644 --- a/src-tauri/src/input.rs +++ b/src-tauri/src/input.rs @@ -43,7 +43,7 @@ const RETURN_EDGE_INSET: f64 = 0.0; // After returning to local, refuse to cross back into the remote for this long. // Lets a fast back-flick settle at the edge without bouncing into the remote. const RETURN_COOLDOWN_MS: u64 = 150; -const MOUSE_MOVE_SEND_INTERVAL_MS: u64 = 8; +const MOUSE_MOVE_SEND_INTERVAL_MS: u64 = 16; const DRAG_MOVE_SEND_INTERVAL_MS: u64 = 8; #[cfg(target_os = "macos")] const MACOS_IDLE_CAPTURE_LOOP_MS: u64 = 100; @@ -100,10 +100,14 @@ const MACOS_RAW_GESTURE_EVENT_TYPES: &[u32] = &[ const WINDOWS_DESKTOP_CHECK_INTERVAL_MS: u64 = 250; static REMOTE_MOUSE_STATE: OnceLock> = OnceLock::new(); +static INPUT_DEBUG_EVENTS: OnceLock>> = OnceLock::new(); +static LAST_SUCCESSFUL_MOUSE_DEBUG_MS: AtomicU64 = AtomicU64::new(0); #[cfg(target_os = "macos")] static MACOS_ACCESSIBILITY_PROMPTED: AtomicBool = AtomicBool::new(false); #[cfg(target_os = "windows")] static WINDOWS_INPUT_DESKTOP_DEFAULT_CACHE: AtomicBool = AtomicBool::new(true); +const INPUT_DEBUG_MAX_EVENTS: usize = 32; +const INPUT_DEBUG_MOUSE_SAMPLE_MS: u64 = 250; #[derive(Debug, Clone, Copy, PartialEq)] enum Edge { @@ -209,6 +213,35 @@ struct RemoteMouseState { buttons: u64, } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct InputDebugEvent { + pub timestamp_ms: u64, + pub controller_id: String, + pub event_type: String, + pub screen_id: String, + pub relative_x: Option, + pub relative_y: Option, + pub absolute_x: Option, + pub absolute_y: Option, + pub desktop: String, + pub route: String, + pub pipe_available: Option, + pub result: String, + pub detail: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct InputDebugInfo { + pub enabled: bool, + pub status: String, + pub latest_failure: Option, + pub last_route: Option, + pub recent_event_count: usize, + pub events: Vec, +} + pub fn stopped_capture_status() -> NativeStageStatus { NativeStageStatus { state: "stubbed".into(), @@ -223,6 +256,146 @@ pub fn stopped_inject_status() -> NativeStageStatus { } } +fn input_debug_events() -> &'static Mutex> { + INPUT_DEBUG_EVENTS.get_or_init(|| Mutex::new(Vec::new())) +} + +fn now_input_debug_ms() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|duration| duration.as_millis() as u64) + .unwrap_or(0) +} + +pub fn record_input_debug_event(event: InputDebugEvent) { + if should_record_input_debug_event(&event.event_type, &event.result, event.timestamp_ms) { + push_input_debug_event(event); + } +} + +fn record_input_debug_event_lazy(event_type: &str, result: &str, timestamp_ms: u64, build: F) +where + F: FnOnce() -> InputDebugEvent, +{ + if should_record_input_debug_event(event_type, result, timestamp_ms) { + push_input_debug_event(build()); + } +} + +fn should_record_input_debug_event(event_type: &str, result: &str, timestamp_ms: u64) -> bool { + event_type != "mouseMove" || result != "injected" || sample_mouse_debug(timestamp_ms) +} + +fn sample_mouse_debug(timestamp_ms: u64) -> bool { + LAST_SUCCESSFUL_MOUSE_DEBUG_MS + .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |previous| { + mouse_debug_sample_due(previous, timestamp_ms).then_some(timestamp_ms) + }) + .is_ok() +} + +fn mouse_debug_sample_due(previous_ms: u64, timestamp_ms: u64) -> bool { + previous_ms == 0 || timestamp_ms.saturating_sub(previous_ms) >= INPUT_DEBUG_MOUSE_SAMPLE_MS +} + +fn push_input_debug_event(event: InputDebugEvent) { + let Ok(mut events) = input_debug_events().lock() else { + return; + }; + events.push(event); + if events.len() > INPUT_DEBUG_MAX_EVENTS { + let overflow = events.len() - INPUT_DEBUG_MAX_EVENTS; + events.drain(0..overflow); + } +} + +pub fn current_input_debug_info() -> InputDebugInfo { + let events = input_debug_events() + .lock() + .map(|events| events.clone()) + .unwrap_or_default(); + let latest_failure = events + .iter() + .rev() + .find(|event| event.result != "injected") + .map(|event| event.detail.clone()); + let last_route = events.last().map(|event| event.route.clone()); + let recent_event_count = events.len(); + let status = if events.is_empty() { + "idle".to_string() + } else { + "collecting".to_string() + }; + + InputDebugInfo { + enabled: true, + status, + latest_failure, + last_route, + recent_event_count, + events, + } +} + +pub fn input_debug_report_lines(summary: &InputDebugInfo) -> Vec { + let mut lines = vec![format!("input debug: {}", summary.status)]; + lines.push(format!( + "input debug recent events: {}", + summary.recent_event_count + )); + if let Some(route) = &summary.last_route { + lines.push(format!("input debug last route: {route}")); + } + if let Some(failure) = &summary.latest_failure { + lines.push(format!("input debug latest failure: {failure}")); + } + + if summary.events.is_empty() { + lines.push("input debug events: none".into()); + } else { + lines.push("input debug events:".into()); + for event in &summary.events { + let relative = match (event.relative_x, event.relative_y) { + (Some(x), Some(y)) => format!("rel=({x},{y})"), + _ => "rel=(n/a)".into(), + }; + let absolute = match (event.absolute_x, event.absolute_y) { + (Some(x), Some(y)) => format!("abs=({x},{y})"), + _ => "abs=(n/a)".into(), + }; + let pipe = match event.pipe_available { + Some(true) => "pipe=true", + Some(false) => "pipe=false", + None => "pipe=n/a", + }; + lines.push(format!( + "- ts={} controller={} event={} screen={} {} {} desktop={} route={} {} result={} detail={}", + event.timestamp_ms, + event.controller_id, + event.event_type, + event.screen_id, + relative, + absolute, + event.desktop, + event.route, + pipe, + event.result, + event.detail + )); + } + } + + lines +} + +#[cfg(test)] +fn clear_input_debug_events_for_tests() { + LAST_SUCCESSFUL_MOUSE_DEBUG_MS.store(0, Ordering::Relaxed); + if let Ok(mut events) = input_debug_events().lock() { + events.clear(); + } +} + /// Direction requested by a screen-switch hotkey. Maps onto the `Edge` that a /// mouse crossing would follow: `Right` means "the remote sits to the right of /// the local screen", matching `Edge::Right` on the `InputTarget`. @@ -1275,6 +1448,7 @@ fn send_packet( ) -> bool { let packet_context = input_packet_context(target, event, layout_state); let event = packet_context.event; + let lossy = matches!(&event, InputEvent::MouseMove { .. }); let packet = InputPacket { protocol: INPUT_PROTOCOL.into(), target_device_id: target.device_id.clone(), @@ -1302,7 +1476,12 @@ fn send_packet( } }; - match quic_transport.send_datagram(peer, payload) { + let send_result = if lossy { + quic_transport.send_datagram_lossy(peer, payload) + } else { + quic_transport.send_datagram(peer, payload) + }; + match send_result { Ok(()) => { input_events.fetch_add(1, Ordering::Relaxed); true @@ -1592,7 +1771,12 @@ pub fn try_inject_packet_from_source( ); } - let injected = inject_input_event(layout, native_layout, packet.event); + let injected = inject_input_event( + layout, + native_layout, + packet.event, + &packet.origin_device_id, + ); if injected { input_events.fetch_add(1, Ordering::Relaxed); } @@ -1707,6 +1891,31 @@ fn origin_peer_id(layout: &LayoutState) -> String { static LAST_UNAUTHORIZED_WARN: OnceLock> = OnceLock::new(); +fn record_unauthorized_debug_event( + controller_id: &str, + event_type: &str, + screen_id: &str, + relative_x: Option, + relative_y: Option, + detail: &str, +) { + record_input_debug_event(InputDebugEvent { + timestamp_ms: now_input_debug_ms(), + controller_id: controller_id.to_string(), + event_type: event_type.to_string(), + screen_id: screen_id.to_string(), + relative_x, + relative_y, + absolute_x: None, + absolute_y: None, + desktop: "unauthorized".into(), + route: "rejected".into(), + pipe_available: None, + result: "dropped".into(), + detail: detail.to_string(), + }); +} + /// Log (at most once every few seconds, since a single mouse move floods many /// packets) why a controller's input was rejected. Without this the packets /// were dropped silently while the device still showed "online", which makes a @@ -1730,6 +1939,49 @@ fn warn_unauthorized_packet(layout: &LayoutState, packet: &InputPacket) { *last = Instant::now(); } + match &packet.event { + InputEvent::MouseMove { screen_id, x, y } => { + record_unauthorized_debug_event( + &packet.origin_device_id, + "mouseMove", + screen_id, + Some(*x), + Some(*y), + reason, + ); + } + InputEvent::MouseButton { .. } => { + record_unauthorized_debug_event( + &packet.origin_device_id, + "mouseButton", + "", + None, + None, + reason, + ); + } + InputEvent::Scroll { .. } => { + record_unauthorized_debug_event( + &packet.origin_device_id, + "scroll", + "", + None, + None, + reason, + ); + } + InputEvent::Key { .. } => { + record_unauthorized_debug_event( + &packet.origin_device_id, + "key", + "", + None, + None, + reason, + ); + } + } + log::warn!( "rejected input from controller id={} key={}: {}", if packet.origin_device_id.trim().is_empty() { @@ -1755,6 +2007,8 @@ fn warn_unauthorized_control_packet(layout: &LayoutState, packet: &InputControlP "controller is not in this device's paired-controllers list" }; + record_unauthorized_debug_event(&packet.origin_device_id, "control", "", None, None, reason); + log::warn!( "rejected input control from controller id={} key={}: {}", if packet.origin_device_id.trim().is_empty() { @@ -1902,13 +2156,33 @@ fn inject_input_event( layout: &LayoutState, native_layout: &LayoutState, event: InputEvent, + controller_id: &str, ) -> bool { - let Some(command) = input_event_to_command(layout, native_layout, event) else { + let debug_event = input_debug_event_from_input_event(&event); + let Some(command) = input_event_to_command(layout, native_layout, &event) else { + record_input_debug_event(InputDebugEvent { + timestamp_ms: now_input_debug_ms(), + controller_id: controller_id.to_string(), + event_type: debug_event.event_type.into(), + screen_id: debug_event.screen_id.to_string(), + relative_x: debug_event.relative_x, + relative_y: debug_event.relative_y, + absolute_x: None, + absolute_y: None, + desktop: "unknown".into(), + route: "dropped".into(), + pipe_available: None, + result: "dropped".into(), + detail: "could not map input event to native command".into(), + }); return false; }; #[cfg(target_os = "windows")] { + let desktop_is_default = windows_inject_desktop_is_default(); + let route_to_helper = should_route_to_windows_helper(&command); + let (absolute_x, absolute_y) = command_coordinates(&command); // Inject locally on the normal desktop; hand off to the privileged SYSTEM // helper only for the secure desktop (lock screen / UAC) or Ctrl+Alt+Del. // @@ -1923,19 +2197,116 @@ fn inject_input_event( // logged-in user at the foreground window's own integrity, so it clicks // and types normally. On the secure desktop the foreground is LogonUI // (System integrity), so the worker's equal-integrity injection works. - if should_route_to_windows_helper(&command) { + if route_to_helper { match windows_pipe_dispatcher().send(&command) { - Ok(()) => return true, - Err(error) => note_windows_helper_unavailable(&error), + Ok(()) => { + let timestamp_ms = now_input_debug_ms(); + record_input_debug_event_lazy( + debug_event.event_type, + "injected", + timestamp_ms, + || InputDebugEvent { + timestamp_ms, + controller_id: controller_id.to_string(), + event_type: debug_event.event_type.into(), + screen_id: debug_event.screen_id.to_string(), + relative_x: debug_event.relative_x, + relative_y: debug_event.relative_y, + absolute_x, + absolute_y, + desktop: if desktop_is_default { + "default".into() + } else { + "secure".into() + }, + route: "helper".into(), + pipe_available: Some(true), + result: "injected".into(), + detail: "dispatched through input helper".into(), + }, + ); + return true; + } + Err(error) => { + note_windows_helper_unavailable(&error); + record_input_debug_event(InputDebugEvent { + timestamp_ms: now_input_debug_ms(), + controller_id: controller_id.to_string(), + event_type: debug_event.event_type.into(), + screen_id: debug_event.screen_id.to_string(), + relative_x: debug_event.relative_x, + relative_y: debug_event.relative_y, + absolute_x, + absolute_y, + desktop: if desktop_is_default { + "default".into() + } else { + "secure".into() + }, + route: "helper-fallback".into(), + pipe_available: Some(false), + result: "fallback".into(), + detail: error, + }); + } } } inject_input_command(command); + let timestamp_ms = now_input_debug_ms(); + record_input_debug_event_lazy(debug_event.event_type, "injected", timestamp_ms, || { + InputDebugEvent { + timestamp_ms, + controller_id: controller_id.to_string(), + event_type: debug_event.event_type.into(), + screen_id: debug_event.screen_id.to_string(), + relative_x: debug_event.relative_x, + relative_y: debug_event.relative_y, + absolute_x, + absolute_y, + desktop: if desktop_is_default { + "default".into() + } else { + "secure".into() + }, + route: if route_to_helper { + "helper-fallback".into() + } else { + "local".into() + }, + pipe_available: if route_to_helper { Some(false) } else { None }, + result: "injected".into(), + detail: if route_to_helper { + "fell back to local injection".into() + } else { + "injected locally".into() + }, + } + }); return true; } #[cfg(not(target_os = "windows"))] { + let (absolute_x, absolute_y) = command_coordinates(&command); inject_input_command(command); + let timestamp_ms = now_input_debug_ms(); + record_input_debug_event_lazy(debug_event.event_type, "injected", timestamp_ms, || { + InputDebugEvent { + timestamp_ms, + controller_id: controller_id.to_string(), + event_type: debug_event.event_type.into(), + screen_id: debug_event.screen_id.to_string(), + relative_x: debug_event.relative_x, + relative_y: debug_event.relative_y, + absolute_x, + absolute_y, + desktop: "n/a".into(), + route: "local".into(), + pipe_available: None, + result: "injected".into(), + detail: "injected locally".into(), + } + }); true } } @@ -1988,22 +2359,22 @@ fn windows_inject_desktop_is_default() -> bool { fn input_event_to_command( layout: &LayoutState, native_layout: &LayoutState, - event: InputEvent, + event: &InputEvent, ) -> Option { match event { InputEvent::MouseMove { screen_id, x, y } => { - if let Some(screen) = local_screen_for_event(layout, &screen_id) { - let native_screen = local_screen_for_event(native_layout, &screen_id) + if let Some(screen) = local_screen_for_event(layout, screen_id) { + let native_screen = local_screen_for_event(native_layout, screen_id) .map(platform_native_screen) .unwrap_or_else(|| platform_native_screen(screen)); let absolute_x = map_relative_to_native_axis( - x, + *x, screen.width, native_screen.x, native_screen.width, ); let absolute_y = map_relative_to_native_axis( - y, + *y, screen.height, native_screen.y, native_screen.height, @@ -2018,11 +2389,66 @@ fn input_event_to_command( None } InputEvent::MouseButton { button, down } => { - let (x, y) = update_remote_mouse_button(button, down); - Some(InputCommand::MouseButton { button, down, x, y }) + let (x, y) = update_remote_mouse_button(*button, *down); + Some(InputCommand::MouseButton { + button: *button, + down: *down, + x, + y, + }) } - InputEvent::Scroll { delta_x, delta_y } => Some(InputCommand::Scroll { delta_x, delta_y }), - InputEvent::Key { key_code, down } => Some(InputCommand::Key { key_code, down }), + InputEvent::Scroll { delta_x, delta_y } => Some(InputCommand::Scroll { + delta_x: *delta_x, + delta_y: *delta_y, + }), + InputEvent::Key { key_code, down } => Some(InputCommand::Key { + key_code: *key_code, + down: *down, + }), + } +} + +struct InputDebugEventSeed<'a> { + event_type: &'static str, + screen_id: &'a str, + relative_x: Option, + relative_y: Option, +} + +fn input_debug_event_from_input_event(event: &InputEvent) -> InputDebugEventSeed<'_> { + match event { + InputEvent::MouseMove { screen_id, x, y } => InputDebugEventSeed { + event_type: "mouseMove", + screen_id, + relative_x: Some(*x), + relative_y: Some(*y), + }, + InputEvent::MouseButton { .. } => InputDebugEventSeed { + event_type: "mouseButton", + screen_id: "", + relative_x: None, + relative_y: None, + }, + InputEvent::Scroll { .. } => InputDebugEventSeed { + event_type: "scroll", + screen_id: "", + relative_x: None, + relative_y: None, + }, + InputEvent::Key { .. } => InputDebugEventSeed { + event_type: "key", + screen_id: "", + relative_x: None, + relative_y: None, + }, + } +} + +fn command_coordinates(command: &InputCommand) -> (Option, Option) { + match command { + InputCommand::MouseMove { x, y, .. } => (Some(*x), Some(*y)), + InputCommand::MouseButton { x, y, .. } => (Some(*x), Some(*y)), + _ => (None, None), } } @@ -2946,6 +3372,9 @@ fn handle_windows_mouse_move(context: &WindowsCaptureContext, x: f64, y: f64) -> let targets = current_input_targets(&context.layout_state, &context.native_layout); if let Some(active_target) = crossing_target(&targets, x, y, dx, dy, &context.layout_state) { + // Give the connection a fresh chance on each crossing attempt so that + // recovery after a transient disconnect is immediate. + context.quic_transport.reset_datagram_failures(); let anchor = local_anchor_point(&active_target); hide_windows_cursor_if_needed(context); set_windows_cursor(anchor.0.round() as i32, anchor.1.round() as i32); @@ -3038,7 +3467,15 @@ fn handle_windows_scroll(context: &WindowsCaptureContext, message: u32, mouse_da let Some(active_target) = active else { return false; }; - let delta = ((mouse_data >> 16) as i16 / 120) as i32; + let raw_delta = (mouse_data >> 16) as i16; + + let delta = if raw_delta.abs() >= 120 { + (raw_delta / 120) as i32 + } else if raw_delta != 0 { + raw_delta.signum() as i32 + } else { + 0 + }; let (delta_x, delta_y) = if message == WM_MOUSEHWHEEL { (delta, 0) } else if message == WM_MOUSEWHEEL { @@ -3439,6 +3876,9 @@ fn handle_macos_mouse_move( if let Some(active_target) = mac_crossing_target(context, &targets, location.x, location.y, dx, dy) { + // Give the connection a fresh chance on each crossing attempt so that + // recovery after a transient disconnect is immediate. + context.quic_transport.reset_datagram_failures(); let anchor = mac_cursor_point( context, local_anchor_point(&active_target), @@ -3688,6 +4128,15 @@ fn mac_crossing_target( return None; } + // Suppress the Y-flip when the cursor is at an outer edge of the local + // screens moving outward. Without this guard the flip maps a + // bottom-edge push into a top-edge crossing (or vice-versa), producing + // a false transition to a remote screen on the opposite side. + let edge_margin = CROSSING_ACTIVATION_BAND; + if (y >= max_y - edge_margin && dy > 0.0) || (y <= min_y + edge_margin && dy < 0.0) { + return None; + } + crossing_target_with_transform(targets, x, flipped_y, dx, -dy, true, &context.layout_state) } @@ -5346,6 +5795,117 @@ fn inject_key(_key_code: u16, _down: bool) {} mod tests { use super::*; + #[test] + fn input_debug_summary_keeps_recent_events_and_latest_failure() { + clear_input_debug_events_for_tests(); + + for index in 0..(INPUT_DEBUG_MAX_EVENTS + 5) { + record_input_debug_event(InputDebugEvent { + timestamp_ms: index as u64, + controller_id: format!("controller-{index}"), + event_type: "key".into(), + screen_id: "screen-1".into(), + relative_x: Some(index as i32), + relative_y: Some(index as i32 + 1), + absolute_x: Some(index as i32 + 100), + absolute_y: Some(index as i32 + 200), + desktop: "default".into(), + route: "local".into(), + pipe_available: Some(false), + result: "injected".into(), + detail: "ok".into(), + }); + } + + record_input_debug_event(InputDebugEvent { + timestamp_ms: 999, + controller_id: "controller-fail".into(), + event_type: "mouseMove".into(), + screen_id: "screen-1".into(), + relative_x: Some(10), + relative_y: Some(20), + absolute_x: Some(110), + absolute_y: Some(220), + desktop: "secure".into(), + route: "helper-fallback".into(), + pipe_available: Some(false), + result: "fallback".into(), + detail: "input helper pipe unavailable".into(), + }); + + let summary = current_input_debug_info(); + + assert!(summary.enabled); + assert_eq!(summary.recent_event_count, INPUT_DEBUG_MAX_EVENTS); + assert_eq!( + summary.latest_failure.as_deref(), + Some("input helper pipe unavailable") + ); + assert_eq!(summary.last_route.as_deref(), Some("helper-fallback")); + assert_eq!(summary.events.len(), INPUT_DEBUG_MAX_EVENTS); + assert_eq!( + summary + .events + .last() + .map(|event| event.controller_id.as_str()), + Some("controller-fail") + ); + assert_eq!( + summary + .events + .first() + .map(|event| event.controller_id.as_str()), + Some("controller-6") + ); + } + + #[test] + fn input_debug_report_renders_route_and_coordinates() { + let summary = InputDebugInfo { + enabled: true, + status: "collecting".into(), + latest_failure: Some("input helper pipe unavailable".into()), + last_route: Some("helper-fallback".into()), + recent_event_count: 1, + events: vec![InputDebugEvent { + timestamp_ms: 123, + controller_id: "controller-a".into(), + event_type: "mouseMove".into(), + screen_id: "screen-1".into(), + relative_x: Some(10), + relative_y: Some(20), + absolute_x: Some(110), + absolute_y: Some(220), + desktop: "secure".into(), + route: "helper-fallback".into(), + pipe_available: Some(false), + result: "fallback".into(), + detail: "input helper pipe unavailable".into(), + }], + }; + + let lines = input_debug_report_lines(&summary); + + assert!(lines + .iter() + .any(|line| line.contains("input debug: collecting"))); + assert!(lines + .iter() + .any(|line| line.contains("latest failure: input helper pipe unavailable"))); + assert!(lines + .iter() + .any(|line| line.contains("route=helper-fallback"))); + assert!(lines.iter().any(|line| line.contains("rel=(10,20)"))); + assert!(lines.iter().any(|line| line.contains("abs=(110,220)"))); + } + + #[test] + fn input_debug_samples_successful_mouse_moves() { + assert!(mouse_debug_sample_due(0, 1_000)); + assert!(!mouse_debug_sample_due(1_000, 1_100)); + assert!(mouse_debug_sample_due(1_000, 1_300)); + } + #[cfg(target_os = "macos")] #[test] fn windows_vk_to_mac_flag_covers_modifiers() { @@ -6099,7 +6659,7 @@ mod tests { let command = input_event_to_command( &layout, &native_layout, - InputEvent::MouseMove { + &InputEvent::MouseMove { screen_id: "local-display-1".into(), x: 960, y: 540, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 4ef4127..a79912a 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -2,7 +2,7 @@ use std::{ collections::{hash_map::DefaultHasher, HashMap, HashSet}, env, fs, hash::{Hash, Hasher}, - io::{Read, Write}, + io::{Read, Seek, SeekFrom, Write}, net::{Ipv4Addr, SocketAddr, UdpSocket}, path::{Path, PathBuf}, process::Command, @@ -43,8 +43,8 @@ const TRANSPORT_PORT_MAX: u16 = 65_535; // ports starting from the configured base, so two peers that landed on different // ports (e.g. 47833 and 47834) still reach each other. const DISCOVERY_PORT_SPAN: u16 = 8; -const REPOSITORY_URL: &str = "https://github.com/XxMinor/mykvm"; -const RELEASES_URL: &str = "https://github.com/XxMinor/mykvm/releases/latest"; +const REPOSITORY_URL: &str = "https://github.com/aceleisureman/mykvm"; +const RELEASES_URL: &str = "https://github.com/aceleisureman/mykvm/releases/latest"; const DISCOVERY_PROTOCOL: &str = "mykvm.discovery.v1"; // UDP discovery is a heartbeat, not the transport itself. Keep peers through // short announce gaps so online clients do not flicker offline in the UI. @@ -58,12 +58,13 @@ const CLIPBOARD_PROTOCOL: &str = "mykvm.clipboard.v1"; // pasteboard is not always byte-identical to what we wrote (macOS re-encodes // it), so a pure content-signature check can ping-pong; this window guarantees // we never echo received content straight back. -const CLIPBOARD_ECHO_GRACE_MS: u64 = 1200; +const CLIPBOARD_ECHO_GRACE_MS: u64 = 3000; const CLIPBOARD_POLL_INTERVAL_MS: u64 = 150; const CLIPBOARD_IDLE_SLEEP_MS: u64 = 25; const CLIPBOARD_RETRY_INTERVAL_MS: u64 = 2000; -const CLIPBOARD_WRITE_ATTEMPTS: usize = 5; -const CLIPBOARD_WRITE_RETRY_DELAY_MS: u64 = 30; +const CLIPBOARD_BACKOFF_MAX_MS: u64 = 30_000; +const CLIPBOARD_WRITE_ATTEMPTS: usize = 10; +const CLIPBOARD_WRITE_RETRY_DELAY_BASE_MS: u64 = 50; const FILE_TRANSFER_PROTOCOL: &str = "mykvm.file-transfer.v1"; const FILE_TRANSFER_CHUNK_BYTES: usize = 256 * 1024; const FILE_TRANSFER_MAX_FILE_BYTES: u64 = 2 * 1024 * 1024 * 1024; @@ -93,6 +94,10 @@ static HOSTNAME_CACHE: OnceLock> = OnceLock::new(); #[cfg(target_os = "windows")] static WINDOWS_FIREWALL_ENSURED: AtomicBool = AtomicBool::new(false); +/// Signals that a clipboard write (from remote peer) is in progress. +/// The local clipboard polling thread checks this to avoid racing on +/// OpenClipboard (Windows OS error 1418). +static CLIPBOARD_WRITE_ACTIVE: AtomicBool = AtomicBool::new(false); #[cfg(target_os = "windows")] static SINGLE_INSTANCE_MUTEX: OnceLock>> = OnceLock::new(); @@ -390,6 +395,7 @@ struct DiagnosticInfo { config_dir: String, network_hint: String, firewall_hint: String, + input_debug: input::InputDebugInfo, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -465,8 +471,11 @@ struct IncomingFileTransfer { next_chunk_index: u64, temp_path: PathBuf, final_path: PathBuf, + started_at: Instant, } +const FILE_TRANSFER_STALE_TIMEOUT: Duration = Duration::from_secs(5 * 60); + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] struct FileTransferSummary { @@ -533,6 +542,7 @@ struct AppRuntime { pairing_challenge: Arc>>, file_transfers: Arc>>, edge_drop_targets: Arc>>, + edge_drop_stop: Arc, quic_transport: Mutex>, discovery_stop: Mutex>>, input_stop: Mutex>>, @@ -572,6 +582,7 @@ impl AppRuntime { pairing_challenge: Arc::new(Mutex::new(None)), file_transfers: Arc::new(Mutex::new(HashMap::new())), edge_drop_targets: Arc::new(Mutex::new(HashMap::new())), + edge_drop_stop: Arc::new(AtomicBool::new(false)), quic_transport: Mutex::new(None), discovery_stop: Mutex::new(None), input_stop: Mutex::new(None), @@ -633,7 +644,11 @@ impl AppRuntime { } fn runtime_status_for_layout(&self, layout: &LayoutState) -> RuntimeStatus { - let mut runtime = self.runtime.lock().unwrap().clone(); + let mut runtime = self + .runtime + .lock() + .unwrap_or_else(|e| e.into_inner()) + .clone(); runtime.discovery = self.discovery_status_for_layout(layout); runtime.clipboard = self.clipboard_status(layout); runtime.pairing = self.pairing_status_for_layout(layout); @@ -655,7 +670,12 @@ impl AppRuntime { local_peer.input_ready = advertised_input_ready(layout, self.input_receive_enabled.load(Ordering::Relaxed)); let peers = active_peers(&self.peers, &local_peer.id); - let state = if self.discovery_stop.lock().unwrap().is_some() { + let state = if self + .discovery_stop + .lock() + .unwrap_or_else(|e| e.into_inner()) + .is_some() + { "ready" } else { "idle" @@ -843,7 +863,7 @@ impl AppRuntime { let mut stored = self .quic_transport .lock() - .map_err(|_| "QUIC transport lock poisoned".to_string())?; + .unwrap_or_else(|e| e.into_inner()); *stored = Some(transport.clone()); Ok(transport) } @@ -852,7 +872,7 @@ impl AppRuntime { let mut discovery_stop = self .discovery_stop .lock() - .map_err(|_| "discovery state lock poisoned".to_string())?; + .unwrap_or_else(|e| e.into_inner()); if discovery_stop.is_some() { return Ok(()); @@ -867,7 +887,7 @@ impl AppRuntime { let mut layout = self .layout .lock() - .map_err(|_| "layout state lock poisoned".to_string())? + .unwrap_or_else(|e| e.into_inner()) .clone(); let desired_port = if layout.transport_port_mode == "auto" { default_transport_port() @@ -1261,6 +1281,169 @@ fn read_diagnostic_info( diagnostic_info(&app, state.inner()) } +#[derive(Debug, Clone, serde::Serialize)] +#[serde(rename_all = "camelCase")] +struct SyncRecord { + timestamp: String, + kind: String, + direction: String, + target: String, + content_type: String, + preview: String, + detail: String, +} + +#[tauri::command] +async fn read_sync_history( + app: AppHandle, + count: Option, +) -> Result, String> { + let log_dir = app + .path() + .app_log_dir() + .map_err(|e| format!("failed to resolve log dir: {e}"))?; + let log_file = log_dir.join("mykvm.log"); + tokio::task::spawn_blocking(move || -> Result, String> { + if !log_file.exists() { + return Ok(vec![]); + } + let content = + fs::read_to_string(&log_file).map_err(|e| format!("failed to read log file: {e}"))?; + let mut records: Vec = Vec::new(); + for line in content.lines() { + if let Some(rec) = parse_sync_line(line) { + records.push(rec); + } + } + let max = count.unwrap_or(100); + let start = if records.len() > max { + records.len() - max + } else { + 0 + }; + Ok(records[start..].to_vec()) + }) + .await + .map_err(|e| format!("sync history task failed: {e}"))? +} + +fn parse_sync_line(line: &str) -> Option { + let ts = line.get(1..20)?.to_string(); + if line.contains("sync:clipboard:sent") { + let to = extract_kv(line, "to="); + let content_raw = extract_kv(line, "content="); + let (ct, preview) = split_content_field(&content_raw); + Some(SyncRecord { + timestamp: ts, + kind: "clipboard".into(), + direction: "sent".into(), + target: to, + content_type: ct, + preview, + detail: String::new(), + }) + } else if line.contains("sync:clipboard:received") { + let from = extract_kv(line, "from="); + let content_raw = extract_kv(line, "content="); + let (ct, preview) = split_content_field(&content_raw); + Some(SyncRecord { + timestamp: ts, + kind: "clipboard".into(), + direction: "received".into(), + target: from, + content_type: ct, + preview, + detail: String::new(), + }) + } else if line.contains("sync:file:sent") { + let to = extract_kv(line, "to="); + let files = extract_kv(line, "files="); + let bytes = extract_kv(line, "bytes="); + let detail = format!("{} files, {} bytes", files, bytes); + Some(SyncRecord { + timestamp: ts, + kind: "file".into(), + direction: "sent".into(), + target: to, + content_type: "file".into(), + preview: String::new(), + detail, + }) + } else if line.contains("received file transfer") { + let rest = line + .split("received file transfer ") + .nth(1) + .unwrap_or("") + .trim(); + let parts: Vec<&str> = rest.splitn(2, ' ').collect(); + let file_name = parts.first().unwrap_or(&"").to_string(); + let detail = parts.get(1).unwrap_or(&"").to_string(); + Some(SyncRecord { + timestamp: ts, + kind: "file".into(), + direction: "received".into(), + target: String::new(), + content_type: "file".into(), + preview: file_name, + detail, + }) + } else { + None + } +} + +fn extract_kv(line: &str, key: &str) -> String { + let Some(start) = line.find(key) else { + return String::new(); + }; + let rest = &line[start + key.len()..]; + if let Some(pos) = rest + .find(" content=") + .or_else(|| rest.find(" files=")) + .or_else(|| rest.find(" bytes=")) + { + rest[..pos].to_string() + } else { + rest.trim().to_string() + } +} + +fn split_content_field(raw: &str) -> (String, String) { + if raw.starts_with("text:") { + ("text".into(), raw[5..].to_string()) + } else if raw == "image" { + ("image".into(), String::new()) + } else { + ("unknown".into(), raw.to_string()) + } +} + +#[tauri::command] +async fn read_log_lines(app: AppHandle, count: Option) -> Result, String> { + let log_dir = app + .path() + .app_log_dir() + .map_err(|error| format!("failed to resolve log dir: {error}"))?; + let log_file = log_dir.join("mykvm.log"); + tokio::task::spawn_blocking(move || -> Result, String> { + if !log_file.exists() { + return Ok(vec![]); + } + let content = fs::read_to_string(&log_file) + .map_err(|error| format!("failed to read log file: {error}"))?; + let lines: Vec = content.lines().map(String::from).collect(); + let max_lines = count.unwrap_or(200); + let start = if lines.len() > max_lines { + lines.len() - max_lines + } else { + 0 + }; + Ok(lines[start..].to_vec()) + }) + .await + .map_err(|error| format!("log lines task failed: {error}"))? +} + #[tauri::command] fn open_log_directory(app: AppHandle) -> Result<(), String> { let log_dir = app @@ -1282,10 +1465,7 @@ fn save_layout( state: tauri::State<'_, AppRuntime>, ) -> Result { let (previous_layout, saved_layout) = { - let mut stored_layout = state - .layout - .lock() - .map_err(|_| "layout state lock poisoned".to_string())?; + let mut stored_layout = state.layout.lock().unwrap_or_else(|e| e.into_inner()); let previous_layout = stored_layout.clone(); let saved_layout = merge_runtime_owned_layout_fields(layout, &previous_layout); write_layout_to_disk(&state.config_path, &saved_layout)?; @@ -1304,7 +1484,7 @@ fn save_layout( if !state .runtime .lock() - .map_err(|_| "runtime state lock poisoned".to_string())? + .unwrap_or_else(|e| e.into_inner()) .started { state.start_discovery()?; @@ -1410,7 +1590,7 @@ fn restart_runtime_if_running(state: &AppRuntime) -> Result<(), String> { let started = state .runtime .lock() - .map_err(|_| "runtime state lock poisoned".to_string())? + .unwrap_or_else(|e| e.into_inner()) .started; if !started { @@ -1428,10 +1608,7 @@ fn restart_runtime_if_running(state: &AppRuntime) -> Result<(), String> { let (capture, inject) = state.start_input(layout.clone()); let clipboard = state.start_clipboard(layout.clone()); let discovery = state.discovery_status_for_layout(&layout); - let mut runtime = state - .runtime - .lock() - .map_err(|_| "runtime state lock poisoned".to_string())?; + let mut runtime = state.runtime.lock().unwrap_or_else(|e| e.into_inner()); runtime.transport = ready_transport_status(&discovery); runtime.capture = capture; @@ -1454,10 +1631,7 @@ fn start_runtime_inner(state: &AppRuntime) -> Result { let (capture, inject) = state.start_input(layout.clone()); let clipboard = state.start_clipboard(layout.clone()); - let mut runtime = state - .runtime - .lock() - .map_err(|_| "runtime state lock poisoned".to_string())?; + let mut runtime = state.runtime.lock().unwrap_or_else(|e| e.into_inner()); *runtime = RuntimeStatus { started: true, @@ -1525,6 +1699,7 @@ fn diagnostic_info(app: &AppHandle, state: &AppRuntime) -> Result>(); let network_hint = diagnostic_network_hint(&known_devices); let firewall_hint = diagnostic_firewall_hint(); + let input_debug = input::current_input_debug_info(); let mut lines = vec![ "MyKVM diagnostics".to_string(), @@ -1576,6 +1751,7 @@ fn diagnostic_info(app: &AppHandle, state: &AppRuntime) -> Result Result Result { state.stop_clipboard(); state.start_discovery()?; - let mut runtime = state - .runtime - .lock() - .map_err(|_| "runtime state lock poisoned".to_string())?; + let mut runtime = state.runtime.lock().unwrap_or_else(|e| e.into_inner()); let layout = state.layout_snapshot(); let mut stopped_runtime = default_runtime(&layout); stopped_runtime.discovery = state.discovery_status_for_layout(&layout); @@ -1677,7 +1851,7 @@ fn toggle_runtime_from_app(app: &AppHandle) -> Result { let started = state .runtime .lock() - .map_err(|_| "runtime state lock poisoned".to_string())? + .unwrap_or_else(|e| e.into_inner()) .started; let runtime = if started { stop_runtime_inner(state.inner())? @@ -1731,7 +1905,7 @@ fn sync_runtime_toggle_shortcut(app: &AppHandle) -> Result<(), String> { let mut current = state .runtime_toggle_shortcut .lock() - .map_err(|_| "runtime toggle shortcut lock poisoned".to_string())?; + .unwrap_or_else(|e| e.into_inner()); if current.as_deref() == shortcut.as_deref() { return Ok(()); @@ -2165,6 +2339,12 @@ fn send_files_to_device_with_destination( byte_count = byte_count.saturating_add(file.total_bytes); } + log::info!( + "sync:file:sent to={} files={} bytes={}", + target.name, + file_count, + byte_count + ); Ok(FileTransferSummary { target_name: target.name, file_count, @@ -2173,47 +2353,55 @@ fn send_files_to_device_with_destination( } fn start_edge_drop_window_sync(app_handle: AppHandle) { - thread::spawn(move || loop { - let state = app_handle.state::(); - let runtime = state.inner(); - let layout = runtime.layout_snapshot(); - let peers = active_peer_snapshot(&runtime.peers); - let specs = edge_drop_specs_for_window_visibility( - &layout, - &peers, - runtime.main_window_visible.load(Ordering::Relaxed), - ); - let next_labels = specs - .iter() - .map(|spec| spec.label.clone()) - .collect::>(); - let next_targets = specs - .iter() - .map(|spec| (spec.label.clone(), spec.target_device_id.clone())) - .collect::>(); - let stale_labels = runtime - .edge_drop_targets - .lock() - .map(|mut targets| { - let stale = targets - .keys() - .filter(|label| !next_labels.contains(*label)) - .cloned() - .collect::>(); - *targets = next_targets; - stale - }) - .unwrap_or_default(); + let stop = app_handle.state::().edge_drop_stop.clone(); + thread::spawn(move || { + while !stop.load(Ordering::Relaxed) { + let state = app_handle.state::(); + let runtime = state.inner(); + let layout = runtime.layout_snapshot(); + let peers = active_peer_snapshot(&runtime.peers); + let specs = edge_drop_specs_for_window_visibility( + &layout, + &peers, + runtime.main_window_visible.load(Ordering::Relaxed), + ); + let next_labels = specs + .iter() + .map(|spec| spec.label.clone()) + .collect::>(); + let next_targets = specs + .iter() + .map(|spec| (spec.label.clone(), spec.target_device_id.clone())) + .collect::>(); + let stale_labels = runtime + .edge_drop_targets + .lock() + .map(|mut targets| { + let stale = targets + .keys() + .filter(|label| !next_labels.contains(*label)) + .cloned() + .collect::>(); + *targets = next_targets; + stale + }) + .unwrap_or_default(); - let main_handle = app_handle.clone(); - let _ = app_handle.run_on_main_thread(move || { - sync_edge_drop_windows_on_main(&main_handle, specs, stale_labels); - }); + let main_handle = app_handle.clone(); + let _ = app_handle.run_on_main_thread(move || { + sync_edge_drop_windows_on_main(&main_handle, specs, stale_labels); + }); - thread::sleep(Duration::from_millis(1500)); + thread::sleep(Duration::from_millis(1500)); + } }); } +#[allow(dead_code)] +fn stop_edge_drop_window_sync(state: &AppRuntime) { + state.edge_drop_stop.store(true, Ordering::Relaxed); +} + fn sync_edge_drop_windows_on_main( app_handle: &AppHandle, specs: Vec, @@ -2731,7 +2919,7 @@ async fn scan_lan_peers(state: tauri::State<'_, AppRuntime>) -> Result) -> Result) -> Result) -> Result) -> Result { let updated_layout = { - let mut layout = state - .layout - .lock() - .map_err(|_| "layout state lock poisoned".to_string())?; + let mut layout = state.layout.lock().unwrap_or_else(|e| e.into_inner()); layout.paired_controllers.clear(); layout.clone() }; @@ -3442,6 +3627,8 @@ pub fn run() { load_app_state, read_runtime_status, read_diagnostic_info, + read_sync_history, + read_log_lines, open_log_directory, save_layout, start_runtime, @@ -5011,18 +5198,51 @@ fn run_clipboard_sync( let mut last_failed: Option<(String, String, String, Instant)> = None; let mut last_poll = Instant::now() - Duration::from_secs(1); let mut sequence = now_ms(); + let mut consecutive_failures: u32 = 0; + let mut backoff_until: Option = None; + let mut last_change_token = clipboard::change_token(); + let mut local_change_pending = false; while !stop.load(Ordering::Relaxed) { + let change_token = clipboard::change_token(); + if change_token.is_some() && change_token != last_change_token { + last_change_token = change_token; + local_change_pending = true; + last_failed = None; + consecutive_failures = 0; + backoff_until = None; + } + let Some(target) = input::current_clipboard_target(&clipboard_target) else { thread::sleep(Duration::from_millis(120)); last_poll = Instant::now() - Duration::from_secs(1); + consecutive_failures = 0; + backoff_until = None; continue; }; + // Exponential backoff: when consecutive sends keep failing, sleep + // progressively longer to avoid burning CPU on a dead peer. + if let Some(deadline) = backoff_until { + if Instant::now() < deadline { + thread::sleep(Duration::from_millis(CLIPBOARD_IDLE_SLEEP_MS)); + continue; + } + backoff_until = None; + } + if last_poll.elapsed() < Duration::from_millis(CLIPBOARD_POLL_INTERVAL_MS) { thread::sleep(Duration::from_millis(CLIPBOARD_IDLE_SLEEP_MS)); continue; } + + // Skip reading the clipboard while a remote write is in progress to + // avoid racing on OpenClipboard (Windows OS error 1418). + if CLIPBOARD_WRITE_ACTIVE.load(Ordering::Relaxed) { + thread::sleep(Duration::from_millis(CLIPBOARD_IDLE_SLEEP_MS)); + continue; + } + last_poll = Instant::now(); let Some(content) = clipboard::read_content() else { @@ -5039,24 +5259,28 @@ fn run_clipboard_sync( .map(|seen| seen.as_deref() == Some(signature.as_str())) .unwrap_or(false); if is_known_echo { + last_sent = Some((target.device_id.clone(), target.addr.clone(), signature)); + local_change_pending = false; continue; } if let Ok(mut seen) = clipboard_seen_text.lock() { *seen = None; } + } else if let Ok(mut seen) = clipboard_seen_text.lock() { + *seen = None; } if content.is_oversized() { continue; } - if last_sent - .as_ref() - .map(|(device_id, addr, previous)| { - device_id == &target.device_id && addr == &target.addr && previous == &signature - }) - .unwrap_or(false) - { + if clipboard_signature_already_sent( + &last_sent, + &target.device_id, + &target.addr, + &signature, + local_change_pending, + ) { continue; } if last_failed @@ -5072,23 +5296,6 @@ fn run_clipboard_sync( continue; } - let should_send = clipboard_seen_text - .lock() - .map(|mut seen| { - if seen.as_deref() == Some(signature.as_str()) { - *seen = None; - false - } else { - true - } - }) - .unwrap_or(true); - - if !should_send { - last_sent = Some((target.device_id.clone(), target.addr.clone(), signature)); - continue; - } - sequence = sequence.saturating_add(1); let packet = clipboard_packet_from_content( content, @@ -5109,9 +5316,34 @@ fn run_clipboard_sync( if send_result.is_ok() { transport_packets.fetch_add(1, Ordering::Relaxed); clipboard_packets.fetch_add(1, Ordering::Relaxed); + consecutive_failures = 0; + backoff_until = None; + let clip_preview = match &packet.formats.first().map(|f| f.kind.as_str()) { + Some("plainText") => { + let txt = &packet.text; + if txt.len() > 60 { + format!("text:{:.60}...", txt) + } else { + format!("text:{}", txt) + } + } + Some("imageRgba") => "image".to_string(), + _ => "unknown".to_string(), + }; + log::info!( + "sync:clipboard:sent to={} content={}", + target.device_id, + clip_preview + ); last_failed = None; last_sent = Some((target.device_id, target.addr, signature)); + local_change_pending = false; } else { + consecutive_failures = consecutive_failures.saturating_add(1); + let backoff_ms = CLIPBOARD_RETRY_INTERVAL_MS + .saturating_mul(1 << consecutive_failures.min(4)) + .min(CLIPBOARD_BACKOFF_MAX_MS); + backoff_until = Some(Instant::now() + Duration::from_millis(backoff_ms)); if let Err(error) = send_result { log::warn!("clipboard send failed: {error}"); } @@ -5126,6 +5358,24 @@ fn run_clipboard_sync( } } +fn clipboard_signature_already_sent( + last_sent: &Option<(String, String, String)>, + device_id: &str, + addr: &str, + signature: &str, + local_change_pending: bool, +) -> bool { + !local_change_pending + && last_sent + .as_ref() + .map(|(previous_device, previous_addr, previous_signature)| { + previous_device == device_id + && previous_addr == addr + && previous_signature == signature + }) + .unwrap_or(false) +} + /// True while we are inside the post-write grace window (see /// `CLIPBOARD_ECHO_GRACE_MS`). fn clipboard_echo_active(clipboard_echo_until: &Arc>>) -> bool { @@ -5146,18 +5396,21 @@ fn arm_clipboard_echo_guard(clipboard_echo_until: &Arc>>) } fn write_clipboard_content_with_retry(content: &ClipboardContent) -> Result<(), String> { - retry_clipboard_content_write( + CLIPBOARD_WRITE_ACTIVE.store(true, Ordering::Relaxed); + let result = retry_clipboard_content_write( content, CLIPBOARD_WRITE_ATTEMPTS, - Duration::from_millis(CLIPBOARD_WRITE_RETRY_DELAY_MS), + CLIPBOARD_WRITE_RETRY_DELAY_BASE_MS, clipboard::write_content, - ) + ); + CLIPBOARD_WRITE_ACTIVE.store(false, Ordering::Relaxed); + result } fn retry_clipboard_content_write( content: &ClipboardContent, attempts: usize, - retry_delay: Duration, + retry_delay_base_ms: u64, mut write_content: F, ) -> Result<(), String> where @@ -5170,8 +5423,12 @@ where Ok(()) => return Ok(()), Err(error) => last_error = Some(error), } - if attempt + 1 < attempts && !retry_delay.is_zero() { - thread::sleep(retry_delay); + if attempt + 1 < attempts && retry_delay_base_ms > 0 { + // Progressive delay: 50, 100, 150, 200, ... ms + // Total window for 10 attempts: ~2.25s — enough to outlast most + // transient clipboard locks from other processes. + let delay_ms = retry_delay_base_ms * (attempt as u64 + 1); + thread::sleep(Duration::from_millis(delay_ms)); } } @@ -5234,6 +5491,7 @@ where } let accepted_sequence = clipboard_packet_sequence(&packet); + let packet_origin_id = packet.origin_id.clone(); let content = clipboard_content_from_packet(packet); let Some(content) = content else { @@ -5253,6 +5511,21 @@ where }; if written { + let recv_preview = match &content { + ClipboardContent::Text(t) => { + if t.len() > 60 { + format!("text:{:.60}...", t) + } else { + format!("text:{}", t) + } + } + ClipboardContent::Image(_) => "image".to_string(), + }; + log::info!( + "sync:clipboard:received from={} content={}", + packet_origin_id, + recv_preview + ); if let Some((origin_id, sequence)) = accepted_sequence { remember_clipboard_packet_sequence(clipboard_last_sequences, origin_id, sequence); } @@ -5635,7 +5908,7 @@ fn send_file_transfer_packet( target.protocol_version, ); quic_transport - .send_stream_expect_ack(peer, payload) + .send_bulk_stream_expect_ack(peer, payload) .map_err(|error| format!("文件传输失败: {error}")) } @@ -5952,6 +6225,8 @@ fn start_incoming_file_transfer( return false; }; + purge_stale_file_transfers(transfers); + if let Err(error) = fs::create_dir_all(receive_root) { log::warn!( "file transfer receive failed: could not create {}: {error}", @@ -5988,6 +6263,7 @@ fn start_incoming_file_transfer( next_chunk_index: 0, temp_path, final_path, + started_at: Instant::now(), }; transfers @@ -5999,6 +6275,23 @@ fn start_incoming_file_transfer( .unwrap_or(false) } +fn purge_stale_file_transfers(transfers: &Arc>>) { + let Ok(mut map) = transfers.lock() else { + return; + }; + let stale_ids: Vec = map + .iter() + .filter(|(_, t)| t.started_at.elapsed() > FILE_TRANSFER_STALE_TIMEOUT) + .map(|(id, _)| id.clone()) + .collect(); + for id in stale_ids { + if let Some(transfer) = map.remove(&id) { + let _ = fs::remove_file(&transfer.temp_path); + log::info!("purged stale file transfer {} ({})", transfer.file_name, id); + } + } +} + fn append_incoming_file_transfer_chunk( packet: FileTransferPacket, transfers: &Arc>>, @@ -6016,7 +6309,26 @@ fn append_incoming_file_transfer_chunk( || packet.target_id != transfer.target_id || packet.file_name != transfer.file_name || packet.total_bytes != transfer.total_bytes - || packet.chunk_index != transfer.next_chunk_index + { + return false; + } + + let duplicate_end = packet.offset.saturating_add(packet.data.len() as u64); + if packet.chunk_index.saturating_add(1) == transfer.next_chunk_index + && duplicate_end == transfer.received_bytes + { + let existing_matches = fs::File::open(&transfer.temp_path) + .and_then(|mut file| { + file.seek(SeekFrom::Start(packet.offset))?; + let mut existing = vec![0_u8; packet.data.len()]; + file.read_exact(&mut existing)?; + Ok(existing == packet.data) + }) + .unwrap_or(false); + return existing_matches; + } + + if packet.chunk_index != transfer.next_chunk_index || packet.offset != transfer.received_bytes || transfer .received_bytes @@ -7220,9 +7532,7 @@ fn complete_pairing_from_confirm( } { - let mut challenge = pairing_challenge - .lock() - .map_err(|_| "pairing challenge lock poisoned".to_string())?; + let mut challenge = pairing_challenge.lock().unwrap_or_else(|e| e.into_inner()); let Some(existing) = challenge.as_mut() else { return Err("验证码已过期,请重新发起配对。".into()); }; @@ -7247,9 +7557,7 @@ fn complete_pairing_from_confirm( } let snapshot = { - let mut layout = layout_state - .lock() - .map_err(|_| "layout state lock poisoned".to_string())?; + let mut layout = layout_state.lock().unwrap_or_else(|e| e.into_inner()); if layout.machine_role != "client" { return Err("只有客户端可以接受服务端配对。".into()); } @@ -7763,6 +8071,42 @@ mod tests { assert_eq!(layout.devices[1].transport_port, 52000); } + #[test] + fn diagnostic_info_report_includes_input_debug_section() { + let summary = input::InputDebugInfo { + enabled: true, + status: "collecting".into(), + latest_failure: Some("input helper pipe unavailable".into()), + last_route: Some("helper-fallback".into()), + recent_event_count: 1, + events: vec![input::InputDebugEvent { + timestamp_ms: 123, + controller_id: "controller-a".into(), + event_type: "mouseMove".into(), + screen_id: "screen-1".into(), + relative_x: Some(10), + relative_y: Some(20), + absolute_x: Some(110), + absolute_y: Some(220), + desktop: "secure".into(), + route: "helper-fallback".into(), + pipe_available: Some(false), + result: "fallback".into(), + detail: "input helper pipe unavailable".into(), + }], + }; + + let lines = input::input_debug_report_lines(&summary); + + assert!(lines + .iter() + .any(|line| line.contains("input debug: collecting"))); + assert!(lines + .iter() + .any(|line| line.contains("latest failure: input helper pipe unavailable"))); + assert!(lines.iter().any(|line| line.contains("recent events: 1"))); + } + #[test] fn peer_presence_matches_same_cluster_host_after_identity_rotation() { let mut layout = test_layout(); @@ -8321,11 +8665,13 @@ mod tests { width: 2, height: 1, rgba_base64: "AAAAAAAAAAA=".into(), + compressed: false, }); let second = ClipboardContent::Image(ClipboardImage { width: 2, height: 1, rgba_base64: "AQEBAQEBAQE=".into(), + compressed: false, }); assert_ne!(first.signature(), second.signature()); @@ -8340,6 +8686,7 @@ mod tests { width: 800, height: 600, rgba_base64: "A".repeat(encoded_len), + compressed: false, }), "local-device".into(), "peer-device".into(), @@ -8382,7 +8729,7 @@ mod tests { let content = ClipboardContent::Text("hello".into()); let mut calls = 0; - let result = retry_clipboard_content_write(&content, 3, Duration::ZERO, |_| { + let result = retry_clipboard_content_write(&content, 3, 0, |_| { calls += 1; if calls < 3 { Err("clipboard busy".into()) @@ -8395,6 +8742,30 @@ mod tests { assert_eq!(calls, 3); } + #[test] + fn clipboard_sequence_change_resends_identical_content() { + let last_sent = Some(( + "peer-a".to_string(), + "10.0.0.2:47834".to_string(), + "text:hello".to_string(), + )); + + assert!(clipboard_signature_already_sent( + &last_sent, + "peer-a", + "10.0.0.2:47834", + "text:hello", + false, + )); + assert!(!clipboard_signature_already_sent( + &last_sent, + "peer-a", + "10.0.0.2:47834", + "text:hello", + true, + )); + } + #[test] fn clipboard_formats_only_text_packet_is_accepted() { let layout = test_layout(); @@ -8639,7 +9010,7 @@ mod tests { input_ready: false, upgrading: false, screens: vec![], - app_version: "0.1.0".into(), + app_version: "0.9.8".into(), last_seen_ms: now_ms(), }]; @@ -8767,6 +9138,63 @@ mod tests { let _ = fs::remove_dir_all(root); } + #[test] + fn file_transfer_accepts_identical_retried_chunk_once() { + let layout = test_layout(); + let root = temp_test_dir("file-transfer-retry"); + let transfers = Arc::new(Mutex::new(HashMap::new())); + + for packet in [ + test_file_transfer_packet("start", "transfer-retry", "note.txt", 5, 0, 0, b""), + test_file_transfer_packet("chunk", "transfer-retry", "note.txt", 5, 0, 0, b"hello"), + ] { + let payload = encode_wire_packet(&packet).expect("file packet should encode"); + assert!(handle_file_transfer_packet_with_root( + &payload, + &layout, + "local-device", + &transfers, + &root + )); + } + + let duplicate = + test_file_transfer_packet("chunk", "transfer-retry", "note.txt", 5, 0, 0, b"hello"); + let duplicate_payload = encode_wire_packet(&duplicate).expect("duplicate should encode"); + assert!(handle_file_transfer_packet_with_root( + &duplicate_payload, + &layout, + "local-device", + &transfers, + &root + )); + + let mismatched = + test_file_transfer_packet("chunk", "transfer-retry", "note.txt", 5, 0, 0, b"HELLO"); + let mismatched_payload = encode_wire_packet(&mismatched).expect("mismatch should encode"); + assert!(!handle_file_transfer_packet_with_root( + &mismatched_payload, + &layout, + "local-device", + &transfers, + &root + )); + + let finish = + test_file_transfer_packet("finish", "transfer-retry", "note.txt", 5, 1, 5, b""); + let finish_payload = encode_wire_packet(&finish).expect("finish should encode"); + assert!(handle_file_transfer_packet_with_root( + &finish_payload, + &layout, + "local-device", + &transfers, + &root + )); + assert_eq!(fs::read(root.join("note.txt")).unwrap(), b"hello"); + + let _ = fs::remove_dir_all(root); + } + #[test] fn file_transfer_sanitizes_received_file_names() { assert_eq!( diff --git a/src-tauri/src/quic_transport.rs b/src-tauri/src/quic_transport.rs index ed98641..5d40e1d 100644 --- a/src-tauri/src/quic_transport.rs +++ b/src-tauri/src/quic_transport.rs @@ -3,9 +3,12 @@ use std::{ fs, net::{SocketAddr, ToSocketAddrs}, path::{Path, PathBuf}, - sync::{mpsc, Arc}, + sync::{ + atomic::{AtomicU64, Ordering}, + mpsc, Arc, Mutex, + }, thread, - time::Duration, + time::{Duration, Instant}, }; use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _}; @@ -22,7 +25,7 @@ use quinn::{ }, ClientConfig, Endpoint, ServerConfig, }; -use tokio::sync::mpsc as tokio_mpsc; +use tokio::sync::{mpsc as tokio_mpsc, Mutex as TokioMutex}; pub const PROTOCOL_VERSION: u16 = 1; @@ -44,11 +47,81 @@ pub struct PeerEndpoint { pub protocol_version: u16, } +/// Maximum consecutive datagram failures before `send_datagram` short-circuits +/// with an error so the input layer can release the cursor immediately. +const DATAGRAM_FAIL_THRESHOLD: u64 = 3; +const DATAGRAM_QUEUE_CAP: u64 = 32; +const DATAGRAM_HARD_QUEUE_CAP: u64 = 64; +const STREAM_QUEUE_CAP: u64 = 8; +const BULK_STREAM_QUEUE_CAP: u64 = 6; +const MAX_DATAGRAM_HEALTH_PEERS: usize = 64; + +fn try_reserve_pending(pending: &AtomicU64, cap: u64) -> bool { + pending + .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |current| { + (current < cap).then_some(current + 1) + }) + .is_ok() +} + +fn release_pending(pending: &AtomicU64) { + let _ = pending.fetch_update(Ordering::Relaxed, Ordering::Relaxed, |current| { + Some(current.saturating_sub(1)) + }); +} + +#[derive(Default)] +struct DatagramHealth { + failures: HashMap, +} + +impl DatagramHealth { + fn should_fast_fail(&self, peer: &str) -> bool { + self.failure_count(peer) >= DATAGRAM_FAIL_THRESHOLD + } + + fn failure_count(&self, peer: &str) -> u64 { + self.failures.get(peer).copied().unwrap_or(0) + } + + fn record_failure(&mut self, peer: &str) -> u64 { + if !self.failures.contains_key(peer) && self.failures.len() >= MAX_DATAGRAM_HEALTH_PEERS { + if let Some(stale) = self.failures.keys().next().cloned() { + self.failures.remove(&stale); + } + } + let failures = self.failures.entry(peer.to_string()).or_default(); + *failures = failures.saturating_add(1); + *failures + } + + fn record_success(&mut self, peer: &str) { + self.failures.remove(peer); + } + + fn reset(&mut self) { + self.failures.clear(); + } +} + +fn datagram_health_key(peer: &PeerEndpoint) -> String { + format!( + "{}\0{}\0{}", + peer.addr, peer.public_key, peer.protocol_version + ) +} + #[derive(Clone)] pub struct TransportHandle { commands: tokio_mpsc::UnboundedSender, port: u16, public_key: String, + /// Consecutive datagram send failures observed by the transport loop. + /// Shared with the input layer so it can detect a dead peer without + /// waiting for the async transport to propagate the error. + datagram_health: Arc>, + datagram_pending: Arc, + stream_pending: Arc, } impl TransportHandle { @@ -69,6 +142,19 @@ impl TransportHandle { } pub fn send_datagram(&self, peer: PeerEndpoint, payload: Vec) -> Result<(), String> { + self.send_datagram_with_policy(peer, payload, false) + } + + pub fn send_datagram_lossy(&self, peer: PeerEndpoint, payload: Vec) -> Result<(), String> { + self.send_datagram_with_policy(peer, payload, true) + } + + fn send_datagram_with_policy( + &self, + peer: PeerEndpoint, + payload: Vec, + lossy: bool, + ) -> Result<(), String> { if payload.len() > MAX_DATAGRAM_BYTES { return Err(format!( "QUIC datagram is too large: {} bytes", @@ -76,9 +162,43 @@ impl TransportHandle { )); } - self.commands + // Fast-fail: if the transport loop has observed multiple consecutive + // datagram failures for the current peer, report the error immediately + // so the input layer can release the cursor. + let health_key = datagram_health_key(&peer); + if self + .datagram_health + .lock() + .map(|health| health.should_fast_fail(&health_key)) + .unwrap_or(false) + { + return Err( + "QUIC datagram send failed: peer unreachable (consecutive failures)".to_string(), + ); + } + + let cap = if lossy { + DATAGRAM_QUEUE_CAP + } else { + DATAGRAM_HARD_QUEUE_CAP + }; + if !try_reserve_pending(&self.datagram_pending, cap) { + return if lossy { + Ok(()) + } else { + Err("QUIC input queue is full".into()) + }; + } + + if self + .commands .send(TransportCommand::SendDatagram { peer, payload }) - .map_err(|_| "QUIC transport is stopped".to_string()) + .is_err() + { + release_pending(&self.datagram_pending); + return Err("QUIC transport is stopped".into()); + } + Ok(()) } pub fn send_stream_expect_ack( @@ -86,7 +206,15 @@ impl TransportHandle { peer: PeerEndpoint, payload: Vec, ) -> Result<(), String> { - self.send_stream_inner(peer, payload, true) + self.send_stream_inner(peer, payload, true, false) + } + + pub fn send_bulk_stream_expect_ack( + &self, + peer: PeerEndpoint, + payload: Vec, + ) -> Result<(), String> { + self.send_stream_inner(peer, payload, true, true) } fn send_stream_inner( @@ -94,6 +222,7 @@ impl TransportHandle { peer: PeerEndpoint, payload: Vec, ack_required: bool, + bulk: bool, ) -> Result<(), String> { if payload.len() > MAX_STREAM_BYTES { return Err(format!( @@ -102,20 +231,43 @@ impl TransportHandle { )); } + let queue_cap = if bulk { + BULK_STREAM_QUEUE_CAP + } else { + STREAM_QUEUE_CAP + }; + if !try_reserve_pending(&self.stream_pending, queue_cap) { + return Err("QUIC reliable stream queue is full".into()); + } + let (result_tx, result_rx) = mpsc::channel(); - self.commands + if self + .commands .send(TransportCommand::SendStream { peer, payload, ack_required, result: result_tx, }) - .map_err(|_| "QUIC transport is stopped".to_string())?; + .is_err() + { + release_pending(&self.stream_pending); + return Err("QUIC transport is stopped".into()); + } result_rx - .recv_timeout(Duration::from_secs(5)) + .recv_timeout(Duration::from_secs(3)) .map_err(|_| "QUIC stream send timed out".to_string())? } + /// Reset the datagram failure counter. Call this when a peer is + /// re-discovered so subsequent sends are attempted immediately instead + /// of being short-circuited. + pub fn reset_datagram_failures(&self) { + if let Ok(mut health) = self.datagram_health.lock() { + health.reset(); + } + } + pub fn shutdown(&self) { let _ = self.commands.send(TransportCommand::Shutdown); } @@ -141,6 +293,8 @@ struct PeerKey { public_key: String, } +type SharedConnections = Arc>>; + pub fn start( preferred_port: u16, identity_dir: PathBuf, @@ -154,6 +308,12 @@ pub fn start( let identity = load_or_create_identity(&identity_dir)?; let (ready_tx, ready_rx) = mpsc::channel(); let (command_tx, command_rx) = tokio_mpsc::unbounded_channel(); + let datagram_health = Arc::new(Mutex::new(DatagramHealth::default())); + let datagram_health_inner = Arc::clone(&datagram_health); + let datagram_pending = Arc::new(AtomicU64::new(0)); + let datagram_pending_inner = Arc::clone(&datagram_pending); + let stream_pending = Arc::new(AtomicU64::new(0)); + let stream_pending_inner = Arc::clone(&stream_pending); thread::Builder::new() .name("mykvm-quic-transport".into()) @@ -178,6 +338,9 @@ pub fn start( on_datagram, on_stream, ready_tx, + datagram_health_inner, + datagram_pending_inner, + stream_pending_inner, )); }) .map_err(|error| format!("failed to spawn QUIC transport thread: {error}"))?; @@ -190,6 +353,9 @@ pub fn start( commands: command_tx, port: ready.port, public_key: ready.public_key, + datagram_health, + datagram_pending, + stream_pending, }) } @@ -205,6 +371,9 @@ async fn run_transport( on_datagram: DatagramHandler, on_stream: StreamHandler, ready_tx: mpsc::Sender>, + datagram_health: Arc>, + datagram_pending: Arc, + stream_pending: Arc, ) { let (endpoint, public_key) = match bind_endpoint(preferred_port, &identity) { Ok(bound) => bound, @@ -225,13 +394,57 @@ async fn run_transport( let _ = ready_tx.send(Ok(ReadyTransport { port, public_key })); spawn_accept_loop(endpoint.clone(), on_datagram, on_stream); - let mut connections: HashMap = HashMap::new(); + let connections: SharedConnections = Arc::new(TokioMutex::new(HashMap::new())); + let mut last_datagram_fail_log: Option = None; + // Cache of peers whose connection recently failed, to avoid repeated 2s + // timeout attempts on every mouse-move datagram. + let mut datagram_fail_cache: HashMap = HashMap::new(); while let Some(command) = commands.recv().await { match command { TransportCommand::SendDatagram { peer, payload } => { - if let Err(error) = send_datagram(&endpoint, &mut connections, peer, payload).await - { - log::warn!("QUIC datagram send failed: {error}"); + datagram_pending.fetch_sub(1, Ordering::Relaxed); + let health_key = datagram_health_key(&peer); + // Skip send attempt if this peer failed recently (within 5s). + let skip = resolve_peer_addr(&peer.addr) + .ok() + .and_then(|addr| datagram_fail_cache.get(&addr)) + .map(|t| t.elapsed() < Duration::from_secs(5)) + .unwrap_or(false); + let result = if skip { + Err("peer recently unreachable".to_string()) + } else { + send_datagram(&endpoint, &connections, peer.clone(), payload).await + }; + if let Err(error) = result { + let count = datagram_health + .lock() + .map(|mut health| health.record_failure(&health_key)) + .unwrap_or(1); + // Evict the dead connection so next attempt reconnects. + if let Ok(addr) = resolve_peer_addr(&peer.addr) { + let key = PeerKey { + addr, + public_key: peer.public_key.clone(), + }; + connections.lock().await.remove(&key); + datagram_fail_cache.insert(addr, Instant::now()); + } + // Rate-limit the warning log. + let should_log = last_datagram_fail_log + .map(|t| t.elapsed() > Duration::from_secs(10)) + .unwrap_or(true); + if should_log { + log::warn!("QUIC datagram send failed (x{count}): {error}"); + last_datagram_fail_log = Some(Instant::now()); + } + } else { + if let Ok(mut health) = datagram_health.lock() { + health.record_success(&health_key); + } + // Clear fail cache on success so we try normally next time. + if let Ok(addr) = resolve_peer_addr(&peer.addr) { + datagram_fail_cache.remove(&addr); + } } } TransportCommand::SendStream { @@ -240,12 +453,33 @@ async fn run_transport( ack_required, result, } => { - let send_result = - send_stream(&endpoint, &mut connections, peer, payload, ack_required).await; - if let Err(error) = &send_result { - log::warn!("QUIC stream send failed: {error}"); - } - let _ = result.send(send_result); + let endpoint = endpoint.clone(); + let connections = Arc::clone(&connections); + let stream_pending = Arc::clone(&stream_pending); + tokio::spawn(async move { + let send_result = send_stream( + &endpoint, + &connections, + peer.clone(), + payload.clone(), + ack_required, + ) + .await; + let send_result = if send_result.is_err() { + if let Ok(key) = peer_key(&peer) { + connections.lock().await.remove(&key); + } + log::info!("QUIC stream send retry after connection eviction"); + send_stream(&endpoint, &connections, peer, payload, ack_required).await + } else { + send_result + }; + if let Err(error) = &send_result { + log::warn!("QUIC stream send failed: {error}"); + } + release_pending(&stream_pending); + let _ = result.send(send_result); + }); } TransportCommand::Shutdown => break, } @@ -513,11 +747,17 @@ fn client_config(peer: &PeerEndpoint) -> Result { fn spawn_accept_loop(endpoint: Endpoint, on_datagram: DatagramHandler, on_stream: StreamHandler) { tokio::spawn(async move { + let mut last_fail_log: HashMap = HashMap::new(); while let Some(incoming) = endpoint.accept().await { let remote = incoming.remote_address(); let on_datagram = Arc::clone(&on_datagram); let on_stream = Arc::clone(&on_stream); + let should_log = last_fail_log + .get(&remote) + .map(|t| t.elapsed() > Duration::from_secs(10)) + .unwrap_or(true); + tokio::spawn(async move { match incoming.await { Ok(connection) => { @@ -525,10 +765,22 @@ fn spawn_accept_loop(endpoint: Endpoint, on_datagram: DatagramHandler, on_stream spawn_stream_reader(connection, remote, on_stream); } Err(error) => { - log::warn!("QUIC incoming connection failed from {remote}: {error}"); + if should_log { + log::warn!("QUIC incoming connection failed from {remote}: {error}"); + } } } }); + + // Rate-limit failure logs per remote address. + if should_log { + last_fail_log.insert(remote, Instant::now()); + } + // Prevent unbounded growth of the log-rate map. + if last_fail_log.len() > 64 { + let cutoff = Instant::now() - Duration::from_secs(60); + last_fail_log.retain(|_, t| *t > cutoff); + } } }); } @@ -586,7 +838,7 @@ fn spawn_stream_reader( async fn send_datagram( endpoint: &Endpoint, - connections: &mut HashMap, + connections: &SharedConnections, peer: PeerEndpoint, payload: Vec, ) -> Result<(), String> { @@ -594,7 +846,7 @@ async fn send_datagram( match connection.send_datagram(payload.into()) { Ok(()) => Ok(()), Err(error) => { - connections.remove(&key); + connections.lock().await.remove(&key); Err(error.to_string()) } } @@ -602,7 +854,7 @@ async fn send_datagram( async fn send_stream( endpoint: &Endpoint, - connections: &mut HashMap, + connections: &SharedConnections, peer: PeerEndpoint, payload: Vec, ack_required: bool, @@ -610,7 +862,7 @@ async fn send_stream( let (key, connection) = connection_for(endpoint, connections, &peer).await?; let result = send_stream_on_connection(connection, payload, ack_required).await; if result.is_err() { - connections.remove(&key); + connections.lock().await.remove(&key); } result } @@ -657,17 +909,20 @@ fn verify_stream_ack(bytes: &[u8]) -> Result<(), String> { async fn connection_for( endpoint: &Endpoint, - connections: &mut HashMap, + connections: &SharedConnections, peer: &PeerEndpoint, ) -> Result<(PeerKey, quinn::Connection), String> { let key = peer_key(peer)?; - if let Some(connection) = connections.get(&key) { - if connection.close_reason().is_none() { - return Ok((key, connection.clone())); + { + let mut connections = connections.lock().await; + if let Some(connection) = connections.get(&key) { + if connection.close_reason().is_none() { + return Ok((key, connection.clone())); + } } + connections.remove(&key); } - connections.remove(&key); let config = client_config(peer)?; let connecting = endpoint @@ -677,7 +932,10 @@ async fn connection_for( .await .map_err(|_| format!("QUIC connection to {} timed out", key.addr))? .map_err(|error| format!("failed to connect QUIC to {}: {error}", key.addr))?; - connections.insert(key.clone(), connection.clone()); + connections + .lock() + .await + .insert(key.clone(), connection.clone()); Ok((key, connection)) } @@ -773,6 +1031,49 @@ mod tests { assert_eq!(QUIC_WORKER_THREADS, 2); } + #[test] + fn datagram_health_is_isolated_per_peer() { + let mut health = DatagramHealth::default(); + for _ in 0..DATAGRAM_FAIL_THRESHOLD { + health.record_failure("peer-a"); + } + + assert!(health.should_fast_fail("peer-a")); + assert!(!health.should_fast_fail("peer-b")); + + health.record_failure("peer-b"); + health.record_success("peer-a"); + + assert!(!health.should_fast_fail("peer-a")); + assert_eq!(health.failure_count("peer-b"), 1); + } + + #[test] + fn pending_budget_is_bounded_and_reusable() { + let pending = AtomicU64::new(0); + + assert!(try_reserve_pending(&pending, 2)); + assert!(try_reserve_pending(&pending, 2)); + assert!(!try_reserve_pending(&pending, 2)); + + release_pending(&pending); + assert!(try_reserve_pending(&pending, 2)); + assert_eq!(pending.load(Ordering::Relaxed), 2); + } + + #[test] + fn bulk_streams_leave_capacity_for_priority_streams() { + let pending = AtomicU64::new(0); + for _ in 0..BULK_STREAM_QUEUE_CAP { + assert!(try_reserve_pending(&pending, BULK_STREAM_QUEUE_CAP)); + } + assert!(!try_reserve_pending(&pending, BULK_STREAM_QUEUE_CAP)); + + assert!(try_reserve_pending(&pending, STREAM_QUEUE_CAP)); + assert!(try_reserve_pending(&pending, STREAM_QUEUE_CAP)); + assert!(!try_reserve_pending(&pending, STREAM_QUEUE_CAP)); + } + #[test] fn identity_is_stable_across_reloads() { let dir = std::env::temp_dir().join("mykvm-quic-identity-stability-test"); diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 562ad9c..8a56865 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,11 +1,11 @@ { "$schema": "../node_modules/@tauri-apps/cli/config.schema.json", "productName": "mykvm", - "version": "0.1.0", + "version": "0.9.8", "identifier": "com.xzhpl.mykvm", "build": { "frontendDist": "../dist", - "devUrl": "http://localhost:5173", + "devUrl": "http://localhost:5174", "beforeDevCommand": "npm run dev", "beforeBuildCommand": "node scripts/build-tauri-assets.mjs" }, @@ -50,9 +50,9 @@ }, "plugins": { "updater": { - "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEY4NTQyMTlDMkRCREIxQ0EKUldUS3NiMHRuQ0ZVK0pQK3pnV3ZkcnJxUjMzclVoQUh6Vk5lVlNyRzVvRFdWcVF6OWlOUmpEMFIK", + "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDNFOTk3ODREMUEwRjM2RDkKUldUWk5nOGFUWGlaUHF0YVhLa1NKL1RPaUhINzdheUhKNkdCWHRSNWZpMlBnbnA3MUY3a2NoYVEK", "endpoints": [ - "https://github.com/XxMinor/mykvm/releases/latest/download/latest.json" + "https://github.com/aceleisureman/mykvm/releases/latest/download/latest.json" ], "windows": { "installMode": "passive" diff --git a/src/App.css b/src/App.css index c1819d1..8ae4977 100644 --- a/src/App.css +++ b/src/App.css @@ -1912,6 +1912,36 @@ font-weight: 700; } +.diagnostic-input-debug { + display: grid; + gap: 10px; + padding: 12px 14px; + border-radius: 14px; + background: rgba(24, 24, 27, 0.5); + border: 1px solid rgba(161, 161, 170, 0.18); +} + +.diagnostic-input-debug h3 { + margin: 0; + font-size: 13px; + letter-spacing: 0.04em; + text-transform: uppercase; + color: #e4e4e7; +} + +.diagnostic-event-list { + display: grid; + gap: 6px; +} + +.diagnostic-event-item { + margin: 0 !important; + padding: 8px 10px; + border-radius: 10px; + background: rgba(39, 39, 42, 0.7); + overflow-wrap: anywhere; +} + .update-status-badge { min-height: 26px; display: inline-flex; @@ -2526,3 +2556,53 @@ display: grid; } } +/* Log viewer */ +.log-panel{display:flex;flex-direction:column;gap:0;padding-top:0;flex:1 1 0;min-height:0} +.log-toolbar{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:10px 16px;background:var(--surface-card,#1a1a1e);border:1px solid var(--border,rgba(63,63,70,.5));border-radius:8px 8px 0 0;flex-shrink:0} +.log-toolbar h2{margin:0;font-size:14px;font-weight:700} +.log-toolbar-left{display:flex;align-items:center;gap:10px} +.log-toolbar-right{display:flex;align-items:center;gap:12px} +.log-line-count{font-size:11px;font-weight:600;padding:2px 8px;border-radius:999px;background:var(--accent,#2f7af8);color:#fff} +.log-filter-group{display:flex;gap:2px;background:var(--surface-sunken,rgba(0,0,0,.25));border-radius:6px;padding:2px} +.log-filter-btn{padding:3px 12px;font-size:11px;font-weight:600;border:none;border-radius:4px;background:transparent;color:var(--text-tertiary,#71717a);cursor:pointer;transition:all .15s} +.log-filter-btn:hover{color:var(--text-primary,#fafafa)} +.log-filter-btn.active{background:var(--surface-card,#27272a);color:var(--text-primary,#fafafa);box-shadow:0 1px 2px rgba(0,0,0,.3)} +.log-filter-btn.active.warn{color:#f59e0b} +.log-filter-btn.active.error{color:#ef4444} +.log-action-group{display:flex;gap:4px} +.log-action-btn{padding:4px 10px;font-size:11px;font-weight:600;border:1px solid var(--border,rgba(63,63,70,.5));border-radius:6px;background:transparent;color:var(--text-secondary,#a1a1aa);cursor:pointer;transition:all .15s;white-space:nowrap} +.log-action-btn:hover{background:rgba(255,255,255,.06);color:var(--text-primary,#fafafa)} +.log-action-btn.active{background:var(--accent,#2f7af8);border-color:var(--accent,#2f7af8);color:#fff} +.log-container{flex:1 1 0;min-height:200px;max-height:calc(100vh - 220px);overflow-y:auto;overflow-x:hidden;background:#0d0d0f;border-left:1px solid var(--border,rgba(63,63,70,.5));border-right:1px solid var(--border,rgba(63,63,70,.5));padding:4px 0;font-family:"Cascadia Code","JetBrains Mono","Fira Code",Consolas,monospace;font-size:12px;line-height:1.65} +.log-container::-webkit-scrollbar{width:6px} +.log-container::-webkit-scrollbar-track{background:transparent} +.log-container::-webkit-scrollbar-thumb{background:rgba(255,255,255,.12);border-radius:3px} +.log-container::-webkit-scrollbar-thumb:hover{background:rgba(255,255,255,.24)} +.log-line{padding:1px 14px;color:var(--text-secondary,#a1a1aa);border-bottom:1px solid rgba(63,63,70,.18);word-break:break-all;white-space:pre-wrap;transition:background .1s} +.log-line:hover{background:rgba(255,255,255,.03)} +.log-line.log-warn{color:#f59e0b;background:rgba(245,158,11,.05)} +.log-line.log-warn:hover{background:rgba(245,158,11,.1)} +.log-line.log-error{color:#ef4444;background:rgba(239,68,68,.05)} +.log-line.log-error:hover{background:rgba(239,68,68,.1)} +.log-empty{text-align:center;color:var(--text-tertiary,#52525b);padding:48px 16px;margin:0} +.log-bottom-bar{display:flex;align-items:center;justify-content:space-between;padding:6px 16px;background:var(--surface-card,#1a1a1e);border:1px solid var(--border,rgba(63,63,70,.5));border-radius:0 0 8px 8px;flex-shrink:0} +.log-status{font-size:11px;color:var(--text-tertiary,#71717a)} +/* Sync history */ +.sync-history-list{padding:0} +.sync-history-list::-webkit-scrollbar{width:6px} +.sync-history-list::-webkit-scrollbar-track{background:transparent} +.sync-history-list::-webkit-scrollbar-thumb{background:rgba(255,255,255,.12);border-radius:3px} +.sync-record{display:flex;align-items:center;gap:10px;padding:8px 14px;border-bottom:1px solid rgba(63,63,70,.18);font-size:12px;transition:background .1s} +.sync-record:hover{background:rgba(255,255,255,.03)} +.sync-record:last-child{border-bottom:none} +.sync-time{color:var(--text-tertiary,#52525b);font-family:"Cascadia Code","JetBrains Mono",Consolas,monospace;font-size:11px;min-width:60px} +.sync-badge{padding:2px 8px;border-radius:999px;font-size:10px;font-weight:700;text-transform:uppercase} +.sync-badge-clipboard{background:rgba(99,102,241,.15);color:#818cf8} +.sync-badge-file{background:rgba(34,197,94,.15);color:#4ade80} +.sync-dir{font-weight:600;font-size:11px} +.sync-dir-sent{color:#60a5fa} +.sync-dir-received{color:#a78bfa} +.sync-detail{color:var(--text-secondary,#a1a1aa);font-size:11px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:300px} +.sync-content-type{color:#e2e8f0;font-size:11px;padding:2px 6px;background:rgba(255,255,255,.06);border-radius:4px} +.sync-target{color:#94a3b8;font-size:11px;font-family:'Cascadia Code','JetBrains Mono',Consolas,monospace} +.sync-preview{color:#cbd5e1;font-size:11px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:360px;padding:2px 8px;background:rgba(255,255,255,.04);border-radius:4px;font-family:'Cascadia Code','JetBrains Mono',Consolas,monospace} diff --git a/src/App.tsx b/src/App.tsx index 1cb3295..e850f24 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -23,6 +23,9 @@ import { loadAppState, minimizeMainWindow, openLogDirectory, + readLogLines, + readSyncHistory, + type SyncRecord, openRepositoryUrl, openUpdateReleasePage, probeLanPeer, @@ -69,6 +72,7 @@ import type { FlattenedScreen, LayoutBounds } from "./layout"; import type { AppStateSnapshot, DiagnosticInfo, + InputDebugEvent, LanPeer, LanPeerScreen, PerformanceSample, @@ -111,13 +115,17 @@ const WORKSPACE_TABS = [ { id: "layout" }, { id: "devices" }, { id: "settings" }, + { id: "logs" }, + { id: "sync" }, ] as const; type WorkspaceTab = (typeof WORKSPACE_TABS)[number]["id"]; -const CLIENT_TABS: WorkspaceTab[] = ["settings"]; +const CLIENT_TABS: WorkspaceTab[] = ["settings", "logs", "sync"]; const PERFORMANCE_SAMPLE_LIMIT = 32; const UPDATE_DISMISSED_VERSION_KEY = "mykvm:update:dismissedVersion"; +const LOG_REFRESH_INTERVAL_MS = 3000; +const LOG_MAX_LINES = 500; type UpdateStatus = | "idle" | "checking" @@ -217,6 +225,12 @@ function App() { const [isPortable, setIsPortable] = useState(false); const [autostartEnabled, setAutostartEnabled] = useState(false); const [errorMessage, setErrorMessage] = useState(null); + const [logLines, setLogLines] = useState([]); + const [logAutoRefresh, setLogAutoRefresh] = useState(true); + const [logFilter, setLogFilter] = useState<"all" | "INFO" | "WARN" | "ERROR">("all"); + const logContainerRef = useRef(null); + const [syncRecords, setSyncRecords] = useState([]); + const [syncFilter, setSyncFilter] = useState<"all" | "clipboard" | "file">("all"); const [isCapturingEdgeSwitchHotkey, setIsCapturingEdgeSwitchHotkey] = useState(false); const [capturingDirection, setCapturingDirection] = useState< @@ -638,6 +652,41 @@ function App() { machineRole === "client" && !CLIENT_TABS.includes(activeTab) ? "settings" : activeTab; + useEffect(() => { + if (currentTab !== "logs") return; + let cancelled = false; + const fetchLogs = async () => { + try { + const lines = await readLogLines(LOG_MAX_LINES); + if (!cancelled) { + setLogLines(lines); + if (logContainerRef.current) { + logContainerRef.current.scrollTop = logContainerRef.current.scrollHeight; + } + } + } catch { + // ignore + } + }; + void fetchLogs(); + if (!logAutoRefresh) return; + const timer = setInterval(() => void fetchLogs(), LOG_REFRESH_INTERVAL_MS); + return () => { cancelled = true; clearInterval(timer); }; + }, [currentTab, logAutoRefresh]); + + useEffect(() => { + if (currentTab !== "sync") return; + let cancelled = false; + const fetchSync = async () => { + try { + const records = await readSyncHistory(200); + if (!cancelled) setSyncRecords(records); + } catch { /* ignore */ } + }; + void fetchSync(); + const timer = setInterval(() => void fetchSync(), 5000); + return () => { cancelled = true; clearInterval(timer); }; + }, [currentTab]); const localPlatform = runtime?.discovery.localPeer.platform.toLowerCase() ?? navigator.platform.toLowerCase(); @@ -896,6 +945,20 @@ function App() { } } + function formatInputDebugEvent(event: InputDebugEvent) { + const relative = + event.relativeX != null && event.relativeY != null + ? `rel(${event.relativeX}, ${event.relativeY})` + : "rel(-)"; + const absolute = + event.absoluteX != null && event.absoluteY != null + ? `abs(${event.absoluteX}, ${event.absoluteY})` + : "abs(-)"; + const pipe = + event.pipeAvailable == null ? "pipe --" : `pipe ${event.pipeAvailable ? "yes" : "no"}`; + return `${event.eventType} ${relative} ${absolute} ${event.desktop} ${event.route} ${pipe} ${event.result}`; + } + async function persistLayout(nextLayout: LayoutState) { setIsSaving(true); try { @@ -3020,6 +3083,51 @@ function App() { {diagnosticInfo.networkHint} {diagnosticInfo.firewallHint}

) : null} + {diagnosticInfo ? ( +
+

{ui.settings.inputDebug}

+
+
+
{ui.settings.inputDebugStatus}
+
{diagnosticInfo.inputDebug.status}
+
+
+
{ui.settings.inputDebugRoute}
+
{diagnosticInfo.inputDebug.lastRoute ?? "--"}
+
+
+
{ui.settings.inputDebugFailure}
+
+ {diagnosticInfo.inputDebug.latestFailure ?? "--"} +
+
+
+
{ui.settings.inputDebugEvents}
+
{diagnosticInfo.inputDebug.recentEventCount}
+
+
+ {diagnosticInfo.inputDebug.events.length ? ( +
+ {diagnosticInfo.inputDebug.events + .slice() + .reverse() + .slice(0, 8) + .map((event) => ( +

+ {formatInputDebugEvent(event)} +

+ ))} +
+ ) : ( +

+ {ui.settings.inputDebugEmpty} +

+ )} +
+ ) : null}
+ ))} +
+ + + +
+ {syncRecords.filter((r) => syncFilter === "all" || r.kind === syncFilter).length === 0 ? ( +

{ui.syncHistory.noRecords}

+ ) : ( + syncRecords + .filter((r) => syncFilter === "all" || r.kind === syncFilter) + .slice() + .reverse() + .map((rec, i) => ( +
+ {rec.timestamp} + + {rec.kind === "clipboard" ? ui.syncHistory.clipboard : ui.syncHistory.file} + + + {rec.direction === "sent" ? ui.syncHistory.sent : ui.syncHistory.received} + + {rec.contentType === "text" ? 📋 文本 : null} + {rec.contentType === "image" ? 🖼️ 图片 : null} + {rec.contentType === "file" ? 📁 文件 : null} + {rec.target ? → {rec.target} : null} + {rec.preview ? {rec.preview} : null} + {rec.detail ? {rec.detail} : null} +
+ )) + )} +
+
+ {ui.syncHistory.subtitle} +
+ ) : null} + + {currentTab === "logs" ? ( +
+
+
+

{ui.logs.title}

+ {logLines.length} {ui.logs.lineCount} +
+
+
+ {(["all", "INFO", "WARN", "ERROR"] as const).map((lv) => ( + + ))} +
+
+ + + +
+
+
+
+ {logLines.length === 0 ? ( +

{ui.logs.noLogs}

+ ) : ( + logLines + .filter((line) => logFilter === "all" || line.includes(`[${logFilter}]`)) + .map((line, i) => ( +
+ {line} +
+ )) + )} +
+
+ + {logAutoRefresh ? `${ui.logs.autoRefresh} ${ui.common.enabled}` : `${ui.logs.autoRefresh} ${ui.common.disabled}`} + + +
+
+ ) : null} + {serverPairing ? (
diff --git a/src/constants.ts b/src/constants.ts index 8814728..1b399ef 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -1,3 +1,3 @@ export const APP_VERSION = __APP_VERSION__; -export const REPOSITORY_URL = "https://github.com/XxMinor/mykvm"; +export const REPOSITORY_URL = "https://github.com/aceleisureman/mykvm"; export const RELEASES_URL = `${REPOSITORY_URL}/releases/latest`; diff --git a/src/desktopApi.ts b/src/desktopApi.ts index 2ac66c6..37f1567 100644 --- a/src/desktopApi.ts +++ b/src/desktopApi.ts @@ -91,7 +91,7 @@ const FALLBACK_RUNTIME: RuntimeStatus = { isPrimary: true, }, ], - appVersion: '0.1.0', + appVersion: '0.9.8', lastSeenMs: Date.now(), }, peers: [], @@ -210,7 +210,7 @@ export async function readDiagnosticInfo(): Promise { if (!isTauri()) { return { report: 'Desktop diagnostics are available only in the Tauri desktop runtime.', - appVersion: '0.1.0', + appVersion: '0.9.8', platform: navigator.platform, role: defaultLayout.machineRole, runtimeStarted: browserRuntime.started, @@ -224,12 +224,46 @@ export async function readDiagnosticInfo(): Promise { configDir: '', networkHint: 'Desktop diagnostics are available only in the Tauri desktop runtime.', firewallHint: 'Desktop diagnostics are available only in the Tauri desktop runtime.', + inputDebug: { + enabled: false, + status: 'idle', + latestFailure: null, + lastRoute: null, + recentEventCount: 0, + events: [], + }, } } return invoke('read_diagnostic_info') } +export interface SyncRecord { + timestamp: string + kind: 'clipboard' | 'file' + direction: 'sent' | 'received' + target: string + contentType: string + preview: string + detail: string +} + +export async function readSyncHistory(count?: number): Promise { + if (!isTauri()) { + return [] + } + + return invoke('read_sync_history', { count: count ?? 100 }) +} + +export async function readLogLines(count?: number): Promise { + if (!isTauri()) { + return ['[browser] Log only available in desktop'] + } + + return invoke('read_log_lines', { count: count ?? 200 }) +} + export async function openLogDirectory(): Promise { if (!isTauri()) { return diff --git a/src/i18n.ts b/src/i18n.ts index 12993b9..590e765 100644 --- a/src/i18n.ts +++ b/src/i18n.ts @@ -4,6 +4,8 @@ export const TEXT = { layout: "布局", devices: "设备", settings: "设置", + logs: "日志", + sync: "同步记录", }, roles: { server: "服务端", @@ -90,6 +92,12 @@ export const TEXT = { diagnosticsCopied: "诊断信息已复制。", copyDiagnostics: "复制诊断信息", openLogDirectory: "打开日志目录", + inputDebug: "输入调试", + inputDebugStatus: "状态", + inputDebugRoute: "最近路由", + inputDebugFailure: "最近失败", + inputDebugEvents: "最近事件", + inputDebugEmpty: "暂无输入调试记录。", name: "名称", address: "地址", ports: "端口", @@ -175,6 +183,37 @@ export const TEXT = { clipboardPackets: "剪贴板包", noSamples: "开启后开始采样", }, + + logs: { + eyebrow: "Logs", + title: "运行日志", + subtitle: "实时查看应用日志,方便排查连接和传输问题。", + autoRefresh: "自动刷新", + refresh: "刷新", + clear: "清空", + noLogs: "暂无日志记录。", + lineCount: "行数", + scrollToBottom: "滚动到底部", + openLogDir: "打开日志目录", + levelAll: "全部", + levelInfo: "INFO", + levelWarn: "WARN", + levelError: "ERROR", + }, + syncHistory: { + title: "同步记录", + subtitle: "剪贴板和文件同步的历史记录。", + clipboard: "剪贴板", + file: "文件", + sent: "发送", + received: "接收", + noRecords: "暂无同步记录。", + filterAll: "全部", + filterClipboard: "剪贴板", + filterFile: "文件", + to: "发送到", + refresh: "刷新", + }, layout: { eyebrow: "Device layout", title: "显示器布局", @@ -250,6 +289,8 @@ export const TEXT = { layout: "Layout", devices: "Devices", settings: "Settings", + logs: "Logs", + sync: "Sync", }, roles: { server: "Server", @@ -338,6 +379,12 @@ export const TEXT = { diagnosticsCopied: "Diagnostics copied.", copyDiagnostics: "Copy Diagnostics", openLogDirectory: "Open Log Folder", + inputDebug: "Input Debug", + inputDebugStatus: "Status", + inputDebugRoute: "Last Route", + inputDebugFailure: "Latest Failure", + inputDebugEvents: "Recent Events", + inputDebugEmpty: "No input debug events yet.", name: "Name", address: "Address", ports: "Actual Ports", @@ -423,6 +470,37 @@ export const TEXT = { clipboardPackets: "Clipboard packets", noSamples: "Enable monitoring to sample", }, + + logs: { + eyebrow: "Logs", + title: "Application Logs", + subtitle: "View real-time logs for diagnosing connection and transport issues.", + autoRefresh: "Auto Refresh", + refresh: "Refresh", + clear: "Clear", + noLogs: "No log records yet.", + lineCount: "Lines", + scrollToBottom: "Scroll to Bottom", + openLogDir: "Open Log Folder", + levelAll: "All", + levelInfo: "INFO", + levelWarn: "WARN", + levelError: "ERROR", + }, + syncHistory: { + title: "Sync History", + subtitle: "Clipboard and file sync history records.", + clipboard: "Clipboard", + file: "File", + sent: "Sent", + received: "Received", + noRecords: "No sync records yet.", + filterAll: "All", + filterClipboard: "Clipboard", + filterFile: "File", + to: "Sent to", + refresh: "Refresh", + }, layout: { eyebrow: "Device layout", title: "Display Layout", diff --git a/src/runtime.ts b/src/runtime.ts index 18b3c46..454558a 100644 --- a/src/runtime.ts +++ b/src/runtime.ts @@ -82,6 +82,31 @@ export interface DiagnosticDevice { sameSubnet?: boolean | null } +export interface InputDebugEvent { + timestampMs: number + controllerId: string + eventType: string + screenId: string + relativeX?: number | null + relativeY?: number | null + absoluteX?: number | null + absoluteY?: number | null + desktop: string + route: string + pipeAvailable?: boolean | null + result: string + detail: string +} + +export interface InputDebugInfo { + enabled: boolean + status: string + latestFailure?: string | null + lastRoute?: string | null + recentEventCount: number + events: InputDebugEvent[] +} + export interface DiagnosticInfo { report: string appVersion: string @@ -98,6 +123,7 @@ export interface DiagnosticInfo { configDir: string networkHint: string firewallHint: string + inputDebug: InputDebugInfo } export interface PrivilegeStatus { diff --git a/vite.config.mjs b/vite.config.mjs index 9a40ac9..f8c9b8d 100644 --- a/vite.config.mjs +++ b/vite.config.mjs @@ -15,7 +15,7 @@ export default defineConfig({ }, clearScreen: false, server: { - port: 5173, + port: 5174, strictPort: true, }, })