diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b6515e726..9e66c0114 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,7 +3,8 @@ name: CI # 多平台跨语言质量门禁。release-tauri.yml 是发版流水线(仅打包构建),这里负责 # 在合并前快速验证两件事: # 1. 全部前端/契约测试与 vite bundle 通过(捕获行为、合同及跨 locale 类型 drift) -# 2. Tauri 后端在 macOS / Windows、移动端在 Android 编译;Linux 只测试共享 core。 +# 2. Tauri 后端在 macOS / Windows、移动端在 Android 编译;Linux 同时验证共享 Core +# 与独立 egui host,且不拉取本地 Qwen ASR 或 Tauri/WebKit 依赖。 # 跑 build-mac.sh / windows-package-msvc.ps1 / Tauri bundle 太重;只跑轻量 cargo check + vite build。 on: @@ -166,8 +167,10 @@ jobs: --no-daemon linux-core-contract: - name: Linux core tests - runs-on: ubuntu-22.04 + name: Linux core and egui host tests + # cpal's PipeWire backend needs the PipeWire/libspa ABI shipped by 24.04. + # 22.04 headers fail to compile libspa 0.10.x (missing spa_meta_first etc.). + runs-on: ubuntu-24.04 defaults: run: working-directory: openless-all/app @@ -182,8 +185,42 @@ jobs: with: workspaces: 'openless-all/app -> target' + - name: Install Linux egui build dependencies + run: | + sudo apt-get update + sudo apt-get install -y \ + build-essential \ + libasound2-dev \ + libdbus-1-dev \ + libpipewire-0.3-dev \ + libssl-dev \ + libwayland-dev \ + libx11-dev \ + libxkbcommon-dev \ + pkg-config \ + ripgrep + - name: Test Core - run: cargo test --locked -p openless-core + run: | + cargo test --locked -p openless-core + cargo clippy --locked -p openless-core --all-targets + + - name: Test Linux egui host without local Qwen ASR + run: | + cargo test --locked -p openless-linux-egui --all-targets + cargo check --locked -p openless-linux-egui --all-targets + node scripts/linux-egui-tauri-free-contract.test.mjs + node scripts/linux-egui-release-contract.test.mjs + + - name: Enforce Linux dependency boundaries + shell: pwsh + run: | + ./scripts/check-core-deps.ps1 + ./scripts/check-core-deps.ps1 openless-linux-egui + ./scripts/check-core-secret-surface.ps1 + ./scripts/check-core-test-isolation.ps1 + ./scripts/check-core-runtime-seam.ps1 + ./scripts/check-linux-public-surface.ps1 - name: Test remote input TLS identity and Unix key permissions run: cargo test --locked --manifest-path src-tauri/backend-tests/Cargo.toml --test remote_tls diff --git a/.github/workflows/release-linux-egui.yml b/.github/workflows/release-linux-egui.yml index f0bae627d..192724431 100644 --- a/.github/workflows/release-linux-egui.yml +++ b/.github/workflows/release-linux-egui.yml @@ -15,9 +15,6 @@ on: release_tag: required: false type: string - secrets: - LINUX_EGUI_MINISIGN_SECRET_KEY: - required: false permissions: contents: write @@ -41,38 +38,21 @@ jobs: build-essential \ cmake \ desktop-file-utils \ - extra-cmake-modules \ fcitx5-modules-dev \ - file \ - fuse \ libasound2-dev \ libdbus-1-dev \ + libpipewire-0.3-dev \ + libpulse-dev \ libfcitx5config-dev \ libfcitx5core-dev \ libfcitx5utils-dev \ - libopenblas-dev \ libssl-dev \ libwayland-dev \ libx11-dev \ libxkbcommon-dev \ - patchelf \ pkg-config \ ripgrep \ rpm \ - ruby-dev \ - wget - sudo gem install --no-document fpm -v 1.16.0 - - - name: Install appimagetool - env: - APPIMAGETOOL_SHA256: b90f4a8b18967545fda78a445b27680a1642f1ef9488ced28b65398f2be7add2 - run: | - wget -q https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-x86_64.AppImage \ - -O /tmp/appimagetool - echo "$APPIMAGETOOL_SHA256 /tmp/appimagetool" | sha256sum --check --strict - chmod +x /tmp/appimagetool - sudo install -m 0755 /tmp/appimagetool /usr/local/bin/appimagetool - file /usr/local/bin/appimagetool | grep -q ELF - uses: dtolnay/rust-toolchain@stable with: @@ -92,24 +72,15 @@ jobs: test -s build/libopenless.so test -s build/openless.conf - - name: Checkout pinned Qwen ASR runtime source - run: git submodule update --init --depth 1 -- openless-all/app/src-tauri/vendor/qwen-asr - - - name: Build portable Qwen ASR runtime - working-directory: openless-all/app/src-tauri/vendor/qwen-asr - run: | - make blas CFLAGS_BASE="-Wall -Wextra -O3 -ffast-math -mtune=generic" - test -x qwen_asr - ./qwen_asr --help >/dev/null 2>&1 - - name: Verify framework-independent Linux contract working-directory: openless-all/app shell: pwsh run: | cargo test --locked -p openless-core - cargo clippy --locked -p openless-core --all-targets -- -D warnings + cargo clippy --locked -p openless-core --all-targets cargo test --locked -p openless-linux-egui --all-targets - cargo check --locked -p openless-linux-egui --all-targets + node scripts/linux-egui-tauri-free-contract.test.mjs + node scripts/linux-egui-release-contract.test.mjs ./scripts/check-core-deps.ps1 ./scripts/check-core-deps.ps1 openless-linux-egui ./scripts/check-core-secret-surface.ps1 @@ -127,9 +98,9 @@ jobs: exit 0 fi case "$RELEASE_TAG" in - v*-tauri) ;; + v*) ;; *) - echo "::error::release_tag must name an existing v*-tauri release" + echo "::error::release_tag must name an existing v* release" exit 1 ;; esac @@ -157,17 +128,16 @@ jobs: VERSION=${RELEASE_TAG#v} VERSION=${VERSION%-tauri} else - VERSION=$(sed -n 's/^version = "\([^"]*\)"/\1/p' src-tauri/Cargo.toml | head -1) + VERSION=$(node -p "require('./package.json').version") fi test -n "$VERSION" echo "version=$VERSION" >> "$GITHUB_OUTPUT" - - name: Package deb, rpm, and AppImage without Tauri + - name: Package deb and rpm without Tauri working-directory: openless-all/app env: OPENLESS_LINUX_VERSION: ${{ steps.version.outputs.version }} OPENLESS_LINUX_ARCH: x86_64 - APPIMAGE_EXTRACT_AND_RUN: '1' run: bash scripts/package-linux-egui.sh - name: Verify package contents and ELF dependencies @@ -176,74 +146,31 @@ jobs: OUTPUT=target/linux-egui-packages test "$(find "$OUTPUT" -maxdepth 1 -name '*.deb' | wc -l)" -eq 1 test "$(find "$OUTPUT" -maxdepth 1 -name '*.rpm' | wc -l)" -eq 1 - test "$(find "$OUTPUT" -maxdepth 1 -name '*.AppImage' | wc -l)" -eq 1 + test "$(find "$OUTPUT" -maxdepth 1 -name '*.AppImage' | wc -l)" -eq 0 ! ldd target/release/openless-linux-egui | grep -q 'not found' ! ldd target/release/openless-linux-egui | grep -Eqi 'webkit|wry|tauri' - ! ldd src-tauri/vendor/qwen-asr/qwen_asr | grep -q 'not found' dpkg-deb -c "$OUTPUT"/*.deb | grep -q 'usr/bin/openless' dpkg-deb -c "$OUTPUT"/*.deb | grep -q 'fcitx5/libopenless.so' - dpkg-deb -c "$OUTPUT"/*.deb | grep -q 'usr/lib/openless/resources/qwen-asr/qwen_asr' rpm -qlp "$OUTPUT"/*.rpm | grep -q '/usr/bin/openless' rpm -qlp "$OUTPUT"/*.rpm | grep -q '/usr/lib64/fcitx5/libopenless.so' - rpm -qlp "$OUTPUT"/*.rpm | grep -q '/usr/lib/openless/resources/qwen-asr/qwen_asr' - "$OUTPUT"/*.AppImage --appimage-extract >/dev/null - test -x squashfs-root/usr/bin/openless - test -s squashfs-root/usr/lib/openless/resources/linux-fcitx5-plugin/libopenless.so - test -x squashfs-root/usr/lib/openless/resources/qwen-asr/qwen_asr - ! ldd squashfs-root/usr/lib/openless/resources/qwen-asr/qwen_asr | grep -q 'not found' - squashfs-root/usr/lib/openless/resources/qwen-asr/qwen_asr --help >/dev/null 2>&1 - rm -rf squashfs-root - - name: Sign AppImage and write independent updater manifest + - name: Generate release checksums working-directory: openless-all/app - env: - MINISIGN_SECRET: ${{ secrets.LINUX_EGUI_MINISIGN_SECRET_KEY }} - VERSION: ${{ steps.version.outputs.version }} run: | + set -euo pipefail OUTPUT=target/linux-egui-packages - APPIMAGE=$(find "$OUTPUT" -maxdepth 1 -name '*.AppImage' -print -quit) - if [ -n "${RELEASE_TAG:-}" ] && [ -z "${MINISIGN_SECRET:-}" ]; then - echo "::error::LINUX_EGUI_MINISIGN_SECRET_KEY is required for release upload" - exit 1 - fi - if [ -n "${MINISIGN_SECRET:-}" ]; then - if ! command -v minisign >/dev/null 2>&1; then - cargo install --locked --version 0.9.1 minisign - fi - printf '%s' "$MINISIGN_SECRET" > "$RUNNER_TEMP/linux-egui.minisign.key" - minisign -S -s "$RUNNER_TEMP/linux-egui.minisign.key" -m "$APPIMAGE" -x "$APPIMAGE.minisig" - fi - SHA256=$(sha256sum "$APPIMAGE" | cut -d' ' -f1) - ASSET=$(basename "$APPIMAGE") - RELEASE_REPOSITORY="${GITHUB_REPOSITORY:-Open-Less/openless}" - SIGNATURE=null - if [ -f "$APPIMAGE.minisig" ]; then - SIGNATURE=$(base64 -w0 "$APPIMAGE.minisig" | jq -R .) - fi - jq -n \ - --arg version "$VERSION" \ - --arg url "https://github.com/$RELEASE_REPOSITORY/releases/download/${RELEASE_TAG:-manual}/$ASSET" \ - --arg sha256 "$SHA256" \ - --argjson signature "$SIGNATURE" \ - '{schemaVersion:1, host:"linux-egui", arch:"x86_64", version:$version, url:$url, sha256:$sha256, minisign:$signature}' \ - > "$OUTPUT/latest-linux-egui-x86_64.json" + test "$(find "$OUTPUT" -maxdepth 1 -name '*.deb' | wc -l)" -eq 1 + test "$(find "$OUTPUT" -maxdepth 1 -name '*.rpm' | wc -l)" -eq 1 + test "$(find "$OUTPUT" -maxdepth 1 -name '*.AppImage' | wc -l)" -eq 0 + ( cd "$OUTPUT" && sha256sum ./*.deb ./*.rpm > SHA256SUMS ) + test "$(wc -l < "$OUTPUT/SHA256SUMS")" -eq 2 + cat "$OUTPUT/SHA256SUMS" - - name: Verify Linux updater manifest matches the artifact + - name: Verify Linux release checksums working-directory: openless-all/app run: | - set -euo pipefail - OUTPUT=target/linux-egui-packages - APPIMAGE=$(find "$OUTPUT" -maxdepth 1 -name '*.AppImage' -print -quit) - MANIFEST="$OUTPUT/latest-linux-egui-x86_64.json" - test -s "$APPIMAGE" - test -s "$MANIFEST" - SHA256=$(sha256sum "$APPIMAGE" | cut -d' ' -f1) - RELEASE_REPOSITORY="${GITHUB_REPOSITORY:-Open-Less/openless}" - jq -e \ - --arg sha256 "$SHA256" \ - --arg repository "$RELEASE_REPOSITORY" \ - '.schemaVersion == 1 and .host == "linux-egui" and .arch == "x86_64" and .sha256 == $sha256 and (.url | startswith("https://github.com/" + $repository + "/releases/download/"))' \ - "$MANIFEST" >/dev/null + test -s target/linux-egui-packages/SHA256SUMS + ( cd target/linux-egui-packages && sha256sum -c SHA256SUMS >/dev/null ) - name: Upload Linux egui workflow artifacts uses: actions/upload-artifact@v4 diff --git a/.gitignore b/.gitignore index be8d5c105..bd4178cd1 100644 --- a/.gitignore +++ b/.gitignore @@ -34,6 +34,9 @@ video-materials/ node_modules/ dist/ target/ +# Explicitly ignore local cache symlinks as well as generated target directories. +/openless-all/app/target +/openless-all/egui-frontend/target # 本地从 CI / Actions 下载的 APK 及解压目录(非源码) ci-artifacts/ *.apk diff --git a/.gitmodules b/.gitmodules index 18862cfed..b9c2c02ae 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,5 +1,5 @@ -[submodule "openless-all/app/src-tauri/vendor/qwen-asr"] - path = openless-all/app/src-tauri/vendor/qwen-asr +[submodule "openless-all/app/vendor/qwen-asr"] + path = openless-all/app/vendor/qwen-asr url = https://github.com/Open-Less/qwen-asr.git [submodule "openless-all/app/src-tauri/vendor/qwen3-asr-rs"] path = openless-all/app/src-tauri/vendor/qwen3-asr-rs diff --git a/docs/linux-egui-tauri2-parity.md b/docs/linux-egui-tauri2-parity.md new file mode 100644 index 000000000..3d489c9de --- /dev/null +++ b/docs/linux-egui-tauri2-parity.md @@ -0,0 +1,82 @@ +# Linux egui / Tauri 2 parity tracker + +This local stack is based on PR #1019 head `5f668b1d`. Linux ships one +`openless-linux-egui` executable and does not link Tauri, Wry, or WebKitGTK. +Windows, macOS, and Android remain on Tauri. + +## Implemented on the native Core 2.0 path + +- eframe/egui 0.33.3 shell, system CJK fallback, single instance, minimized + startup, close-to-tray, safe shutdown, XDG autostart, notifications, external + URLs, file dialogs, file logging, and diagnostic-log export +- fcitx5 dictation, QA, selection polish, translation, style switching, main + window, style-pack direct, and applicable Coding Agent hotkeys; settings use + strict collision checks and survive fcitx5 restart +- CPAL recording with canonical WAV archives, Core retention policy, history + playback/export and failed-session retranscription +- Native `mute_during_recording` (PipeWire `wpctl`/PulseAudio `pactl`) with + guaranteed sink restore by RAII across stop/cancel/error/drop/shutdown +- Native recording start/stop audio cues synthesized to the default sink on a + worker thread (never blocking the egui frame), gated by `audio_cue_on_record` + and muted-aware (`audio_mute`/`audio_cue` modules) +- Core-backed history search/delete/clear/copy/re-polish/retranscribe, vocabulary, + pending corrections, shared vocabulary presets, correction rules, and complete + style-pack CRUD/reset/prompt diagnostics/ZIP import-export/direct hotkeys +- provider channel CRUD/order/enable/activation, credential metadata, endpoint, + model listing, validation, and revision-aware settings conflict merging +- Qwen catalog/download/cancel/delete/activate/prepare/preload/release/test and + progress; Remote Input TLS service, URLs, PIN, locale, connection count, and + error events +- Marketplace list/detail/install/download/upload/update/delete, likes, authored + packs, GitHub device flow, polling, cancel, and logout +- dictation, QA text/voice, selection polish preview/confirm/cancel/revert, and + Less Computer text/voice/stream/tool approval/cancel through high-level Core APIs +- #997 native QA/selection/capsule popup design: same-executable re-entry, + versioned serde JSONL, Markdown, drag/Esc, QA microphone and Enter submit, + session/sequence/kind guards, nonblocking pipes, crash restart, and snapshot replay +- stable/beta AppImage checks, delayed/hourly/manual scheduling, byte progress, + SHA-256 and pinned minisign verification, same-directory fsync and atomic replace; + deb/rpm use the release page +- Remote Input assets, Qwen vendor files, shared icons, version parsing, packaging, + and release workflow are independent of `src-tauri` + +## Automated evidence + +- `cargo test -p openless-core --locked` +- `cargo test -p openless-linux-egui --locked` +- `cargo clippy --locked -p openless-linux-egui --all-targets -- -D warnings` +- PR #1019 Core/public-surface/dependency contract scripts +- fcitx5 C++ build plus `input_target_contract` +- `cargo tree` and release ELF `ldd` checks for Tauri/Wry/WebKitGTK +- Linux Tauri-free source, packaging, workflow, production-mock, capability, popup, + settings-conflict, updater-validation, lifecycle, and staged-file gates + +## Deliberate limits and device evidence still required + +- Linux supports fcitx5 only; there is no IBus or global-hotkey fallback. +- Selection Voice remains hidden because the Linux production target/intent adapter + is not implemented. It must not be advertised through capabilities. Selection + polish and QA remain available. +- Generic Qwen is the Linux local runtime. Windows Foundry and Apple MLX are not + Linux parity requirements. +- Foreground-application identity and native post-insertion edit observation + are explicit `Unsupported` on Linux: fcitx5 exposes surrounding text but no + reliable app/control identity across X11/Wayland, PRIMARY cannot prove an + original selection, and there is deliberately no IBus/global-hotkey fallback. + The factory keeps Core's Noop HostContext/EditObservation adapters (reporting + `source_app = None`) instead of faking edits. These are L02/L03 follow-ups, + not simulated UI data or false capabilities. +- Mute/restore and start/stop cues are implemented (`audio_mute`/`audio_cue`), + but the cpal cue-playback and real sink mute/restore paths still require + X11/Wayland device runs before being recorded as verified; a Linux output + stream is a PipeWire/KDE sink-input, so device evidence must confirm the cue + does not disturb the session or trigger unexpected volume OSD. +- X11 and Wayland device runs are still required for focus, Unicode insertion, + popup positioning, tray, fcitx5 reload/rebind, microphone unplug/recovery, + Secret Service, real phone Remote Input, real Qwen inference, and signed + AppImage install/rollback. Ignored hardware tests or a green build are not + recorded as device proof. + +The automated scope is complete only when every command above passes at the +current PR #1019 head. The device-only rows remain “implemented, awaiting device +evidence” or “explicit unsupported” and must not be described as verified. diff --git a/openless-all/app/Cargo.lock b/openless-all/app/Cargo.lock index a4b807be9..2b720f588 100644 --- a/openless-all/app/Cargo.lock +++ b/openless-all/app/Cargo.lock @@ -18,6 +18,96 @@ version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" +[[package]] +name = "accesskit" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf203f9d3bd8f29f98833d1fbef628df18f759248a547e7e01cfbf63cda36a99" + +[[package]] +name = "accesskit_atspi_common" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "890d241cf51fc784f0ac5ac34dfc847421f8d39da6c7c91a0fcc987db62a8267" +dependencies = [ + "accesskit", + "accesskit_consumer", + "atspi-common", + "serde", + "thiserror 1.0.69", + "zvariant 5.15.0", +] + +[[package]] +name = "accesskit_consumer" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db81010a6895d8707f9072e6ce98070579b43b717193d2614014abd5cb17dd43" +dependencies = [ + "accesskit", + "hashbrown 0.15.5", +] + +[[package]] +name = "accesskit_macos" +version = "0.22.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0089e5c0ac0ca281e13ea374773898d9354cc28d15af9f0f7394d44a495b575" +dependencies = [ + "accesskit", + "accesskit_consumer", + "hashbrown 0.15.5", + "objc2 0.5.2", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "accesskit_unix" +version = "0.17.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "301e55b39cfc15d9c48943ce5f572204a551646700d0e8efa424585f94fec528" +dependencies = [ + "accesskit", + "accesskit_atspi_common", + "async-channel", + "async-executor", + "async-task", + "atspi", + "futures-lite", + "futures-util", + "serde", + "zbus 5.19.0", +] + +[[package]] +name = "accesskit_windows" +version = "0.29.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2d63dd5041e49c363d83f5419a896ecb074d309c414036f616dc0b04faca971" +dependencies = [ + "accesskit", + "accesskit_consumer", + "hashbrown 0.15.5", + "static_assertions", + "windows 0.61.3", + "windows-core 0.61.2", +] + +[[package]] +name = "accesskit_winit" +version = "0.29.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8cfabe59d0eaca7412bfb1f70198dd31e3b0496fee7e15b066f9c36a1a140a0" +dependencies = [ + "accesskit", + "accesskit_macos", + "accesskit_unix", + "accesskit_windows", + "raw-window-handle", + "winit", +] + [[package]] name = "adler2" version = "2.0.1" @@ -59,21 +149,21 @@ dependencies = [ [[package]] name = "alsa" -version = "0.9.1" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed7572b7ba83a31e20d1b48970ee402d2e3e0537dcfe0a3ff4d6eb7508617d43" +checksum = "812947049edcd670a82cd5c73c3661d2e58468577ba8489de58e1a73c04cbd5d" dependencies = [ "alsa-sys", - "bitflags 2.13.1", + "bitflags 2.13.2", "cfg-if", "libc", ] [[package]] name = "alsa-sys" -version = "0.3.1" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db8fee663d06c4e303404ef5f40488a53e062f89ba8bfed81f42325aafad1527" +checksum = "ad7569085a265dd3f607ebecce7458eaab2132a84393534c95b18dcbc3f31e04" dependencies = [ "libc", "pkg-config", @@ -86,14 +176,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0f2a1bb052857d5dd49572219344a7332b31b76405648eabac5bc68978251bcd" dependencies = [ "android-properties", - "bitflags 2.13.1", + "bitflags 2.13.2", "cc", - "jni 0.22.4", + "jni", "libc", "log", - "ndk 0.9.0", + "ndk", "ndk-context", - "ndk-sys 0.6.0+11769913", + "ndk-sys", "num_enum", "thiserror 2.0.20", ] @@ -106,13 +196,29 @@ checksum = "fc7eb209b1518d6bb87b283c20095f5228ecda460da70b44f0802523dea6da04" [[package]] name = "android_system_properties" -version = "0.1.5" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" dependencies = [ "libc", ] +[[package]] +name = "annotate-snippets" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "710e8eae58854cdc1790fcb56cca04d712a17be849eeb81da2a724bf4bae2bc4" +dependencies = [ + "anstyle", + "unicode-width", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + [[package]] name = "anyhow" version = "1.0.104" @@ -145,7 +251,6 @@ dependencies = [ "parking_lot", "percent-encoding", "windows-sys 0.60.2", - "wl-clipboard-rs", "x11rb", ] @@ -162,12 +267,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "175571dd1d178ced59193a6fc02dde1b972eb0bc56c892cde9beeceac5bf0f6b" [[package]] -name = "ash" -version = "0.38.0+1.3.281" +name = "ashpd" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bb44936d800fea8f016d7f2311c6a4f97aebd5dc86f09906139ec848cf3a46f" +checksum = "d2f3f79755c74fd155000314eb349864caa787c6592eace6c6882dad873d9c39" dependencies = [ - "libloading", + "enumflags2", + "futures-channel", + "futures-util", + "rand 0.9.5", + "raw-window-handle", + "serde", + "serde_repr", + "tokio", + "url", + "zbus 5.19.0", ] [[package]] @@ -233,6 +347,20 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "async-executor" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + [[package]] name = "async-io" version = "2.6.0" @@ -323,7 +451,7 @@ checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" dependencies = [ "proc-macro2", "quote", - "syn 3.0.4", + "syn 3.0.5", ] [[package]] @@ -332,6 +460,56 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" +[[package]] +name = "atspi" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c83247582e7508838caf5f316c00791eee0e15c0bf743e6880585b867e16815c" +dependencies = [ + "atspi-common", + "atspi-connection", + "atspi-proxies", +] + +[[package]] +name = "atspi-common" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33dfc05e7cdf90988a197803bf24f5788f94f7c94a69efa95683e8ffe76cfdfb" +dependencies = [ + "enumflags2", + "serde", + "static_assertions", + "zbus 5.19.0", + "zbus-lockstep", + "zbus-lockstep-macros", + "zbus_names 4.3.4", + "zvariant 5.15.0", +] + +[[package]] +name = "atspi-connection" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4193d51303d8332304056ae0004714256b46b6635a5c556109b319c0d3784938" +dependencies = [ + "atspi-common", + "atspi-proxies", + "futures-lite", + "zbus 5.19.0", +] + +[[package]] +name = "atspi-proxies" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2eebcb9e7e76f26d0bcfd6f0295e1cd1e6f33bedbc5698a971db8dc43d7751c" +dependencies = [ + "atspi-common", + "serde", + "zbus 5.19.0", +] + [[package]] name = "autocfg" version = "1.5.1" @@ -403,7 +581,8 @@ version = "0.72.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" dependencies = [ - "bitflags 2.13.1", + "annotate-snippets", + "bitflags 2.13.2", "cexpr", "clang-sys", "itertools", @@ -438,18 +617,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" -dependencies = [ - "serde_core", -] - -[[package]] -name = "block" -version = "0.1.6" +version = "2.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" [[package]] name = "block-buffer" @@ -478,11 +648,20 @@ dependencies = [ "objc2 0.5.2", ] +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2 0.6.4", +] + [[package]] name = "blocking" -version = "1.6.2" +version = "1.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" +checksum = "a70e4329df6cb94385eed412ec92375c3cdd8a6e502493d1229b6414e4036dfa" dependencies = [ "async-channel", "async-task", @@ -517,7 +696,7 @@ checksum = "46d07918caa9eeaaf06b7873925c53a61daac173539b4f7715090745e44e4e69" dependencies = [ "proc-macro2", "quote", - "syn 3.0.4", + "syn 3.0.5", ] [[package]] @@ -537,7 +716,7 @@ checksum = "fc0e56a716f1e132ff6bf4bdac1c944a3fcdc1cae65f70a4a2a1ac3b401d2d1f" dependencies = [ "proc-macro2", "quote", - "syn 3.0.4", + "syn 3.0.5", ] [[package]] @@ -593,7 +772,7 @@ version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b99da2f8558ca23c71f4fd15dc57c906239752dd27ff3c00a1d56b685b7cbfec" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "log", "polling", "rustix 0.38.44", @@ -607,7 +786,7 @@ version = "0.14.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4dbf9978365bac10f54d1d4b04f7ce4427e51f71d61f2fe15e3fed5166474df7" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "polling", "rustix 1.1.4", "slab", @@ -649,9 +828,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.4.4" +version = "1.4.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ad534f4357a5264cce5019c989cf66a4f0dc4e0d1b1d15f8aacec0ff7360273" +checksum = "005ec2760ca554fae18df7a11195552ec576cd665632a881bc011d5bb2fd4d80" dependencies = [ "find-msvc-tools", "jobserver", @@ -659,12 +838,6 @@ dependencies = [ "shlex 2.0.1", ] -[[package]] -name = "cesu8" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" - [[package]] name = "cexpr" version = "0.6.0" @@ -674,6 +847,16 @@ dependencies = [ "nom 7.1.3", ] +[[package]] +name = "cfg-expr" +version = "0.20.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe4ece8474b5f766c63426647e7b4b316b67431ade1036a8313cee24a03ae917" +dependencies = [ + "smallvec", + "target-lexicon", +] + [[package]] name = "cfg-if" version = "1.0.4" @@ -714,7 +897,7 @@ checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" dependencies = [ "iana-time-zone", "num-traits", - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -729,9 +912,9 @@ dependencies = [ [[package]] name = "clang-sys" -version = "1.8.1" +version = "1.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" +checksum = "157a8ba7b480713b56f4c09fd13fc3e0a22a5dfab8097ba61cbc5feef950788a" dependencies = [ "glob", "libc", @@ -749,19 +932,18 @@ dependencies = [ [[package]] name = "codespan-reporting" -version = "0.11.1" +version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3538270d33cc669650c4b093848450d380def10c331d38c768e34cac80576e6e" +checksum = "fe6d2e5af09e8c8ad56c969f2157a3d4238cebc7c55f0a517728c38f7b200f81" dependencies = [ - "termcolor", "unicode-width", ] [[package]] name = "combine" -version = "4.6.7" +version = "4.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e" dependencies = [ "bytes", "memchr", @@ -782,6 +964,12 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6" +[[package]] +name = "cookie-factory" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9885fa71e26b8ab7855e2ec7cae6e9b380edff76cd052e07c683a0319d51b3a2" + [[package]] name = "core-foundation" version = "0.9.4" @@ -824,45 +1012,50 @@ dependencies = [ [[package]] name = "coreaudio-rs" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "321077172d79c662f64f5071a03120748d5bb652f5231570141be24cfcd2bace" -dependencies = [ - "bitflags 1.3.2", - "core-foundation-sys", - "coreaudio-sys", -] - -[[package]] -name = "coreaudio-sys" -version = "0.2.18" +version = "0.14.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9b4739a805a62757a83e5654fa3faabec0442666b263bb2287d5a8185bfd953" +checksum = "7d5d7dca3ebcf65a035582c9ad4385371a9d9ee6537474d2a278f4e1e475bb58" dependencies = [ - "bindgen", + "bitflags 2.13.2", + "libc", + "objc2-audio-toolbox", + "objc2-core-audio", + "objc2-core-audio-types", + "objc2-core-foundation", ] [[package]] name = "cpal" -version = "0.15.3" +version = "0.18.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "873dab07c8f743075e57f524c583985fbaf745602acbe916a01539364369a779" +checksum = "6f02e8d0327b42d3e2e4ab2119af397344eb9fc54a34bf0ddeaa1277af8681f1" dependencies = [ "alsa", - "core-foundation-sys", + "block2 0.6.2", "coreaudio-rs", "dasp_sample", - "jni 0.21.1", + "futures", + "jni", "js-sys", "libc", "mach2", - "ndk 0.8.0", + "ndk", "ndk-context", - "oboe", - "wasm-bindgen", - "wasm-bindgen-futures", + "num-derive", + "num-traits", + "objc2 0.6.4", + "objc2-audio-toolbox", + "objc2-avf-audio", + "objc2-core-audio", + "objc2-core-audio-types", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "pipewire", + "portable-atomic", + "pulseaudio", "web-sys", - "windows 0.54.0", + "windows 0.62.2", + "windows-core 0.62.2", ] [[package]] @@ -909,9 +1102,9 @@ dependencies = [ [[package]] name = "crossbeam-utils" -version = "0.8.22" +version = "0.8.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" [[package]] name = "crunchy" @@ -1102,7 +1295,9 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", + "block2 0.6.2", + "libc", "objc2 0.6.4", ] @@ -1114,7 +1309,7 @@ checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" dependencies = [ "proc-macro2", "quote", - "syn 3.0.4", + "syn 3.0.5", ] [[package]] @@ -1149,9 +1344,9 @@ checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" [[package]] name = "ecolor" -version = "0.31.1" +version = "0.33.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc4feb366740ded31a004a0e4452fbf84e80ef432ecf8314c485210229672fd1" +checksum = "71ddb8ac7643d1dba1bb02110e804406dd459a838efcb14011ced10556711a8e" dependencies = [ "bytemuck", "emath", @@ -1159,9 +1354,9 @@ dependencies = [ [[package]] name = "eframe" -version = "0.31.1" +version = "0.33.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0dfe0859f3fb1bc6424c57d41e10e9093fe938f426b691e42272c2f336d915c" +checksum = "457481173e6db5ca9fa2be93a58df8f4c7be639587aeb4853b526c6cf87db4e6" dependencies = [ "ahash", "bytemuck", @@ -1188,31 +1383,33 @@ dependencies = [ "wasm-bindgen-futures", "web-sys", "web-time", - "winapi", - "windows-sys 0.59.0", + "windows-sys 0.61.2", "winit", ] [[package]] name = "egui" -version = "0.31.1" +version = "0.33.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25dd34cec49ab55d85ebf70139cb1ccd29c977ef6b6ba4fe85489d6877ee9ef3" +checksum = "6a9b567d356674e9a5121ed3fedfb0a7c31e059fe71f6972b691bcd0bfc284e3" dependencies = [ + "accesskit", "ahash", - "bitflags 2.13.1", + "bitflags 2.13.2", "emath", "epaint", "log", "nohash-hasher", "profiling", + "smallvec", + "unicode-segmentation", ] [[package]] name = "egui-wgpu" -version = "0.31.1" +version = "0.33.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d319dfef570f699b6e9114e235e862a2ddcf75f0d1a061de9e1328d92146d820" +checksum = "5e4d209971c84b2352a06174abdba701af1e552ce56b144d96f2bd50a3c91236" dependencies = [ "ahash", "bytemuck", @@ -1221,7 +1418,7 @@ dependencies = [ "epaint", "log", "profiling", - "thiserror 1.0.69", + "thiserror 2.0.20", "type-map", "web-time", "wgpu", @@ -1230,15 +1427,18 @@ dependencies = [ [[package]] name = "egui-winit" -version = "0.31.1" +version = "0.33.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d9dfbb78fe4eb9c3a39ad528b90ee5915c252e77bbab9d4ebc576541ab67e13" +checksum = "ec6687e5bb551702f4ad10ac428bab12acf9d53047ebb1082d4a0ed8c6251a29" dependencies = [ - "ahash", + "accesskit_winit", "arboard", "bytemuck", "egui", "log", + "objc2 0.5.2", + "objc2-foundation 0.2.2", + "objc2-ui-kit", "profiling", "raw-window-handle", "smithay-clipboard", @@ -1249,11 +1449,10 @@ dependencies = [ [[package]] name = "egui_glow" -version = "0.31.1" +version = "0.33.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "910906e3f042ea6d2378ec12a6fd07698e14ddae68aed2d819ffe944a73aab9e" +checksum = "6420863ea1d90e750f75075231a260030ad8a9f30a7cef82cdc966492dc4c4eb" dependencies = [ - "ahash", "bytemuck", "egui", "glow", @@ -1267,15 +1466,15 @@ dependencies = [ [[package]] name = "either" -version = "1.17.0" +version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d" +checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" [[package]] name = "emath" -version = "0.31.1" +version = "0.33.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e4cadcff7a5353ba72b7fea76bf2122b5ebdbc68e8155aa56dfdea90083fe1b" +checksum = "491bdf728bf25ddd9ad60d4cf1c48588fa82c013a2440b91aa7fc43e34a07c32" dependencies = [ "bytemuck", ] @@ -1286,6 +1485,17 @@ version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" +[[package]] +name = "enum-primitive-derive" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba7795da175654fe16979af73f81f26a8ea27638d8d9823d317016888a63dc4c" +dependencies = [ + "num-traits", + "quote", + "syn 2.0.119", +] + [[package]] name = "enumflags2" version = "0.7.12" @@ -1309,9 +1519,9 @@ dependencies = [ [[package]] name = "epaint" -version = "0.31.1" +version = "0.33.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41fcc0f5a7c613afd2dee5e4b30c3e6acafb8ad6f0edb06068811f708a67c562" +checksum = "009d0dd3c2163823a0abdb899451ecbc78798dec545ee91b43aff1fa790bab62" dependencies = [ "ab_glyph", "ahash", @@ -1327,9 +1537,9 @@ dependencies = [ [[package]] name = "epaint_default_fonts" -version = "0.31.1" +version = "0.33.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc7e7a64c02cf7a5b51e745a9e45f60660a286f151c238b9d397b3e923f5082f" +checksum = "5c4fbe202b6578d3d56428fa185cdf114a05e49da05f477b3c7f0fbb221f1862" [[package]] name = "equivalent" @@ -1435,24 +1645,19 @@ dependencies = [ [[package]] name = "find-msvc-tools" -version = "0.1.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" - -[[package]] -name = "fixedbitset" -version = "0.5.7" +version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" +checksum = "3e0f1c7c3a72c66fd80abe965175f7523475c0489a87d3ff9d6e8c87d87a9d2d" [[package]] name = "flate2" -version = "1.1.9" +version = "1.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" dependencies = [ "crc32fast", - "miniz_oxide", + "miniz_oxide 0.9.1", + "zlib-rs", ] [[package]] @@ -1467,6 +1672,12 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + [[package]] name = "foreign-types" version = "0.5.0" @@ -1485,7 +1696,7 @@ checksum = "ea5190182e6915eb873ddbc16e23b711b6eb1f9c00a0d0a3a91b5f6228475225" dependencies = [ "proc-macro2", "quote", - "syn 3.0.4", + "syn 3.0.5", ] [[package]] @@ -1519,6 +1730,21 @@ version = "0.4.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7ab85b9b05e3978cc9a9cf8fea7f01b494e1a09ed3037e16ba39edc7a29eb61a" +[[package]] +name = "futures" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + [[package]] name = "futures-channel" version = "0.3.34" @@ -1526,6 +1752,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" dependencies = [ "futures-core", + "futures-sink", ] [[package]] @@ -1534,6 +1761,17 @@ version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" +[[package]] +name = "futures-executor" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + [[package]] name = "futures-io" version = "0.3.34" @@ -1561,7 +1799,7 @@ checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" dependencies = [ "proc-macro2", "quote", - "syn 3.0.4", + "syn 3.0.5", ] [[package]] @@ -1582,6 +1820,7 @@ version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" dependencies = [ + "futures-channel", "futures-core", "futures-io", "futures-macro", @@ -1609,7 +1848,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" dependencies = [ "rustix 1.1.4", - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -1677,9 +1916,9 @@ dependencies = [ [[package]] name = "glob" -version = "0.3.4" +version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" [[package]] name = "glow" @@ -1699,7 +1938,7 @@ version = "0.32.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "12124de845cacfebedff80e877bb37b5b75c34c5a4c89e47e1cdd67fb6041325" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "cfg_aliases", "cgl", "dispatch2", @@ -1759,45 +1998,6 @@ dependencies = [ "gl_generator", ] -[[package]] -name = "gpu-alloc" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "45cf04b2726f02df5508c6de726acdc90cdf97ac771a9a0ffd8ba10a6e696bf9" -dependencies = [ - "bitflags 2.13.1", - "gpu-alloc-types", -] - -[[package]] -name = "gpu-alloc-types" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2bbed164dd10ed526c2e4fe3e721ca4a71c61730e5aafac6844b417b3227058" -dependencies = [ - "bitflags 2.13.1", -] - -[[package]] -name = "gpu-descriptor" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b89c83349105e3732062a895becfc71a8f921bb71ecbbdd8ff99263e3b53a0ca" -dependencies = [ - "bitflags 2.13.1", - "gpu-descriptor-types", - "hashbrown 0.15.5", -] - -[[package]] -name = "gpu-descriptor-types" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fdf242682df893b86f33a73828fb09ca4b2d3bb6cc95249707fc684d27484b91" -dependencies = [ - "bitflags 2.13.1", -] - [[package]] name = "h2" version = "0.4.19" @@ -1825,6 +2025,7 @@ checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" dependencies = [ "cfg-if", "crunchy", + "num-traits", "zerocopy", ] @@ -1834,7 +2035,16 @@ version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ - "foldhash", + "foldhash 0.1.5", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "foldhash 0.2.0", ] [[package]] @@ -1851,9 +2061,9 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] name = "hermit-abi" -version = "0.5.2" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" +checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284" [[package]] name = "hex" @@ -1932,9 +2142,9 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" [[package]] name = "hyper" -version = "1.11.0" +version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d22053281f852e11534f5198498373cbb59295120a20771d90f7ed1897490a72" +checksum = "27b501faa50e7a26c3d3560ca625132f4078a17771f4810baf70475ae48cbe43" dependencies = [ "atomic-waker", "bytes", @@ -2143,9 +2353,9 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.14.0" +version = "2.14.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" dependencies = [ "equivalent", "hashbrown 0.17.1", @@ -2163,9 +2373,9 @@ dependencies = [ [[package]] name = "ipnet" -version = "2.12.1" +version = "2.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a756c3fac73139e83f14c2d742155dd2b78d3ee56597b419a0579b7bdd6dd78" +checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" [[package]] name = "itertools" @@ -2182,22 +2392,6 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" -[[package]] -name = "jni" -version = "0.21.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" -dependencies = [ - "cesu8", - "cfg-if", - "combine", - "jni-sys 0.3.1", - "log", - "thiserror 1.0.69", - "walkdir", - "windows-sys 0.45.0", -] - [[package]] name = "jni" version = "0.22.4" @@ -2212,7 +2406,7 @@ dependencies = [ "simd_cesu8", "thiserror 2.0.20", "walkdir", - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -2268,9 +2462,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.104" +version = "0.3.105" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" +checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e" dependencies = [ "cfg-if", "futures-util", @@ -2290,17 +2484,6 @@ dependencies = [ "zeroize", ] -[[package]] -name = "khronos-egl" -version = "6.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6aae1df220ece3c0ada96b8153459b67eebe9ae9212258bb0134ae60416fdf76" -dependencies = [ - "libc", - "libloading", - "pkg-config", -] - [[package]] name = "khronos_api" version = "3.1.0" @@ -2335,19 +2518,52 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" dependencies = [ "cfg-if", - "windows-link", + "windows-link 0.2.1", ] +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + [[package]] name = "libredox" version = "0.1.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8d8f1ea3f21fd3405dcaf6c9b5c1630af9afc422d9073ea39c5f6d6c772e08ed" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "libc", "plain", - "redox_syscall 0.9.3", + "redox_syscall 0.9.4", +] + +[[package]] +name = "libspa" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "882f7427e7989dcc9d388b7f05c4630390a1d7696f9ffa469cd4a7a48f0b4c40" +dependencies = [ + "bitflags 2.13.2", + "cc", + "cookie-factory", + "libc", + "libspa-sys", + "nom 8.0.0", + "rustix 1.1.4", + "system-deps", +] + +[[package]] +name = "libspa-sys" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b6e17bdaf63ed0d5e4144022624032b41fd9733112e8c74ac26fc9bf1291924" +dependencies = [ + "bindgen", + "cc", + "system-deps", ] [[package]] @@ -2356,7 +2572,7 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "83270a18e9f90d0707c41e9f35efada77b64c0e6f3f1810e71c8368a864d5590" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "libc", ] @@ -2439,21 +2655,9 @@ dependencies = [ [[package]] name = "mach2" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44" -dependencies = [ - "libc", -] - -[[package]] -name = "malloc_buf" -version = "0.0.6" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb" -dependencies = [ - "libc", -] +checksum = "dae608c151f68243f2b000364e1f7b186d9c29845f7d2d85bd31b9ad77ad552b" [[package]] name = "matchit" @@ -2495,21 +2699,6 @@ dependencies = [ "autocfg", ] -[[package]] -name = "metal" -version = "0.31.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f569fb946490b5743ad69813cb19629130ce9374034abe31614a36402d18f99e" -dependencies = [ - "bitflags 2.13.1", - "block", - "core-graphics-types", - "foreign-types", - "log", - "objc", - "paste", -] - [[package]] name = "mime" version = "0.3.17" @@ -2532,6 +2721,12 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" +[[package]] +name = "minisign-verify" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f9645cb765ea72b8111f36c522475d2daa0d22c957a9826437e97534bc4e9e" + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -2542,13 +2737,24 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + [[package]] name = "mio" -version = "1.2.2" +version = "1.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" +checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" dependencies = [ "libc", + "log", "wasi", "windows-sys 0.61.2", ] @@ -2585,38 +2791,27 @@ dependencies = [ [[package]] name = "naga" -version = "24.0.0" +version = "27.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e380993072e52eef724eddfcde0ed013b0c023c3f0417336ed041aa9f076994e" +checksum = "066cf25f0e8b11ee0df221219010f213ad429855f57c494f995590c861a9a7d8" dependencies = [ "arrayvec", "bit-set", - "bitflags 2.13.1", + "bitflags 2.13.2", + "cfg-if", "cfg_aliases", "codespan-reporting", + "half", + "hashbrown 0.16.1", "hexf-parse", "indexmap", + "libm", "log", + "num-traits", + "once_cell", "rustc-hash 1.1.0", - "spirv", - "strum", - "termcolor", "thiserror 2.0.20", - "unicode-xid", -] - -[[package]] -name = "ndk" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2076a31b7010b17a38c01907c45b945e8f11495ee4dd588309718901b1f7a5b7" -dependencies = [ - "bitflags 2.13.1", - "jni-sys 0.3.1", - "log", - "ndk-sys 0.5.0+25.2.9519653", - "num_enum", - "thiserror 1.0.69", + "unicode-ident", ] [[package]] @@ -2625,10 +2820,10 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "jni-sys 0.3.1", "log", - "ndk-sys 0.6.0+11769913", + "ndk-sys", "num_enum", "raw-window-handle", "thiserror 1.0.69", @@ -2640,15 +2835,6 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" -[[package]] -name = "ndk-sys" -version = "0.5.0+25.2.9519653" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c196769dd60fd4f363e11d948139556a344e79d451aeb2fa2fd040738ef7691" -dependencies = [ - "jni-sys 0.3.1", -] - [[package]] name = "ndk-sys" version = "0.6.0+11769913" @@ -2664,7 +2850,7 @@ version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "22f9786d56d972959e1408b6a93be6af13b9c1392036c5c1fafa08a1b0c6ee87" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "byteorder", "derive_builder", "getset", @@ -2693,7 +2879,7 @@ version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "cfg-if", "cfg_aliases", "libc", @@ -2812,6 +2998,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", + "libm", ] [[package]] @@ -2837,12 +3024,12 @@ dependencies = [ ] [[package]] -name = "objc" -version = "0.2.7" +name = "num_threads" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1" +checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" dependencies = [ - "malloc_buf", + "libc", ] [[package]] @@ -2876,8 +3063,8 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff" dependencies = [ - "bitflags 2.13.1", - "block2", + "bitflags 2.13.2", + "block2 0.5.1", "libc", "objc2 0.5.2", "objc2-core-data", @@ -2892,21 +3079,48 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", + "block2 0.6.2", "objc2 0.6.4", "objc2-core-foundation", "objc2-core-graphics", "objc2-foundation 0.3.2", ] +[[package]] +name = "objc2-audio-toolbox" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6948501a91121d6399b79abaa33a8aa4ea7857fe019f341b8c23ad6e81b79b08" +dependencies = [ + "bitflags 2.13.2", + "libc", + "objc2 0.6.4", + "objc2-core-audio", + "objc2-core-audio-types", + "objc2-core-foundation", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-avf-audio" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13a380031deed8e99db00065c45937da434ca987c034e13b87e4441f9e4090be" +dependencies = [ + "bitflags 2.13.2", + "objc2 0.6.4", + "objc2-foundation 0.3.2", +] + [[package]] name = "objc2-cloud-kit" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "74dd3b56391c7a0596a295029734d3c1c5e7e510a4cb30245f8221ccea96b009" dependencies = [ - "bitflags 2.13.1", - "block2", + "bitflags 2.13.2", + "block2 0.5.1", "objc2 0.5.2", "objc2-core-location", "objc2-foundation 0.2.2", @@ -2918,19 +3132,42 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a5ff520e9c33812fd374d8deecef01d4a840e7b41862d849513de77e44aa4889" dependencies = [ - "block2", + "block2 0.5.1", "objc2 0.5.2", "objc2-foundation 0.2.2", ] +[[package]] +name = "objc2-core-audio" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1eebcea8b0dbff5f7c8504f3107c68fc061a3eb44932051c8cf8a68d969c3b2" +dependencies = [ + "dispatch2", + "objc2 0.6.4", + "objc2-core-audio-types", + "objc2-core-foundation", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-core-audio-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a89f2ec274a0cf4a32642b2991e8b351a404d290da87bb6a9a9d8632490bd1c" +dependencies = [ + "bitflags 2.13.2", + "objc2 0.6.4", +] + [[package]] name = "objc2-core-data" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef" dependencies = [ - "bitflags 2.13.1", - "block2", + "bitflags 2.13.2", + "block2 0.5.1", "objc2 0.5.2", "objc2-foundation 0.2.2", ] @@ -2941,8 +3178,10 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", + "block2 0.6.2", "dispatch2", + "libc", "objc2 0.6.4", ] @@ -2952,7 +3191,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "dispatch2", "objc2 0.6.4", "objc2-core-foundation", @@ -2965,7 +3204,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80" dependencies = [ - "block2", + "block2 0.5.1", "objc2 0.5.2", "objc2-foundation 0.2.2", "objc2-metal", @@ -2977,7 +3216,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "000cfee34e683244f284252ee206a27953279d370e309649dc3ee317b37e5781" dependencies = [ - "block2", + "block2 0.5.1", "objc2 0.5.2", "objc2-contacts", "objc2-foundation 0.2.2", @@ -2995,8 +3234,8 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" dependencies = [ - "bitflags 2.13.1", - "block2", + "bitflags 2.13.2", + "block2 0.5.1", "dispatch", "libc", "objc2 0.5.2", @@ -3008,7 +3247,9 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", + "block2 0.6.2", + "libc", "objc2 0.6.4", "objc2-core-foundation", ] @@ -3019,7 +3260,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "objc2 0.6.4", "objc2-core-foundation", ] @@ -3030,7 +3271,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a1a1ae721c5e35be65f01a03b6d2ac13a54cb4fa70d8a5da293d7b0020261398" dependencies = [ - "block2", + "block2 0.5.1", "objc2 0.5.2", "objc2-app-kit 0.2.2", "objc2-foundation 0.2.2", @@ -3042,8 +3283,8 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" dependencies = [ - "bitflags 2.13.1", - "block2", + "bitflags 2.13.2", + "block2 0.5.1", "objc2 0.5.2", "objc2-foundation 0.2.2", ] @@ -3054,8 +3295,8 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" dependencies = [ - "bitflags 2.13.1", - "block2", + "bitflags 2.13.2", + "block2 0.5.1", "objc2 0.5.2", "objc2-foundation 0.2.2", "objc2-metal", @@ -3077,8 +3318,8 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8bb46798b20cd6b91cbd113524c490f1686f4c4e8f49502431415f3512e2b6f" dependencies = [ - "bitflags 2.13.1", - "block2", + "bitflags 2.13.2", + "block2 0.5.1", "objc2 0.5.2", "objc2-cloud-kit", "objc2-core-data", @@ -3098,7 +3339,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44fa5f9748dbfe1ca6c0b79ad20725a11eca7c2218bceb4b005cb1be26273bfe" dependencies = [ - "block2", + "block2 0.5.1", "objc2 0.5.2", "objc2-foundation 0.2.2", ] @@ -3109,36 +3350,13 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "76cfcbf642358e8689af64cee815d139339f3ed8ad05103ed5eaf73db8d84cb3" dependencies = [ - "bitflags 2.13.1", - "block2", + "bitflags 2.13.2", + "block2 0.5.1", "objc2 0.5.2", "objc2-core-location", "objc2-foundation 0.2.2", ] -[[package]] -name = "oboe" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8b61bebd49e5d43f5f8cc7ee2891c16e0f41ec7954d36bcb6c14c5e0de867fb" -dependencies = [ - "jni 0.21.1", - "ndk 0.8.0", - "ndk-context", - "num-derive", - "num-traits", - "oboe-sys", -] - -[[package]] -name = "oboe-sys" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c8bb09a4a2b1d668170cfe0a7d5bc103f8999fb316c98099b6a9939c9f2e79d" -dependencies = [ - "cc", -] - [[package]] name = "oid-registry" version = "0.7.1" @@ -3188,25 +3406,32 @@ dependencies = [ name = "openless-linux-egui" version = "0.1.0" dependencies = [ - "arboard", "axum", "base64", + "chrono", "cpal", "dbus", "eframe", + "egui", "fs2", "futures-util", "hyper-util", + "image", "keyring", "libc", "local-ip-address", "log", + "minisign-verify", "openless-core", "rcgen", + "reqwest", + "rfd", "rustls", + "semver", "serde", "serde_json", "sha2", + "simplelog", "tempfile", "time", "tokio", @@ -3225,15 +3450,6 @@ dependencies = [ "libredox", ] -[[package]] -name = "ordered-float" -version = "4.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7bb71e1b3fa6ca1c61f383464aaf2bb0e2f8e772a1f01d486832464de363b951" -dependencies = [ - "num-traits", -] - [[package]] name = "ordered-stream" version = "0.2.0" @@ -3244,16 +3460,6 @@ dependencies = [ "pin-project-lite", ] -[[package]] -name = "os_pipe" -version = "1.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" -dependencies = [ - "libc", - "windows-sys 0.61.2", -] - [[package]] name = "owned_ttf_parser" version = "0.25.1" @@ -3289,15 +3495,9 @@ dependencies = [ "libc", "redox_syscall 0.5.18", "smallvec", - "windows-link", + "windows-link 0.2.1", ] -[[package]] -name = "paste" -version = "1.0.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" - [[package]] name = "pbkdf2" version = "0.12.2" @@ -3324,17 +3524,6 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" -[[package]] -name = "petgraph" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" -dependencies = [ - "fixedbitset", - "hashbrown 0.15.5", - "indexmap", -] - [[package]] name = "phf" version = "0.13.1" @@ -3425,6 +3614,31 @@ dependencies = [ "futures-io", ] +[[package]] +name = "pipewire" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde71084c4e25959d68f1ea54daa75e5ecdb338e5caf0b5510143b79baa32d5c" +dependencies = [ + "bitflags 2.13.2", + "libc", + "libspa", + "libspa-sys", + "pipewire-sys", + "rustix 1.1.4", +] + +[[package]] +name = "pipewire-sys" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ce653f53e63e5b93853218092ee9a8906a5d082c92f3f1db26316955dd63ce0" +dependencies = [ + "bindgen", + "libspa-sys", + "system-deps", +] + [[package]] name = "pkg-config" version = "0.3.34" @@ -3443,11 +3657,11 @@ version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "crc32fast", "fdeflate", "flate2", - "miniz_oxide", + "miniz_oxide 0.8.9", ] [[package]] @@ -3464,6 +3678,27 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "pollster" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3" + +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "portable-atomic-util" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10ab3eb7f3becc3a1cbc4f2c6f20267996cfc1a6467a873763411b136a122715" +dependencies = [ + "portable-atomic", +] + [[package]] name = "potential_utf" version = "0.1.6" @@ -3529,7 +3764,23 @@ checksum = "1c8d9ca532f185d5d4db7a7c9d51420b452168ea1c2b913953281bd6fe1fcbd0" dependencies = [ "proc-macro2", "quote", - "syn 3.0.4", + "syn 3.0.5", +] + +[[package]] +name = "pulseaudio" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d70623bd7967a9ca4c2ae0e807fc380b291f98480fc037042305ec643a4d3373" +dependencies = [ + "bitflags 2.13.2", + "byteorder", + "enum-primitive-derive", + "futures", + "log", + "mio", + "num-traits", + "thiserror 1.0.69", ] [[package]] @@ -3646,10 +3897,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" dependencies = [ "libc", - "rand_chacha", + "rand_chacha 0.3.1", "rand_core 0.6.4", ] +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + [[package]] name = "rand" version = "0.10.2" @@ -3671,6 +3932,16 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + [[package]] name = "rand_core" version = "0.6.4" @@ -3680,6 +3951,15 @@ dependencies = [ "getrandom 0.2.17", ] +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + [[package]] name = "rand_core" version = "0.10.1" @@ -3730,16 +4010,16 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", ] [[package]] name = "redox_syscall" -version = "0.9.3" +version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d678d17679829e73d371e96880897e98fee2ded7acc0a50bdf8af2affa4b2fe5" +checksum = "737970939a87c6fa31e7acad13307bccbb017a073b695b6089a2c484f929e20e" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", ] [[package]] @@ -3829,8 +4109,32 @@ dependencies = [ ] [[package]] -name = "ring" -version = "0.17.14" +name = "rfd" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672" +dependencies = [ + "ashpd", + "block2 0.6.2", + "dispatch2", + "js-sys", + "log", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "pollster", + "raw-window-handle", + "urlencoding", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows-sys 0.60.2", +] + +[[package]] +name = "ring" +version = "0.17.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" dependencies = [ @@ -3869,7 +4173,7 @@ checksum = "1c25ef604ac7dd839d44d64648952ea23c97866f124ff671b0ed2cf3ad9bb06e" dependencies = [ "proc-macro2", "quote", - "syn 3.0.4", + "syn 3.0.5", ] [[package]] @@ -3908,7 +4212,7 @@ version = "0.38.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "errno", "libc", "linux-raw-sys 0.4.15", @@ -3921,7 +4225,7 @@ version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "errno", "libc", "linux-raw-sys 0.12.1", @@ -3930,9 +4234,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.43" +version = "0.23.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" +checksum = "6725596c3f2c3a0aef021139e145d4eafe314a6623e4680ca83852b2c67ab2ba" dependencies = [ "log", "once_cell", @@ -4013,7 +4317,7 @@ dependencies = [ "rand 0.8.8", "serde", "sha2", - "zbus", + "zbus 4.4.0", ] [[package]] @@ -4049,7 +4353,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn 3.0.4", + "syn 3.0.5", ] [[package]] @@ -4073,7 +4377,16 @@ checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" dependencies = [ "proc-macro2", "quote", - "syn 3.0.4", + "syn 3.0.5", +] + +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", ] [[package]] @@ -4154,6 +4467,17 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" +[[package]] +name = "simplelog" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16257adbfaef1ee58b1363bdc0664c9b8e1e30aed86049635fb5f147d065a9c0" +dependencies = [ + "log", + "termcolor", + "time", +] + [[package]] name = "siphasher" version = "1.0.3" @@ -4177,9 +4501,9 @@ dependencies = [ [[package]] name = "smallvec" -version = "1.15.2" +version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" +checksum = "b9be42f50aa861c555654aa3a37f52f4b1074bacf4e48fe0ef7fa584e80f1f0f" [[package]] name = "smithay-client-toolkit" @@ -4187,7 +4511,7 @@ version = "0.19.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3457dea1f0eb631b4034d61d4d8c32074caa6cd1ab2d59f2327bd8461e2c0016" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "calloop 0.13.0", "calloop-wayland-source 0.3.0", "cursor-icon", @@ -4212,7 +4536,7 @@ version = "0.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0512da38f5e2b31201a93524adb8d3136276fa4fe4aafab4e1f727a82b534cc0" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "calloop 0.14.4", "calloop-wayland-source 0.4.1", "cursor-icon", @@ -4263,15 +4587,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "spirv" -version = "0.3.0+sdk-1.3.268.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eda41003dc44290527a59b13432d4a0379379fa074b70174882adfbdfd917844" -dependencies = [ - "bitflags 2.13.1", -] - [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -4290,28 +4605,6 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" -[[package]] -name = "strum" -version = "0.26.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" -dependencies = [ - "strum_macros", -] - -[[package]] -name = "strum_macros" -version = "0.26.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" -dependencies = [ - "heck", - "proc-macro2", - "quote", - "rustversion", - "syn 2.0.119", -] - [[package]] name = "subtle" version = "2.6.1" @@ -4331,9 +4624,9 @@ dependencies = [ [[package]] name = "syn" -version = "3.0.4" +version = "3.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f" +checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9" dependencies = [ "proc-macro2", "quote", @@ -4366,7 +4659,7 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "core-foundation", "system-configuration-sys", ] @@ -4381,6 +4674,19 @@ dependencies = [ "libc", ] +[[package]] +name = "system-deps" +version = "7.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "396a35feb67335377e0251fcbc1092fc85c484bd4e3a7a54319399da127796e7" +dependencies = [ + "cfg-expr", + "heck", + "pkg-config", + "toml", + "version-compare", +] + [[package]] name = "tar" version = "0.4.46" @@ -4392,6 +4698,12 @@ dependencies = [ "xattr", ] +[[package]] +name = "target-lexicon" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca" + [[package]] name = "tempfile" version = "3.27.0" @@ -4451,7 +4763,7 @@ checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" dependencies = [ "proc-macro2", "quote", - "syn 3.0.4", + "syn 3.0.5", ] [[package]] @@ -4475,7 +4787,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" dependencies = [ "deranged", + "libc", "num-conv", + "num_threads", "powerfmt", "serde_core", "time-core", @@ -4510,9 +4824,9 @@ dependencies = [ [[package]] name = "tinyvec" -version = "1.12.0" +version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" +checksum = "4cf0ded5c4e56918d8f8a339e1bb67d038d3bc6d144ac407904015ba2e4cde9b" dependencies = [ "tinyvec_macros", ] @@ -4536,6 +4850,7 @@ dependencies = [ "signal-hook-registry", "socket2", "tokio-macros", + "tracing", "windows-sys 0.61.2", ] @@ -4547,14 +4862,14 @@ checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" dependencies = [ "proc-macro2", "quote", - "syn 3.0.4", + "syn 3.0.5", ] [[package]] name = "tokio-rustls" -version = "0.26.4" +version = "0.26.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +checksum = "b0c85f2c3ef0b1cd58b36682f4b17aaa995f0e5db534d85692b4903abce21f67" dependencies = [ "rustls", "tokio", @@ -4590,6 +4905,21 @@ dependencies = [ "tokio", ] +[[package]] +name = "toml" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", +] + [[package]] name = "toml_datetime" version = "1.1.1+spec-1.1.0" @@ -4601,9 +4931,9 @@ dependencies = [ [[package]] name = "toml_edit" -version = "0.25.13+spec-1.1.0" +version = "0.25.15+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" +checksum = "1340ea94a5856333492c9064b02c778b191dd2c853778d9609debdcdfea3a614" dependencies = [ "indexmap", "toml_datetime", @@ -4620,6 +4950,12 @@ dependencies = [ "winnow", ] +[[package]] +name = "toml_writer" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" + [[package]] name = "tower" version = "0.5.3" @@ -4641,7 +4977,7 @@ version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "bytes", "futures-util", "http", @@ -4697,17 +5033,6 @@ dependencies = [ "once_cell", ] -[[package]] -name = "tree_magic_mini" -version = "3.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8765b90061cba6c22b5831f675da109ae5561588290f9fa2317adab2714d5a6" -dependencies = [ - "memchr", - "nom 8.0.0", - "petgraph", -] - [[package]] name = "try-lock" version = "0.2.5" @@ -4786,15 +5111,9 @@ checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" [[package]] name = "unicode-width" -version = "0.1.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" - -[[package]] -name = "unicode-xid" -version = "0.2.6" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" [[package]] name = "untrusted" @@ -4815,6 +5134,12 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + [[package]] name = "utf-8" version = "0.7.6" @@ -4829,9 +5154,9 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" [[package]] name = "uuid" -version = "1.26.0" +version = "1.26.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5772d71c9be8a8a6ac2117d949c5b224c1b72241bb611d9a3012edcf8af7812" +checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce" dependencies = [ "getrandom 0.4.3", "js-sys", @@ -4839,6 +5164,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "version-compare" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e" + [[package]] name = "version_check" version = "0.9.5" @@ -4881,9 +5212,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.127" +version = "0.2.128" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" +checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf" dependencies = [ "cfg-if", "once_cell", @@ -4894,9 +5225,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.77" +version = "0.4.78" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b7777d5cc23d0e91404e53ce2d5e8ec7acae3026b16233dba62cd3246457950" +checksum = "6ef4c5d3d2cdf5c54f4231181768f5510842e350db025faf1f7163b1030ed928" dependencies = [ "js-sys", "wasm-bindgen", @@ -4904,9 +5235,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.127" +version = "0.2.128" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" +checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -4914,22 +5245,22 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.127" +version = "0.2.128" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" +checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a" dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.5", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.127" +version = "0.2.128" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" +checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e" dependencies = [ "unicode-ident", ] @@ -4967,7 +5298,7 @@ version = "0.31.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3c36a0f861ad76d0901f2800b46321410d9f73f2ea88aac0650d86c32688073" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "rustix 1.1.4", "wayland-backend", "wayland-scanner", @@ -4979,7 +5310,7 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "625c5029dbd43d25e6aa9615e88b829a5cad13b2819c4ae129fdbb7c31ab4c7e" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "cursor-icon", "wayland-backend", ] @@ -5001,7 +5332,7 @@ version = "0.32.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "23d0c813de3daa2ed6520af85a3bd49b0e722a3078506899aa9686fea58dc4b6" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "wayland-backend", "wayland-client", "wayland-scanner", @@ -5013,7 +5344,7 @@ version = "20250721.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40a1f863128dcaaec790d7b4b396cc9b9a7a079e878e18c47e6c2d2c5a8dcbb1" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "wayland-backend", "wayland-client", "wayland-protocols", @@ -5026,7 +5357,7 @@ version = "0.3.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6e9567599ef23e09b8dad6e429e5738d4509dfc46b3b21f32841a304d16b29c8" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "wayland-backend", "wayland-client", "wayland-protocols", @@ -5039,7 +5370,7 @@ version = "0.3.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b6d8cf1eb2c1c31ed1f5643c88a6e53538129d4af80030c8cabd1f9fa884d91" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "wayland-backend", "wayland-client", "wayland-protocols", @@ -5052,7 +5383,7 @@ version = "0.3.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "wayland-backend", "wayland-client", "wayland-protocols", @@ -5084,9 +5415,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.104" +version = "0.3.105" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c435338968042f4f59a557f690a253676d47ce13ceb55d70100e7facf6620a30" +checksum = "9fbddc4a036f00ec4f18c83445bd3115cb306a91da554919a099d9222fe4a7f8" dependencies = [ "js-sys", "wasm-bindgen", @@ -5108,7 +5439,7 @@ version = "1.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "62c35be770821a214dbc362fc26908c853e776c0004294d0b10b8a6bad582f94" dependencies = [ - "jni 0.22.4", + "jni", "log", "ndk-context", "objc2 0.6.4", @@ -5144,24 +5475,22 @@ checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" [[package]] name = "wgpu" -version = "24.0.5" +version = "27.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b0b3436f0729f6cdf2e6e9201f3d39dc95813fad61d826c1ed07918b4539353" +checksum = "bfe68bac7cde125de7a731c3400723cadaaf1703795ad3f4805f187459cd7a77" dependencies = [ "arrayvec", - "bitflags 2.13.1", + "bitflags 2.13.2", + "cfg-if", "cfg_aliases", "document-features", - "js-sys", + "hashbrown 0.16.1", "log", - "parking_lot", + "portable-atomic", "profiling", "raw-window-handle", "smallvec", "static_assertions", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", "wgpu-core", "wgpu-hal", "wgpu-types", @@ -5169,79 +5498,74 @@ dependencies = [ [[package]] name = "wgpu-core" -version = "24.0.5" +version = "27.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f0aa306497a238d169b9dc70659105b4a096859a34894544ca81719242e1499" +checksum = "27a75de515543b1897b26119f93731b385a19aea165a1ec5f0e3acecc229cae7" dependencies = [ "arrayvec", + "bit-set", "bit-vec", - "bitflags 2.13.1", + "bitflags 2.13.2", + "bytemuck", "cfg_aliases", "document-features", + "hashbrown 0.16.1", "indexmap", "log", "naga", "once_cell", "parking_lot", + "portable-atomic", "profiling", "raw-window-handle", "rustc-hash 1.1.0", "smallvec", "thiserror 2.0.20", + "wgpu-core-deps-windows-linux-android", "wgpu-hal", "wgpu-types", ] +[[package]] +name = "wgpu-core-deps-windows-linux-android" +version = "27.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71197027d61a71748e4120f05a9242b2ad142e3c01f8c1b47707945a879a03c3" +dependencies = [ + "wgpu-hal", +] + [[package]] name = "wgpu-hal" -version = "24.0.4" +version = "27.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f112f464674ca69f3533248508ee30cb84c67cf06c25ff6800685f5e0294e259" +checksum = "5b21cb61c57ee198bc4aff71aeadff4cbb80b927beb912506af9c780d64313ce" dependencies = [ - "android_system_properties", - "arrayvec", - "ash", - "bitflags 2.13.1", - "bytemuck", + "bitflags 2.13.2", + "cfg-if", "cfg_aliases", - "core-graphics-types", - "glow", - "glutin_wgl_sys", - "gpu-alloc", - "gpu-descriptor", - "js-sys", - "khronos-egl", - "libc", "libloading", "log", - "metal", "naga", - "ndk-sys 0.5.0+25.2.9519653", - "objc", - "once_cell", - "ordered-float", - "parking_lot", - "profiling", + "portable-atomic", + "portable-atomic-util", "raw-window-handle", "renderdoc-sys", - "rustc-hash 1.1.0", - "smallvec", "thiserror 2.0.20", - "wasm-bindgen", - "web-sys", "wgpu-types", - "windows 0.58.0", ] [[package]] name = "wgpu-types" -version = "24.0.0" +version = "27.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50ac044c0e76c03a0378e7786ac505d010a873665e2d51383dcff8dd227dc69c" +checksum = "afdcf84c395990db737f2dd91628706cb31e86d72e53482320d368e52b5da5eb" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", + "bytemuck", "js-sys", "log", + "thiserror 2.0.20", "web-sys", ] @@ -5278,45 +5602,58 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" [[package]] name = "windows" -version = "0.54.0" +version = "0.61.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9252e5725dbed82865af151df558e754e4a3c2c30818359eb17465f1346a1b49" +checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" dependencies = [ - "windows-core 0.54.0", - "windows-targets 0.52.6", + "windows-collections 0.2.0", + "windows-core 0.61.2", + "windows-future 0.2.1", + "windows-link 0.1.3", + "windows-numerics 0.2.0", ] [[package]] name = "windows" -version = "0.58.0" +version = "0.62.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd04d41d93c4992d421894c18c8b43496aa748dd4c081bac0dc93eb0489272b6" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" dependencies = [ - "windows-core 0.58.0", - "windows-targets 0.52.6", + "windows-collections 0.3.2", + "windows-core 0.62.2", + "windows-future 0.3.2", + "windows-numerics 0.3.1", ] [[package]] -name = "windows-core" -version = "0.54.0" +name = "windows-collections" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12661b9c89351d684a50a8a643ce5f608e20243b9fb84687800163429f161d65" +checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" dependencies = [ - "windows-result 0.1.2", - "windows-targets 0.52.6", + "windows-core 0.61.2", +] + +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core 0.62.2", ] [[package]] name = "windows-core" -version = "0.58.0" +version = "0.61.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ba6d44ec8c2591c134257ce647b7ea6b20335bf6379a27dac5f1641fcf59f99" +checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" dependencies = [ - "windows-implement 0.58.0", - "windows-interface 0.58.0", - "windows-result 0.2.0", - "windows-strings 0.1.0", - "windows-targets 0.52.6", + "windows-implement", + "windows-interface", + "windows-link 0.1.3", + "windows-result 0.3.4", + "windows-strings 0.4.2", ] [[package]] @@ -5325,40 +5662,40 @@ version = "0.62.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" dependencies = [ - "windows-implement 0.60.2", - "windows-interface 0.59.3", - "windows-link", + "windows-implement", + "windows-interface", + "windows-link 0.2.1", "windows-result 0.4.1", "windows-strings 0.5.1", ] [[package]] -name = "windows-implement" -version = "0.58.0" +name = "windows-future" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b" +checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", + "windows-core 0.61.2", + "windows-link 0.1.3", + "windows-threading 0.1.0", ] [[package]] -name = "windows-implement" -version = "0.60.2" +name = "windows-future" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", + "windows-core 0.62.2", + "windows-link 0.2.1", + "windows-threading 0.2.1", ] [[package]] -name = "windows-interface" -version = "0.58.0" +name = "windows-implement" +version = "0.60.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", @@ -5376,6 +5713,12 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "windows-link" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + [[package]] name = "windows-link" version = "0.2.1" @@ -5383,32 +5726,43 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" [[package]] -name = "windows-registry" -version = "0.6.1" +name = "windows-numerics" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" dependencies = [ - "windows-link", - "windows-result 0.4.1", - "windows-strings 0.5.1", + "windows-core 0.61.2", + "windows-link 0.1.3", ] [[package]] -name = "windows-result" -version = "0.1.2" +name = "windows-numerics" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" dependencies = [ - "windows-targets 0.52.6", + "windows-core 0.62.2", + "windows-link 0.2.1", +] + +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", ] [[package]] name = "windows-result" -version = "0.2.0" +version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" dependencies = [ - "windows-targets 0.52.6", + "windows-link 0.1.3", ] [[package]] @@ -5417,17 +5771,16 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" dependencies = [ - "windows-link", + "windows-link 0.2.1", ] [[package]] name = "windows-strings" -version = "0.1.0" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" dependencies = [ - "windows-result 0.2.0", - "windows-targets 0.52.6", + "windows-link 0.1.3", ] [[package]] @@ -5436,16 +5789,7 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-sys" -version = "0.45.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" -dependencies = [ - "windows-targets 0.42.2", + "windows-link 0.2.1", ] [[package]] @@ -5481,22 +5825,7 @@ version = "0.61.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-targets" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" -dependencies = [ - "windows_aarch64_gnullvm 0.42.2", - "windows_aarch64_msvc 0.42.2", - "windows_i686_gnu 0.42.2", - "windows_i686_msvc 0.42.2", - "windows_x86_64_gnu 0.42.2", - "windows_x86_64_gnullvm 0.42.2", - "windows_x86_64_msvc 0.42.2", + "windows-link 0.2.1", ] [[package]] @@ -5521,7 +5850,7 @@ version = "0.53.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" dependencies = [ - "windows-link", + "windows-link 0.2.1", "windows_aarch64_gnullvm 0.53.1", "windows_aarch64_msvc 0.53.1", "windows_i686_gnu 0.53.1", @@ -5533,10 +5862,22 @@ dependencies = [ ] [[package]] -name = "windows_aarch64_gnullvm" -version = "0.42.2" +name = "windows-threading" +version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" +checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link 0.2.1", +] [[package]] name = "windows_aarch64_gnullvm" @@ -5550,12 +5891,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" -[[package]] -name = "windows_aarch64_msvc" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" - [[package]] name = "windows_aarch64_msvc" version = "0.52.6" @@ -5568,12 +5903,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" -[[package]] -name = "windows_i686_gnu" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" - [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -5598,12 +5927,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" -[[package]] -name = "windows_i686_msvc" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" - [[package]] name = "windows_i686_msvc" version = "0.52.6" @@ -5616,12 +5939,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" -[[package]] -name = "windows_x86_64_gnu" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" - [[package]] name = "windows_x86_64_gnu" version = "0.52.6" @@ -5634,12 +5951,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" - [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" @@ -5652,12 +5963,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" -[[package]] -name = "windows_x86_64_msvc" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" - [[package]] name = "windows_x86_64_msvc" version = "0.52.6" @@ -5679,8 +5984,8 @@ dependencies = [ "ahash", "android-activity", "atomic-waker", - "bitflags 2.13.1", - "block2", + "bitflags 2.13.2", + "block2 0.5.1", "bytemuck", "calloop 0.13.0", "cfg_aliases", @@ -5692,7 +5997,7 @@ dependencies = [ "js-sys", "libc", "memmap2", - "ndk 0.9.0", + "ndk", "objc2 0.5.2", "objc2-app-kit 0.2.2", "objc2-foundation 0.2.2", @@ -5736,24 +6041,6 @@ version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" -[[package]] -name = "wl-clipboard-rs" -version = "0.9.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9651471a32e87d96ef3a127715382b2d11cc7c8bb9822ded8a7cc94072eb0a3" -dependencies = [ - "libc", - "log", - "os_pipe", - "rustix 1.1.4", - "thiserror 2.0.20", - "tree_magic_mini", - "wayland-backend", - "wayland-client", - "wayland-protocols", - "wayland-protocols-wlr", -] - [[package]] name = "writeable" version = "0.6.4" @@ -5842,7 +6129,7 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d039de8032a9a8856a6be89cea3e5d12fdd82306ab7c94d74e6deab2460651c5" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "dlib", "log", "once_cell", @@ -5929,9 +6216,69 @@ dependencies = [ "uds_windows", "windows-sys 0.52.0", "xdg-home", - "zbus_macros", - "zbus_names", - "zvariant", + "zbus_macros 4.4.0", + "zbus_names 3.0.0", + "zvariant 4.2.0", +] + +[[package]] +name = "zbus" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5db4be7c075cb421e4b7ee645541604239bd243ba7c357511f4ff3a74b555907" +dependencies = [ + "async-broadcast", + "async-executor", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "libc", + "ordered-stream", + "rustix 1.1.4", + "serde", + "serde_repr", + "tokio", + "tracing", + "uds_windows", + "uuid", + "windows-sys 0.61.2", + "winnow", + "zbus_macros 5.19.0", + "zbus_names 4.3.4", + "zvariant 5.15.0", +] + +[[package]] +name = "zbus-lockstep" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6998de05217a084b7578728a9443d04ea4cd80f2a0839b8d78770b76ccd45863" +dependencies = [ + "zbus_xml", + "zvariant 5.15.0", +] + +[[package]] +name = "zbus-lockstep-macros" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10da05367f3a7b7553c8cdf8fa91aee6b64afebe32b51c95177957efc47ca3a0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "zbus-lockstep", + "zbus_xml", + "zvariant 5.15.0", ] [[package]] @@ -5944,7 +6291,22 @@ dependencies = [ "proc-macro2", "quote", "syn 2.0.119", - "zvariant_utils", + "zvariant_utils 2.1.0", +] + +[[package]] +name = "zbus_macros" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2990635d09ade6df1868f72f8cac69a876a90981e8bd3c40b1be413f8dc88f40" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 3.0.5", + "zbus_names 4.3.4", + "zvariant 5.15.0", + "zvariant_utils 4.2.0", ] [[package]] @@ -5955,23 +6317,55 @@ checksum = "4b9b1fef7d021261cc16cba64c351d291b715febe0fa10dc3a443ac5a5022e6c" dependencies = [ "serde", "static_assertions", - "zvariant", + "zvariant 4.2.0", +] + +[[package]] +name = "zbus_names" +version = "4.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e" +dependencies = [ + "serde", + "winnow", + "zvariant 5.15.0", +] + +[[package]] +name = "zbus_xml" +version = "5.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1586c021a01ca0a9216dcd874e546382e156a5cbab5fab6cb5f10087e22682a" +dependencies = [ + "serde", + "winnow", + "zbus_names 4.3.4", + "zvariant 5.15.0", +] + +[[package]] +name = "zcheapstr" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1afec51604565183aeb5c54c20aeab286120d4e4460f7f76e3e8bb8c0d99473" +dependencies = [ + "serde", ] [[package]] name = "zerocopy" -version = "0.8.56" +version = "0.8.57" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" +checksum = "d35102a9f36d089ccae9e4c6802bc118be4487b80aaffc0ab4e0cf5ce92d2873" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.56" +version = "0.8.57" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" +checksum = "146c01f5ab44258da43cf276c74a2763db2ff3969c9c652c3f2de07041d0b2bc" dependencies = [ "proc-macro2", "quote", @@ -6049,7 +6443,7 @@ checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" dependencies = [ "proc-macro2", "quote", - "syn 3.0.4", + "syn 3.0.5", ] [[package]] @@ -6082,6 +6476,12 @@ dependencies = [ "zstd", ] +[[package]] +name = "zlib-rs" +version = "0.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12" + [[package]] name = "zmij" version = "1.0.23" @@ -6111,18 +6511,18 @@ dependencies = [ [[package]] name = "zstd-safe" -version = "7.2.4" +version = "7.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" +checksum = "64d80649ab6db9d9f6f9c80a40becd948eda4714a0a5ac8c4d157a32231c7882" dependencies = [ "zstd-sys", ] [[package]] name = "zstd-sys" -version = "2.0.16+zstd.1.5.7" +version = "2.1.0+zstd.1.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" +checksum = "0ef0a8027ec3ee71300ab3bcbcd0393f434aa72b91ca6d635a39941deae8eea0" dependencies = [ "cc", "pkg-config", @@ -6153,7 +6553,23 @@ dependencies = [ "enumflags2", "serde", "static_assertions", - "zvariant_derive", + "zvariant_derive 4.2.0", +] + +[[package]] +name = "zvariant" +version = "5.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1d34c27cc6cdd1f458427519dd6b8612f7b7e3f7b9a0b2355d041dda9869147" +dependencies = [ + "endi", + "enumflags2", + "serde", + "url", + "winnow", + "zcheapstr", + "zvariant_derive 5.15.0", + "zvariant_utils 4.2.0", ] [[package]] @@ -6166,7 +6582,20 @@ dependencies = [ "proc-macro2", "quote", "syn 2.0.119", - "zvariant_utils", + "zvariant_utils 2.1.0", +] + +[[package]] +name = "zvariant_derive" +version = "5.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "864155e69b4352db0c7f374917bf45d1e0c8d17659c8b3dbf9795f3673f8c497" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 3.0.5", + "zvariant_utils 4.2.0", ] [[package]] @@ -6179,3 +6608,16 @@ dependencies = [ "quote", "syn 2.0.119", ] + +[[package]] +name = "zvariant_utils" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad0294361a320b694a328460dc73add56c306150f5cb6bfafc44446120008a3" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn 3.0.5", + "winnow", +] diff --git a/openless-all/app/src-tauri/src/remote_server/assets/app.js b/openless-all/app/assets/remote-input/app.js similarity index 100% rename from openless-all/app/src-tauri/src/remote_server/assets/app.js rename to openless-all/app/assets/remote-input/app.js diff --git a/openless-all/app/src-tauri/src/remote_server/assets/done.png b/openless-all/app/assets/remote-input/done.png similarity index 100% rename from openless-all/app/src-tauri/src/remote_server/assets/done.png rename to openless-all/app/assets/remote-input/done.png diff --git a/openless-all/app/src-tauri/src/remote_server/assets/icon.png b/openless-all/app/assets/remote-input/icon.png similarity index 100% rename from openless-all/app/src-tauri/src/remote_server/assets/icon.png rename to openless-all/app/assets/remote-input/icon.png diff --git a/openless-all/app/src-tauri/src/remote_server/assets/index.html b/openless-all/app/assets/remote-input/index.html similarity index 100% rename from openless-all/app/src-tauri/src/remote_server/assets/index.html rename to openless-all/app/assets/remote-input/index.html diff --git a/openless-all/app/src-tauri/src/remote_server/assets/mic.png b/openless-all/app/assets/remote-input/mic.png similarity index 100% rename from openless-all/app/src-tauri/src/remote_server/assets/mic.png rename to openless-all/app/assets/remote-input/mic.png diff --git a/openless-all/app/src-tauri/src/remote_server/assets/style.css b/openless-all/app/assets/remote-input/style.css similarity index 100% rename from openless-all/app/src-tauri/src/remote_server/assets/style.css rename to openless-all/app/assets/remote-input/style.css diff --git a/openless-all/app/src/lib/vocab-presets.json b/openless-all/app/assets/vocab-presets.json similarity index 100% rename from openless-all/app/src/lib/vocab-presets.json rename to openless-all/app/assets/vocab-presets.json diff --git a/openless-all/app/crates/openless-core/src/domains.rs b/openless-all/app/crates/openless-core/src/domains.rs index 4b73d88ec..1397aa38e 100644 --- a/openless-all/app/crates/openless-core/src/domains.rs +++ b/openless-all/app/crates/openless-core/src/domains.rs @@ -282,7 +282,7 @@ pub trait LocalAsrApi: Send + Sync { fn delete_model(&self, target: LocalAsrTarget) -> BoxFuture<'static, Result<(), BackendError>>; fn cleanup_incomplete( &self, - target: LocalAsrTarget, + _target: LocalAsrTarget, ) -> BoxFuture<'static, Result<(), BackendError>> { unsupported("local ASR incomplete download cleanup") } diff --git a/openless-all/app/crates/openless-core/src/lib.rs b/openless-all/app/crates/openless-core/src/lib.rs index 3dc2d6c42..e317e953c 100644 --- a/openless-all/app/crates/openless-core/src/lib.rs +++ b/openless-all/app/crates/openless-core/src/lib.rs @@ -355,4 +355,7 @@ pub use types::{ SelectionVoiceIntentMode, SelectionVoiceManualIntent, SessionId, StylePackChange, TranscriptAccumulator, TranscriptDelta, VocabPreset, VocabPresetStore, VocabularyChange, }; -pub use vocabulary::{list_vocab_presets, save_vocab_presets, DictionaryStore}; +pub use vocabulary::{ + builtin_vocab_presets, list_vocab_presets, resolve_vocab_presets, save_vocab_presets, + DictionaryStore, +}; diff --git a/openless-all/app/crates/openless-core/src/provider_rules.rs b/openless-all/app/crates/openless-core/src/provider_rules.rs index 6ad7919b4..73ba4ced3 100644 --- a/openless-all/app/crates/openless-core/src/provider_rules.rs +++ b/openless-all/app/crates/openless-core/src/provider_rules.rs @@ -693,7 +693,10 @@ pub fn is_stepfun_realtime_provider(id: &str) -> bool { } pub fn is_mimo_provider(id: &str) -> bool { - matches!(id, MIMO_PROVIDER_ID | crate::asr::mimo::ORCAROUTER_PROVIDER_ID) + matches!( + id, + MIMO_PROVIDER_ID | crate::asr::mimo::ORCAROUTER_PROVIDER_ID + ) } pub fn is_dashscope_multimodal_provider(id: &str) -> bool { diff --git a/openless-all/app/crates/openless-core/src/provider_service.rs b/openless-all/app/crates/openless-core/src/provider_service.rs index 102f657dc..4e2233700 100644 --- a/openless-all/app/crates/openless-core/src/provider_service.rs +++ b/openless-all/app/crates/openless-core/src/provider_service.rs @@ -513,8 +513,8 @@ fn validate_provider_endpoint(endpoint: &str, allow_websocket: bool) -> Result<( let url = url::Url::parse(endpoint).map_err(|_| invalid_request("provider endpoint is invalid"))?; if url.host_str().is_none() - || !matches!(url.scheme(), "http" | "https") - && !(allow_websocket && matches!(url.scheme(), "ws" | "wss")) + || !(matches!(url.scheme(), "http" | "https") + || allow_websocket && matches!(url.scheme(), "ws" | "wss")) { return Err(invalid_request("provider endpoint is invalid")); } @@ -1567,20 +1567,52 @@ mod tests { {"id":"legacy/chat"} ]}"#; for (kind, channel_kind, key_account, expected) in [ - (ProviderKind::Llm, ChannelKind::Llm, LLM_API_KEY_ACCOUNT, - vec!["google/gemini-2.5-flash", "google/gemini-tts", "google/gemini-unknown", "orcarouter/fusion-flash"]), - (ProviderKind::Asr, ChannelKind::Asr, ASR_API_KEY_ACCOUNT, - vec!["google/gemini-2.5-flash"]), + ( + ProviderKind::Llm, + ChannelKind::Llm, + LLM_API_KEY_ACCOUNT, + vec![ + "google/gemini-2.5-flash", + "google/gemini-tts", + "google/gemini-unknown", + "orcarouter/fusion-flash", + ], + ), + ( + ProviderKind::Asr, + ChannelKind::Asr, + ASR_API_KEY_ACCOUNT, + vec!["google/gemini-2.5-flash"], + ), ] { let credentials = Arc::new(InMemoryCredentialStore::default()); - let channel = create_channel_with_values(&credentials, channel_kind, "orcarouter", &[]).await; + let channel = + create_channel_with_values(&credentials, channel_kind, "orcarouter", &[]).await; let transport = Arc::new(FakeProviderTransport::default()); transport.push_response(200, catalog.as_bytes().to_vec()); - let service = ProviderService::new_with_transport(credentials.clone(), Arc::new(crate::TokioTaskSpawner), transport.clone()); - let request = ProviderRequest { kind, channel_id: Some(channel.clone()), thinking_enabled: false }; + let service = ProviderService::new_with_transport( + credentials.clone(), + Arc::new(crate::TokioTaskSpawner), + transport.clone(), + ); + let request = ProviderRequest { + kind, + channel_id: Some(channel.clone()), + thinking_enabled: false, + }; assert!(service.list_models(request.clone()).await.is_err()); - let namespace = if kind == ProviderKind::Llm { CredentialNamespace::Llm } else { CredentialNamespace::Asr }; - credentials.write(CredentialKey::new(namespace, Some(channel), key_account).unwrap(), SecretValue::new("fixture-key")).await.unwrap(); + let namespace = if kind == ProviderKind::Llm { + CredentialNamespace::Llm + } else { + CredentialNamespace::Asr + }; + credentials + .write( + CredentialKey::new(namespace, Some(channel), key_account).unwrap(), + SecretValue::new("fixture-key"), + ) + .await + .unwrap(); let result = service.list_models(request).await.unwrap(); assert_eq!(result.models, expected); let requests = transport.requests(); @@ -1684,22 +1716,44 @@ mod tests { #[tokio::test] async fn orcarouter_validation_uses_shared_audio_chat_transcription() { - let (endpoint, request) = spawn_http_response("200 OK", "application/json", - r#"{"choices":[{"message":{"content":"transcript"}}]}"#); + let (endpoint, request) = spawn_http_response( + "200 OK", + "application/json", + r#"{"choices":[{"message":{"content":"transcript"}}]}"#, + ); let credentials = Arc::new(InMemoryCredentialStore::default()); - let channel = create_channel_with_values(&credentials, ChannelKind::Asr, "orcarouter", &[ - (ASR_ENDPOINT_ACCOUNT, &endpoint), (ASR_API_KEY_ACCOUNT, "fixture-key"), - ]).await; + let channel = create_channel_with_values( + &credentials, + ChannelKind::Asr, + "orcarouter", + &[ + (ASR_ENDPOINT_ACCOUNT, &endpoint), + (ASR_API_KEY_ACCOUNT, "fixture-key"), + ], + ) + .await; let service = ProviderService::new(credentials, Arc::new(crate::TokioTaskSpawner)); - service.validate(ProviderRequest { kind: ProviderKind::Asr, channel_id: Some(channel), thinking_enabled: false }).await.unwrap(); - let request = String::from_utf8(request.recv_timeout(Duration::from_secs(2)).unwrap()).unwrap(); + service + .validate(ProviderRequest { + kind: ProviderKind::Asr, + channel_id: Some(channel), + thinking_enabled: false, + }) + .await + .unwrap(); + let request = + String::from_utf8(request.recv_timeout(Duration::from_secs(2)).unwrap()).unwrap(); assert!(request.starts_with("POST /v1/chat/completions ")); - let body: serde_json::Value = serde_json::from_str(request.split_once("\r\n\r\n").unwrap().1).unwrap(); + let body: serde_json::Value = + serde_json::from_str(request.split_once("\r\n\r\n").unwrap().1).unwrap(); assert_eq!(body["model"], crate::asr::mimo::ORCAROUTER_DEFAULT_MODEL); let content = &body["messages"][0]["content"]; assert_eq!(content[0]["type"], "text"); assert_eq!(content[1]["input_audio"]["format"], "wav"); - assert!(!content[1]["input_audio"]["data"].as_str().unwrap().starts_with("data:")); + assert!(!content[1]["input_audio"]["data"] + .as_str() + .unwrap() + .starts_with("data:")); } async fn service_with_fake_transport() -> (ProviderService, Arc, String) diff --git a/openless-all/app/crates/openless-core/src/settings.rs b/openless-all/app/crates/openless-core/src/settings.rs index 37d0f7ccf..a84c81a40 100644 --- a/openless-all/app/crates/openless-core/src/settings.rs +++ b/openless-all/app/crates/openless-core/src/settings.rs @@ -105,6 +105,8 @@ pub struct SettingsEffectPlan { #[serde(default, skip_serializing_if = "Option::is_none")] pub active_asr_provider: Option>, #[serde(default, skip_serializing_if = "Option::is_none")] + pub launch_at_login: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] pub windows_keyboard: Option>, } @@ -120,6 +122,7 @@ impl SettingsEffectPlan { previous.active_asr_provider.clone(), next.active_asr_provider.clone(), ), + launch_at_login: changed(previous.launch_at_login, next.launch_at_login), windows_keyboard: changed(previous.into(), next.into()), } } @@ -127,6 +130,7 @@ impl SettingsEffectPlan { pub fn is_empty(&self) -> bool { self.hotkeys.is_none() && self.active_asr_provider.is_none() + && self.launch_at_login.is_none() && self.windows_keyboard.is_none() } } @@ -136,6 +140,7 @@ impl SettingsEffectPlan { pub enum SettingsEffectKind { WindowsKeyboard, ActiveAsrProvider, + LaunchAtLogin, Hotkeys, } diff --git a/openless-all/app/crates/openless-core/src/vocabulary.rs b/openless-all/app/crates/openless-core/src/vocabulary.rs index 2066964ae..6d8229830 100644 --- a/openless-all/app/crates/openless-core/src/vocabulary.rs +++ b/openless-all/app/crates/openless-core/src/vocabulary.rs @@ -8,7 +8,7 @@ use chrono::Utc; use crate::errors::{BackendError, BackendErrorCode}; use crate::persistence::{atomic_write, persistence_error, read_or_default}; use crate::shared_types::LEARNED_VOCAB_NOTE; -use crate::types::{DictionaryEntry, VocabPresetStore}; +use crate::types::{DictionaryEntry, VocabPreset, VocabPresetStore}; /// Number of recently added manual entries that are guaranteed ASR hotword /// seats before hit-count ranking is applied. @@ -266,6 +266,34 @@ pub fn list_vocab_presets(data_dir: &Path) -> Result Vec { + serde_json::from_str(include_str!("../../../assets/vocab-presets.json")) + .expect("bundled vocabulary presets must be valid JSON") +} + +pub fn resolve_vocab_presets(store: &VocabPresetStore) -> Vec { + let mut presets = builtin_vocab_presets() + .into_iter() + .filter(|preset| !store.disabled_builtin_preset_ids.contains(&preset.id)) + .collect::>(); + for replacement in &store.overrides { + if let Some(existing) = presets + .iter_mut() + .find(|preset| preset.id == replacement.id) + { + *existing = replacement.clone(); + } + } + presets.extend( + store + .custom + .iter() + .filter(|preset| !preset.id.is_empty()) + .cloned(), + ); + presets +} + pub fn save_vocab_presets(data_dir: &Path, store: &VocabPresetStore) -> Result<(), BackendError> { let json = serde_json::to_vec_pretty(store) .map_err(|_| persistence_error("encode vocabulary presets"))?; @@ -355,6 +383,29 @@ mod tests { let _ = std::fs::remove_dir_all(dir); } + #[test] + fn bundled_presets_resolve_disables_overrides_and_custom_entries() { + let store = VocabPresetStore { + custom: vec![VocabPreset { + id: "custom".into(), + name: "自定义".into(), + phrases: vec!["OpenLess".into()], + }], + overrides: vec![VocabPreset { + id: "programmer".into(), + name: "工程师".into(), + phrases: vec!["Rust".into()], + }], + disabled_builtin_preset_ids: vec!["chef".into()], + }; + let resolved = resolve_vocab_presets(&store); + assert!(resolved.iter().any(|preset| preset.id == "custom")); + assert!(resolved + .iter() + .any(|preset| preset.id == "programmer" && preset.name == "工程师")); + assert!(!resolved.iter().any(|preset| preset.id == "chef")); + } + #[test] fn asr_priority_preserves_fresh_manual_entries_and_dedupes_case_variants() { let entry = |phrase: &str, hits: u64, note: Option<&str>| DictionaryEntry { diff --git a/openless-all/app/linux-egui/Cargo.toml b/openless-all/app/linux-egui/Cargo.toml index 1e35030d1..671c16536 100644 --- a/openless-all/app/linux-egui/Cargo.toml +++ b/openless-all/app/linux-egui/Cargo.toml @@ -13,16 +13,24 @@ tokio = { version = "1", features = ["io-util", "macros", "net", "process", "rt- fs2 = "0.4" futures-util = "0.3" log = "0.4" +simplelog = "0.12" +base64 = "0.22" +minisign-verify = "0.2.5" +reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "stream"] } +semver = "1" +chrono = { version = "0.4", default-features = false, features = ["clock", "std"] } serde = { version = "1", features = ["derive"] } serde_json = "1" uuid = { version = "1", features = ["v4"] } [target.'cfg(target_os = "linux")'.dependencies] dbus = "0.9" -arboard = { version = "3", features = ["wayland-data-control"] } keyring = { version = "3.6.3", default-features = false, features = ["linux-native-sync-persistent", "crypto-rust"] } -cpal = "0.15" -eframe = { version = "0.31", default-features = false, features = ["default_fonts", "glow", "wayland", "x11"] } +cpal = { version = "0.18.2", default-features = false, features = ["pipewire", "pulseaudio"] } +rfd = { version = "0.16", default-features = false, features = ["xdg-portal", "tokio"] } +egui = "=0.33.3" +eframe = { version = "=0.33.3", default-features = false, features = ["accesskit", "default_fonts", "glow", "wayland", "x11"] } +image = { version = "0.25.10", default-features = false, features = ["png"] } libc = "0.2" axum = { version = "0.7", default-features = false, features = ["ws", "http1", "tokio"] } hyper-util = { version = "0.1", features = ["tokio", "server-auto", "server", "http1"] } diff --git a/openless-all/app/linux-egui/examples/headless_host.rs b/openless-all/app/linux-egui/examples/headless_host.rs index d7c45080c..7e7e6bf04 100644 --- a/openless-all/app/linux-egui/examples/headless_host.rs +++ b/openless-all/app/linux-egui/examples/headless_host.rs @@ -168,7 +168,10 @@ async fn main() -> Result<(), BackendError> { backend .cancel_less_computer(Some(less_computer_session)) .await?; - assert!(backend.less_computer_capture_cancelled(less_computer_session)); + // Core 2.0 cancellation is terminal and releases the capture lease. A host + // that observes the cancellation after the await must not expect the old + // lease's flag to remain queryable. + assert_eq!(backend.less_computer_active_session(), None); backend.abort_less_computer_capture(less_computer_session)?; assert_eq!(backend.less_computer_active_session(), None); diff --git a/openless-all/app/linux-egui/src/audio.rs b/openless-all/app/linux-egui/src/audio.rs index 5ed9a5784..bae2831af 100644 --- a/openless-all/app/linux-egui/src/audio.rs +++ b/openless-all/app/linux-egui/src/audio.rs @@ -9,12 +9,24 @@ use openless_core::{ #[derive(Debug, Clone, Default)] pub struct LinuxCpalRecorder { preferred_device_name: Option, + recordings_dir: Option, } impl LinuxCpalRecorder { pub fn new(preferred_device_name: Option) -> Self { Self { preferred_device_name, + recordings_dir: None, + } + } + + pub fn with_recordings_dir( + preferred_device_name: Option, + recordings_dir: std::path::PathBuf, + ) -> Self { + Self { + preferred_device_name, + recordings_dir: Some(recordings_dir), } } } @@ -32,11 +44,22 @@ impl AudioRecorder for LinuxCpalRecorder { .microphone_device_name .clone() .or_else(|| self.preferred_device_name.clone()); + // Platform effect, applied by the host recorder exactly like the Tauri + // audio adapter. Never owned by Core; restore is the guard's Drop. + let mute_during_recording = context.recording.mute_during_recording; + let recordings_dir = self.recordings_dir.clone(); Box::pin(async move { #[cfg(target_os = "linux")] { tokio::task::spawn_blocking(move || { - start_linux_recording(session_id, preferred_device_name, consumer, progress) + start_linux_recording( + session_id, + preferred_device_name, + recordings_dir, + mute_during_recording, + consumer, + progress, + ) }) .await .map_err(|error| { @@ -63,10 +86,77 @@ struct LinuxActiveRecording { stop: Arc, thread: Option>, runtime_error: Arc>>, + archive: Option>, + /// Holds the output-mute guard while capture is live. Its `Drop` restores + /// the sink on every terminal path (stop/cancel/error/drop/shutdown). + mute: Option, +} + +impl Drop for LinuxActiveRecording { + fn drop(&mut self) { + // Taking the guard here forces the field to be consumed (and therefore + // restored) even if `stop()` is never reached — e.g. the handle is + // dropped directly on an early error or during Core shutdown before it + // had a chance to call stop. Double restore is harmless because Drop + // of an already-taken guard is a no-op. + self.mute.take(); + } +} + +#[cfg(target_os = "linux")] +struct LinuxRecordingArchive { + path: std::path::PathBuf, + available: Arc, +} + +#[cfg(target_os = "linux")] +impl openless_core::RecordingArchive for LinuxRecordingArchive { + fn is_available(&self) -> bool { + self.available.load(std::sync::atomic::Ordering::Acquire) + } + + fn read_pcm(&self) -> BoxFuture<'static, Result, BackendError>> { + let path = self.path.clone(); + Box::pin(async move { + let wav = tokio::fs::read(path).await.map_err(|error| { + BackendError::new( + BackendErrorCode::Persistence, + format!("read Linux recording archive: {error}"), + ) + })?; + canonical_wav_pcm(&wav).map(ToOwned::to_owned) + }) + } + + fn discard(&self) -> BoxFuture<'static, Result<(), BackendError>> { + let path = self.path.clone(); + let available = Arc::clone(&self.available); + Box::pin(async move { + match tokio::fs::remove_file(path).await { + Ok(()) => available.store(false, std::sync::atomic::Ordering::Release), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + available.store(false, std::sync::atomic::Ordering::Release); + } + Err(error) => { + return Err(BackendError::new( + BackendErrorCode::Persistence, + format!("discard Linux recording archive: {error}"), + )); + } + } + Ok(()) + }) + } } #[cfg(target_os = "linux")] impl ActiveRecording for LinuxActiveRecording { + fn archive(&self) -> Option> { + self.archive + .as_ref() + .map(|archive| Arc::clone(archive) as Arc) + } + fn stop(mut self: Box) -> BoxFuture<'static, Result<(), BackendError>> { Box::pin(async move { self.stop.store(true, std::sync::atomic::Ordering::Release); @@ -100,18 +190,48 @@ impl ActiveRecording for LinuxActiveRecording { #[cfg(target_os = "linux")] fn start_linux_recording( - _session_id: SessionId, + session_id: SessionId, preferred_device_name: Option, + recordings_dir: Option, + mute_during_recording: bool, consumer: Arc, progress: Arc, ) -> Result, BackendError> { use std::sync::atomic::AtomicBool; + // Mute is best-effort and independent of capture availability (mirrors the + // Tauri reference): if it fails we log and continue recording. If capture + // later fails on this path, the guard drops here and restores the sink. + let mute = if mute_during_recording { + match crate::audio_mute::AudioMuteGuard::activate() { + Ok(guard) => Some(guard), + Err(error) => { + log::warn!("[audio-mute] failed to mute output; capture continues: {error}"); + None + } + } + } else { + None + }; let stop = Arc::new(AtomicBool::new(false)); let runtime_error = Arc::new(std::sync::Mutex::new(None)); let (startup_tx, startup_rx) = std::sync::mpsc::sync_channel(1); let stop_for_thread = Arc::clone(&stop); let runtime_error_for_thread = Arc::clone(&runtime_error); + let (writer, archive) = match recordings_dir { + Some(directory) => { + match LinuxWavWriter::create(directory.join(format!("{session_id}.wav"))) { + Ok((writer, archive)) => { + (Some(Arc::new(std::sync::Mutex::new(writer))), Some(archive)) + } + Err(error) => { + log::warn!("failed to create Linux recording archive: {error}"); + (None, None) + } + } + } + None => (None, None), + }; let thread = std::thread::Builder::new() .name("openless-linux-recorder".to_string()) .spawn(move || { @@ -119,6 +239,7 @@ fn start_linux_recording( preferred_device_name, consumer, progress, + writer, stop_for_thread, runtime_error_for_thread, startup_tx, @@ -136,13 +257,17 @@ fn start_linux_recording( stop, thread: Some(thread), runtime_error, + archive, + mute, })), Ok(Err(error)) => { let _ = thread.join(); + // `mute` is dropped on the error path, restoring the sink. Err(error) } Err(error) => { let _ = thread.join(); + // `mute` is dropped on the error path, restoring the sink. Err(BackendError::new( BackendErrorCode::Platform, format!("Linux recorder thread exited during startup: {error}"), @@ -156,38 +281,53 @@ fn run_audio_thread( preferred_device_name: Option, consumer: Arc, progress: Arc, + writer: Option>>, stop: Arc, runtime_error: Arc>>, startup: std::sync::mpsc::SyncSender>, ) { - use cpal::traits::{DeviceTrait, StreamTrait}; - - let result = (|| { - let host = cpal::default_host(); - let device = select_input_device(&host, preferred_device_name.as_deref())?; - let supported = device - .default_input_config() - .map_err(|error| classify_audio_error("default input config", error.to_string()))?; - let sample_format = supported.sample_format(); - let input_sample_rate = supported.sample_rate().0; - let channels = usize::from(supported.channels()); - let config: cpal::StreamConfig = supported.into(); - let stream = build_input_stream( - &device, - &config, - sample_format, - input_sample_rate, - channels, - consumer, - progress, - Arc::clone(&stop), - runtime_error, - )?; - stream - .play() - .map_err(|error| classify_audio_error("start input stream", error.to_string()))?; - Ok::<_, BackendError>(stream) - })(); + let mut result = Err(BackendError::new( + BackendErrorCode::Platform, + "no Linux audio backend is available", + )); + for backend in audio_backend_order() { + let Some(host_id) = cpal::available_hosts() + .into_iter() + .find(|id| id.name().eq_ignore_ascii_case(backend)) + else { + continue; + }; + let host = match cpal::host_from_id(host_id) { + Ok(host) => host, + Err(error) => { + result = Err(classify_audio_error( + &format!("initialize {backend} backend"), + error.to_string(), + )); + log::warn!("{backend} audio backend unavailable: {error}"); + continue; + } + }; + match try_start_audio_stream( + &host, + backend, + preferred_device_name.as_deref(), + &consumer, + &progress, + &writer, + &stop, + &runtime_error, + ) { + Ok(stream) => { + result = Ok(stream); + break; + } + Err(error) => { + log::warn!("{backend} audio backend failed; trying next backend: {error}"); + result = Err(error); + } + } + } let stream = match result { Ok(stream) => { @@ -205,19 +345,68 @@ fn run_audio_thread( drop(stream); } +#[cfg(target_os = "linux")] +fn audio_backend_order() -> [&'static str; 3] { + // Native desktop servers are preferred because they handle device policy, + // hot-plugging and format conversion. ALSA remains the universal fallback. + ["pipewire", "pulseaudio", "alsa"] +} + +#[cfg(target_os = "linux")] +#[allow(clippy::too_many_arguments)] +fn try_start_audio_stream( + host: &cpal::Host, + backend: &str, + preferred_device_name: Option<&str>, + consumer: &Arc, + progress: &Arc, + writer: &Option>>, + stop: &Arc, + runtime_error: &Arc>>, +) -> Result { + use cpal::traits::{DeviceTrait, StreamTrait}; + + let device = select_input_device(host, preferred_device_name).map_err(|error| { + BackendError::new(error.code, format!("{backend} backend: {}", error.message)) + })?; + let supported = device + .default_input_config() + .map_err(|error| classify_audio_error("default input config", error.to_string()))?; + let sample_format = supported.sample_format(); + let input_sample_rate = supported.sample_rate(); + let channels = usize::from(supported.channels()); + let config: cpal::StreamConfig = supported.into(); + let stream = build_input_stream( + &device, + &config, + sample_format, + input_sample_rate, + channels, + Arc::clone(consumer), + Arc::clone(progress), + writer.clone(), + Arc::clone(stop), + Arc::clone(runtime_error), + )?; + stream + .play() + .map_err(|error| classify_audio_error("start input stream", error.to_string()))?; + Ok(stream) +} + #[cfg(target_os = "linux")] fn select_input_device( host: &cpal::Host, preferred_device_name: Option<&str>, ) -> Result { - use cpal::traits::{DeviceTrait, HostTrait}; + use cpal::traits::HostTrait; if let Some(preferred) = preferred_device_name.filter(|name| !name.trim().is_empty()) { let devices = host .input_devices() .map_err(|error| classify_audio_error("enumerate input devices", error.to_string()))?; for device in devices { - if device.name().ok().as_deref() == Some(preferred) { + if device.to_string() == preferred { return Ok(device); } } @@ -243,6 +432,7 @@ fn build_input_stream( channels: usize, consumer: Arc, progress: Arc, + writer: Option>>, stop: Arc, runtime_error: Arc>>, ) -> Result { @@ -254,17 +444,27 @@ fn build_input_stream( let progress = Arc::clone(&progress); let stop_for_error = Arc::clone(&stop); let runtime_error = Arc::clone(&runtime_error); + let writer = writer.clone(); let started = std::time::Instant::now(); let mut normalizer = openless_core::PcmNormalizer::default(); device .build_input_stream::<$sample, _, _>( - config, + *config, move |data: &[$sample], _| { let samples = data.iter().copied().map($to_f32).collect::>(); if let Some(chunk) = normalizer.process(&samples, channels, input_sample_rate) { consumer.consume_pcm_chunk(&chunk.pcm_i16_le); + if let Some(writer) = &writer { + if let Err(error) = writer + .lock() + .expect("Linux WAV writer lock poisoned") + .append(&chunk.pcm_i16_le) + { + log::warn!("Linux recording archive write failed: {error}"); + } + } let _ = progress .publish_level(started.elapsed().as_millis() as u64, chunk.level); } @@ -309,6 +509,103 @@ fn build_input_stream( } } +#[cfg(target_os = "linux")] +struct LinuxWavWriter { + file: std::fs::File, + path: std::path::PathBuf, + bytes_written: u32, + available: Arc, +} + +#[cfg(target_os = "linux")] +impl LinuxWavWriter { + fn create(path: std::path::PathBuf) -> std::io::Result<(Self, Arc)> { + use std::io::Write as _; + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + let mut file = std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&path)?; + file.write_all(&wav_header(0))?; + let available = Arc::new(std::sync::atomic::AtomicBool::new(true)); + let archive = Arc::new(LinuxRecordingArchive { + path: path.clone(), + available: Arc::clone(&available), + }); + Ok(( + Self { + file, + path, + bytes_written: 0, + available, + }, + archive, + )) + } + + fn append(&mut self, pcm: &[u8]) -> std::io::Result<()> { + use std::io::Write as _; + self.file.write_all(pcm)?; + self.bytes_written = self + .bytes_written + .saturating_add(pcm.len().min(u32::MAX as usize) as u32); + Ok(()) + } +} + +#[cfg(target_os = "linux")] +impl Drop for LinuxWavWriter { + fn drop(&mut self) { + use std::io::{Seek as _, SeekFrom, Write as _}; + let result = self + .file + .seek(SeekFrom::Start(0)) + .and_then(|_| self.file.write_all(&wav_header(self.bytes_written))) + .and_then(|_| self.file.sync_all()); + if let Err(error) = result { + self.available + .store(false, std::sync::atomic::Ordering::Release); + let _ = std::fs::remove_file(&self.path); + log::warn!("failed to finalize Linux recording archive: {error}"); + } + } +} + +pub(crate) fn wav_header(data_size: u32) -> [u8; 44] { + let mut header = [0u8; 44]; + header[0..4].copy_from_slice(b"RIFF"); + header[4..8].copy_from_slice(&data_size.saturating_add(36).to_le_bytes()); + header[8..12].copy_from_slice(b"WAVE"); + header[12..16].copy_from_slice(b"fmt "); + header[16..20].copy_from_slice(&16u32.to_le_bytes()); + header[20..22].copy_from_slice(&1u16.to_le_bytes()); + header[22..24].copy_from_slice(&1u16.to_le_bytes()); + header[24..28].copy_from_slice(&16_000u32.to_le_bytes()); + header[28..32].copy_from_slice(&32_000u32.to_le_bytes()); + header[32..34].copy_from_slice(&2u16.to_le_bytes()); + header[34..36].copy_from_slice(&16u16.to_le_bytes()); + header[36..40].copy_from_slice(b"data"); + header[40..44].copy_from_slice(&data_size.to_le_bytes()); + header +} + +fn canonical_wav_pcm(wav: &[u8]) -> Result<&[u8], BackendError> { + if wav.len() <= 44 + || &wav[..4] != b"RIFF" + || &wav[8..12] != b"WAVE" + || &wav[36..40] != b"data" + || !(wav.len() - 44).is_multiple_of(2) + { + return Err(BackendError::new( + BackendErrorCode::Persistence, + "Linux recording archive is not canonical 16 kHz mono PCM WAV", + )); + } + Ok(&wav[44..]) +} + #[cfg(any(target_os = "linux", test))] fn classify_audio_error(context: &str, message: String) -> BackendError { let lower = message.to_ascii_lowercase(); @@ -336,4 +633,18 @@ mod tests { BackendErrorCode::Platform ); } + + #[test] + fn wav_archive_header_and_pcm_round_trip() { + let pcm = [1u8, 0, 2, 0]; + let mut wav = wav_header(pcm.len() as u32).to_vec(); + wav.extend_from_slice(&pcm); + assert_eq!(canonical_wav_pcm(&wav).unwrap(), pcm); + } + + #[cfg(target_os = "linux")] + #[test] + fn audio_backends_are_ordered_from_desktop_server_to_universal_fallback() { + assert_eq!(audio_backend_order(), ["pipewire", "pulseaudio", "alsa"]); + } } diff --git a/openless-all/app/linux-egui/src/audio_cue.rs b/openless-all/app/linux-egui/src/audio_cue.rs new file mode 100644 index 000000000..0d65577e2 --- /dev/null +++ b/openless-all/app/linux-egui/src/audio_cue.rs @@ -0,0 +1,301 @@ +//! Native recording start/stop audio cues for Linux. +//! +//! The Windows/macOS Tauri shell synthesizes a "recording started" chime with +//! the Web Audio API in a webview (`app/src/lib/audioCue.ts`) and silences it +//! when the recording ends. This crate is the native egui host, so there is no +//! webview; the equivalent cue is rendered as PCM and played to the default +//! output sink with cpal (already a dependency for the microphone). +//! +//! Honesty rules: +//! - The frame is never blocked: `play_cue_start`/`play_cue_stop` enqueue a +//! detached worker thread and return immediately. Any real failure to open +//! the default output sink is logged and otherwise silent — a cue is +//! feedback, never a hard error, matching the reference's "silently degrade, +//! never throw" rule. +//! - Cues are gated by the caller on `audio_cue_on_record`, and the start cue +//! is additionally suppressed when `mute_during_recording` is active (an +//! audible start cue through a deliberately muted sink is both pointless and +//! a needless PipeWire/KDE sink-input blip). The stop cue may still play +//! after output is restored. +//! - Synthesis is pure (`render_cue_mono`) so it is unit-testable without any +//! audio device; the cpal playback path still needs real-device evidence on +//! X11/Wayland before it may be reported as verified. + +use std::sync::Arc; + +/// A single synthesized sine note relative to the cue start. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct CueTone { + /// Frequency in Hz. + pub freq_hz: f32, + /// Start offset from the cue start in milliseconds. + pub start_ms: f32, + /// Duration in milliseconds. + pub duration_ms: f32, + /// Exponential-envelope peak gain (0..1). + pub peak_gain: f32, +} + +/// "Recording started" chime: rising minor third (A5 -> C#6), mirroring the +/// reference Web Audio cue so Linux and Windows/macOS share one sound. +pub fn start_cue_tones() -> Vec { + vec![ + CueTone { + freq_hz: 880.0, + start_ms: 0.0, + duration_ms: 130.0, + peak_gain: 0.16, + }, + CueTone { + freq_hz: 1108.73, + start_ms: 95.0, + duration_ms: 170.0, + peak_gain: 0.18, + }, + ] +} + +/// "Recording ended" cue: descending minor third (E5 -> C5). Soft and short so +/// it reads as a clear "done" without masking the terminal feedback. +pub fn stop_cue_tones() -> Vec { + vec![ + CueTone { + freq_hz: 659.25, + start_ms: 0.0, + duration_ms: 120.0, + peak_gain: 0.13, + }, + CueTone { + freq_hz: 523.25, + start_ms: 90.0, + duration_ms: 150.0, + peak_gain: 0.15, + }, + ] +} + +/// Total cue duration in milliseconds (end of the last tone). +pub fn cue_total_duration_ms(tones: &[CueTone]) -> u32 { + tones.iter().fold(0u32, |acc, tone| { + acc.max((tone.start_ms + tone.duration_ms).round() as u32) + }) +} + +/// Render a cue to mono interleaved `f32` samples in `[-1, 1]`. Pure — no +/// device access — so it is fully unit-testable on any target. +pub fn render_cue_mono(tones: &[CueTone], sample_rate: u32) -> Vec { + if tones.is_empty() || sample_rate == 0 { + return Vec::new(); + } + let sr = sample_rate as f32; + let total_samples = + (((cue_total_duration_ms(tones) as f32) / 1000.0 * sr).ceil() as usize).max(1); + let mut out = vec![0.0f32; total_samples]; + for tone in tones { + let start = (tone.start_ms / 1000.0 * sr).round() as usize; + let dur = ((tone.duration_ms / 1000.0) * sr).round() as usize; + let attack = ((0.004 * sr).round() as usize).clamp(1, dur.max(1)); + let release_span = (dur.saturating_sub(attack)).max(1) as f32; + for i in 0..dur { + let idx = start + i; + if idx >= out.len() { + break; + } + let attack_env = if i < attack { + i as f32 / attack as f32 + } else { + 1.0 + }; + let release_env = if i >= attack { + let frac = (i - attack) as f32 / release_span; + (-5.0 * frac).exp() + } else { + 1.0 + }; + let env = attack_env * release_env; + let phase = std::f32::consts::TAU * tone.freq_hz * (idx as f32 / sr); + out[idx] += phase.sin() * tone.peak_gain * env; + } + } + for sample in &mut out { + *sample = sample.clamp(-1.0, 1.0); + } + out +} + +/// Play a start cue asynchronously (never blocks the caller/frame). +pub fn play_cue_start() { + play_cue(start_cue_tones()); +} + +/// Play a stop cue asynchronously (never blocks the caller/frame). +pub fn play_cue_stop() { + play_cue(stop_cue_tones()); +} + +/// Best-effort asynchronous playback on a detached worker thread. +fn play_cue(tones: Vec) { + if tones.is_empty() { + return; + } + std::thread::Builder::new() + .name("openless-audio-cue".to_string()) + .spawn(move || { + if let Err(error) = play_cue_blocking(&tones) { + log::debug!("[audio-cue] cue playback unavailable: {error}"); + } + }) + .map_err(|error| log::debug!("[audio-cue] failed to spawn cue thread: {error}")) + .ok(); +} + +#[cfg(target_os = "linux")] +fn play_cue_blocking(tones: &[CueTone]) -> Result<(), String> { + use cpal::traits::{DeviceTrait, HostTrait, StreamTrait}; + + let host = cpal::default_host(); + let device = host + .default_output_device() + .ok_or_else(|| "no Linux default output device".to_string())?; + let supported = device + .default_output_config() + .map_err(|error| format!("default output config failed: {error}"))?; + let sample_format = supported.sample_format(); + let sample_rate = supported.sample_rate(); + let channels = usize::from(supported.channels()).max(1); + let config: cpal::StreamConfig = supported.into(); + let mono = Arc::new(render_cue_mono(tones, sample_rate)); + if mono.is_empty() { + return Ok(()); + } + let idx = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let done = Arc::new(std::sync::atomic::AtomicBool::new(false)); + + let build = |format: cpal::SampleFormat| -> Result { + macro_rules! make { + ($ty:ty, $convert:expr) => {{ + let mono = Arc::clone(&mono); + let idx = Arc::clone(&idx); + let done = Arc::clone(&done); + let device = &device; + let config = &config; + device.build_output_stream::<$ty, _, _>( + *config, + move |data: &mut [$ty], _: &cpal::OutputCallbackInfo| { + let frames = data.len() / channels; + let mut pos = idx.load(std::sync::atomic::Ordering::Acquire); + for frame in 0..frames { + let sample = if pos < mono.len() { mono[pos] } else { 0.0 }; + pos += 1; + let converted = $convert(sample); + for channel in 0..channels { + data[frame * channels + channel] = converted; + } + } + if pos >= mono.len() { + done.store(true, std::sync::atomic::Ordering::Release); + } + idx.store(pos, std::sync::atomic::Ordering::Release); + }, + move |_error| {}, + None, + ) + }}; + } + match format { + cpal::SampleFormat::F32 => make!(f32, |s: f32| s.clamp(-1.0, 1.0)), + cpal::SampleFormat::I16 => { + make!(i16, |s: f32| (s.clamp(-1.0, 1.0) * i16::MAX as f32) as i16) + } + cpal::SampleFormat::U16 => make!(u16, |s: f32| { + (((s.clamp(-1.0, 1.0) + 1.0) / 2.0) * u16::MAX as f32) as u16 + }), + cpal::SampleFormat::I32 => { + make!(i32, |s: f32| (s.clamp(-1.0, 1.0) * i32::MAX as f32) as i32) + } + other => { + // Unusual sink format: fall back to f32 which most Linux sinks + // accept even when it is not the default config. + let _ = other; + make!(f32, |s: f32| s.clamp(-1.0, 1.0)) + } + } + }; + + let stream = build(sample_format) + .or_else(|_| build(cpal::SampleFormat::F32)) + .map_err(|error| format!("build output stream failed: {error}"))?; + stream + .play() + .map_err(|error| format!("start output stream failed: {error}"))?; + + // Keep the stream alive on this thread until the cue buffer is consumed or + // a short watchdog elapses, then drop it to release the sink. + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2); + while !done.load(std::sync::atomic::Ordering::Acquire) && std::time::Instant::now() < deadline { + std::thread::sleep(std::time::Duration::from_millis(5)); + } + drop(stream); + Ok(()) +} + +#[cfg(not(target_os = "linux"))] +fn play_cue_blocking(_tones: &[CueTone]) -> Result<(), String> { + Err("audio cue playback is only available on Linux".to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn start_cue_is_a_rising_two_tone_and_stop_is_descending() { + let start = start_cue_tones(); + assert_eq!(start.len(), 2); + assert!(start[0].freq_hz < start[1].freq_hz, "start cue rises"); + let stop = stop_cue_tones(); + assert_eq!(stop.len(), 2); + assert!(stop[0].freq_hz > stop[1].freq_hz, "stop cue descends"); + } + + #[test] + fn total_duration_is_last_tone_end() { + let start = start_cue_tones(); + assert_eq!(cue_total_duration_ms(&start), 265); + assert!(cue_total_duration_ms(&stop_cue_tones()) > 0); + } + + #[test] + fn rendering_is_bounded_nonempty_and_expected_length() { + let sr = 48_000; + let mono = render_cue_mono(&start_cue_tones(), sr); + let expected = ((cue_total_duration_ms(&start_cue_tones()) as f32 / 1000.0) * sr as f32) + .ceil() as usize; + assert_eq!(mono.len(), expected); + assert!(mono.iter().any(|s| s.abs() > 1e-3), "cue is not silent"); + assert!( + mono.iter().all(|s| (-1.0..=1.0).contains(s)), + "cue stays within [-1, 1]" + ); + // Envelope is peak-limited well below full scale so it never clips. + let peak = mono.iter().fold(0.0f32, |m, s| m.max(s.abs())); + assert!( + peak <= 0.34, + "start cue peak {peak} stays under envelope sum" + ); + } + + #[test] + fn empty_tones_render_to_empty_and_play_is_a_noop() { + assert!(render_cue_mono(&[], 44_100).is_empty()); + play_cue(Vec::new()); + } + + #[test] + fn mono_cue_respects_sample_rate_scaling() { + let at_44k = render_cue_mono(&stop_cue_tones(), 44_100); + let at_48k = render_cue_mono(&stop_cue_tones(), 48_000); + // Higher sample rate yields proportionally more samples for the same cue. + assert!(at_48k.len() > at_44k.len()); + } +} diff --git a/openless-all/app/linux-egui/src/audio_mute.rs b/openless-all/app/linux-egui/src/audio_mute.rs new file mode 100644 index 000000000..863d2d15e --- /dev/null +++ b/openless-all/app/linux-egui/src/audio_mute.rs @@ -0,0 +1,212 @@ +//! Temporary system-output mute while recording. +//! +//! Linux parity with the legacy desktop [`AudioMuteGuard`] behavior. +//! `mute_during_recording` is applied by +//! the host recorder (never by Core) so the capture itself never hears the +//! user's speakers. Restore is guaranteed by RAII: the guard restores the +//! previous mute state when it is dropped, which happens on every terminal +//! path of the recorder lifecycle — normal stop, cancel, runtime fault/error +//! and process shutdown — because Core consumes/drops the active recording +//! handle on each of those. +//! +//! Muting is intentionally best-effort like the reference: if neither `wpctl` +//! (PipeWire) nor `pactl` (PulseAudio) can drive the default sink, activation +//! fails but capture still proceeds. The parsed state helpers are pure so +//! they can be unit-tested without a live audio server. + +/// The output-mute backend discovered at activation time. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum MuteBackend { + Wpctl, + Pactl, +} + +/// Guard that restores the previous default-sink mute state on drop. +#[derive(Debug)] +pub struct AudioMuteGuard { + inner: Option, +} + +impl AudioMuteGuard { + /// Mute the default output sink unless it is already muted, capturing the + /// previous state for later restore. Best-effort: on failure returns an + /// error and leaves the sink untouched. + pub fn activate() -> Result { + Ok(Self { + inner: Some(platform::activate()?), + }) + } + + /// A no-op guard used when `mute_during_recording` is disabled. + pub fn none() -> Self { + Self { inner: None } + } +} + +impl Drop for AudioMuteGuard { + fn drop(&mut self) { + if let Some(inner) = self.inner.take() { + inner.restore(); + } + } +} + +/// `true` when a `wpctl get-volume` capture reports the sink muted. +pub fn parse_wpctl_muted(output: &str) -> bool { + output.contains("[MUTED]") +} + +/// `true` when a `pactl get-sink-mute` capture reports the sink muted. +pub fn parse_pactl_muted(output: &str) -> bool { + let lower = output.to_ascii_lowercase(); + lower.contains("yes") || output.contains("是") +} + +#[cfg(target_os = "linux")] +mod platform { + use super::{parse_pactl_muted, parse_wpctl_muted, MuteBackend}; + use std::process::Command; + + #[derive(Debug)] + pub struct PlatformMuteGuard { + backend: MuteBackend, + was_muted: bool, + } + + pub fn activate() -> Result { + if let Ok(was_muted) = wpctl_muted() { + if !was_muted { + set_wpctl_muted(true)?; + } + return Ok(PlatformMuteGuard { + backend: MuteBackend::Wpctl, + was_muted, + }); + } + let was_muted = pactl_muted()?; + if !was_muted { + set_pactl_muted(true)?; + } + Ok(PlatformMuteGuard { + backend: MuteBackend::Pactl, + was_muted, + }) + } + + impl PlatformMuteGuard { + pub fn restore(self) { + let result = match self.backend { + MuteBackend::Wpctl => set_wpctl_muted(self.was_muted), + MuteBackend::Pactl => set_pactl_muted(self.was_muted), + }; + if let Err(error) = result { + log::warn!("[audio-mute] restore output mute failed: {error}"); + } + } + } + + fn wpctl_muted() -> Result { + let output = Command::new("wpctl") + .args(["get-volume", "@DEFAULT_AUDIO_SINK@"]) + .output() + .map_err(|error| format!("wpctl get-volume failed: {error}"))?; + if !output.status.success() { + return Err(std::str::from_utf8(&output.stderr) + .unwrap_or_default() + .trim() + .to_string()); + } + Ok(parse_wpctl_muted( + std::str::from_utf8(&output.stdout).unwrap_or_default(), + )) + } + + fn set_wpctl_muted(muted: bool) -> Result<(), String> { + let value = if muted { "1" } else { "0" }; + let output = Command::new("wpctl") + .args(["set-mute", "@DEFAULT_AUDIO_SINK@", value]) + .output() + .map_err(|error| format!("wpctl set-mute failed: {error}"))?; + if output.status.success() { + Ok(()) + } else { + Err(std::str::from_utf8(&output.stderr) + .unwrap_or_default() + .trim() + .to_string()) + } + } + + fn pactl_muted() -> Result { + let output = Command::new("pactl") + .args(["get-sink-mute", "@DEFAULT_SINK@"]) + .output() + .map_err(|error| format!("pactl get-sink-mute failed: {error}"))?; + if !output.status.success() { + return Err(std::str::from_utf8(&output.stderr) + .unwrap_or_default() + .trim() + .to_string()); + } + Ok(parse_pactl_muted( + std::str::from_utf8(&output.stdout).unwrap_or_default(), + )) + } + + fn set_pactl_muted(muted: bool) -> Result<(), String> { + let value = if muted { "1" } else { "0" }; + let output = Command::new("pactl") + .args(["set-sink-mute", "@DEFAULT_SINK@", value]) + .output() + .map_err(|error| format!("pactl set-sink-mute failed: {error}"))?; + if output.status.success() { + Ok(()) + } else { + Err(std::str::from_utf8(&output.stderr) + .unwrap_or_default() + .trim() + .to_string()) + } + } +} + +#[cfg(not(target_os = "linux"))] +mod platform { + #[derive(Debug)] + pub struct PlatformMuteGuard; + + pub fn activate() -> Result { + Err("output mute is not supported on this platform".to_string()) + } + + impl PlatformMuteGuard { + pub fn restore(self) {} + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn wpctl_output_parser_detects_muted_flag() { + assert!(parse_wpctl_muted("Volume: 0.00 [MUTED]")); + assert!(!parse_wpctl_muted("Volume: 0.82")); + } + + #[test] + fn pactl_output_parser_detects_muted_flag_in_any_locale() { + assert!(parse_pactl_muted("Mute: yes")); + assert!(parse_pactl_muted("静音:是")); + assert!(!parse_pactl_muted("Mute: no")); + assert!(!parse_pactl_muted("静音:否")); + } + + #[test] + fn disabled_guard_is_a_noop_that_drops_without_panicking() { + // `none()` is a pure no-op guard; dropping it must not panic and has no + // command side effect to assert, so we only verify construction/drop. + let guard = AudioMuteGuard::none(); + drop(guard); + } +} diff --git a/openless-all/app/linux-egui/src/backend.rs b/openless-all/app/linux-egui/src/backend.rs index 7c1ff6a7f..a02dc6665 100644 --- a/openless-all/app/linux-egui/src/backend.rs +++ b/openless-all/app/linux-egui/src/backend.rs @@ -3,14 +3,13 @@ use std::sync::Arc; use futures_util::future::BoxFuture; use openless_core::{ - AudioConsumer, AudioRecorder, BackendConfig, BackendDependencies, BackendError, - BackendErrorCode, BackendRepositories, BackendServices, CredentialStore, DictationEngine, - DictationEngineRouter, MarketplaceConfig, ModelStore, ModelStoreConfig, OpenLessBackend, - PipelineDictationEngine, PolishFailurePolicy, ProviderService, SettingsRuntime, - SharedAuxiliaryTextPolisher, SharedCloudTextPolisher, SharedCloudTranscriptionEngine, - SharedOmniDictationEngine, TextInserter, TextPolisher, TextPolisherRouter, TextStreamSink, - TranscriptOutput, TranscriptionEngine, TranscriptionRouter, TranscriptionSession, - SHARED_CLOUD_ASR_PROVIDER_TYPES, SHARED_CLOUD_LLM_PROVIDER_TYPES, SHARED_OMNI_PROVIDER_TYPES, + AudioRecorder, BackendConfig, BackendDependencies, BackendError, BackendErrorCode, + BackendRepositories, BackendServices, CredentialStore, DictationEngine, DictationEngineRouter, + MarketplaceConfig, OpenLessBackend, PipelineDictationEngine, PolishFailurePolicy, + ProviderService, SettingsRuntime, SharedAuxiliaryTextPolisher, SharedCloudTextPolisher, + SharedCloudTranscriptionEngine, SharedOmniDictationEngine, TextInserter, TextPolisher, + TextPolisherRouter, TranscriptionEngine, TranscriptionRouter, SHARED_CLOUD_ASR_PROVIDER_TYPES, + SHARED_CLOUD_LLM_PROVIDER_TYPES, SHARED_OMNI_PROVIDER_TYPES, }; use crate::qa::LinuxQaRuntime; @@ -47,619 +46,6 @@ impl openless_core::TaskSpawner for LinuxTaskSpawner { } } -const QWEN_PREPARE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5); -const QWEN_TRANSCRIBE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(300); - -#[derive(Debug)] -struct QwenProcessOutput { - status: std::process::ExitStatus, - stdout: Vec, - stderr: Vec, -} - -async fn run_qwen_process( - executable: std::path::PathBuf, - args: Vec, - stdin: Option>, - cancelled: Arc, - timeout: std::time::Duration, -) -> Result { - use tokio::io::{AsyncReadExt, AsyncWriteExt}; - - if cancelled.load(std::sync::atomic::Ordering::Acquire) { - return Err(BackendError::new( - BackendErrorCode::Cancelled, - "Qwen ASR operation cancelled", - )); - } - let mut command = tokio::process::Command::new(&executable); - command - .args(args) - .stdin(if stdin.is_some() { - std::process::Stdio::piped() - } else { - std::process::Stdio::null() - }) - .stdout(std::process::Stdio::piped()) - .stderr(std::process::Stdio::piped()) - .kill_on_drop(true); - crate::coding_agent::isolate_process_group(&mut command); - let mut child = command.spawn().map_err(|error| { - BackendError::new( - if matches!( - error.kind(), - std::io::ErrorKind::NotFound | std::io::ErrorKind::PermissionDenied - ) { - BackendErrorCode::Unsupported - } else { - BackendErrorCode::Platform - }, - format!("Qwen ASR runtime unavailable: {error}"), - ) - })?; - let stdin_task = stdin.map(|input| { - let mut pipe = child.stdin.take().expect("piped Qwen stdin"); - tokio::spawn(async move { - pipe.write_all(&input).await?; - pipe.shutdown().await - }) - }); - let mut stdout = child.stdout.take().expect("piped Qwen stdout"); - let mut stderr = child.stderr.take().expect("piped Qwen stderr"); - let stdout_task = tokio::spawn(async move { - let mut bytes = Vec::new(); - stdout.read_to_end(&mut bytes).await.map(|_| bytes) - }); - let stderr_task = tokio::spawn(async move { - let mut bytes = Vec::new(); - stderr.read_to_end(&mut bytes).await.map(|_| bytes) - }); - let started = tokio::time::Instant::now(); - let status = loop { - tokio::select! { - status = child.wait() => break Ok(status.map_err(qwen_platform_error)?), - _ = tokio::time::sleep(std::time::Duration::from_millis(20)) => { - let error = if cancelled.load(std::sync::atomic::Ordering::Acquire) { - Some(BackendError::new( - BackendErrorCode::Cancelled, - "Qwen ASR operation cancelled", - )) - } else if started.elapsed() >= timeout { - Some(BackendError::new( - BackendErrorCode::Provider, - "Qwen ASR operation timed out", - ).retryable(true)) - } else { - None - }; - if let Some(error) = error { - crate::coding_agent::kill_process_group(&mut child)?; - let _ = child.wait().await; - break Err(error); - } - } - } - }; - let stdin_result = match stdin_task { - Some(task) => Some(task.await.map_err(qwen_internal_error)?), - None => None, - }; - let stdout = stdout_task - .await - .map_err(qwen_internal_error)? - .map_err(qwen_platform_error)?; - let stderr = stderr_task - .await - .map_err(qwen_internal_error)? - .map_err(qwen_platform_error)?; - let status = status?; - if status.success() { - if let Some(result) = stdin_result { - result.map_err(qwen_platform_error)?; - } - } - Ok(QwenProcessOutput { - status, - stdout, - stderr, - }) -} - -fn qwen_platform_error(error: impl std::fmt::Display) -> BackendError { - BackendError::new(BackendErrorCode::Platform, error.to_string()) -} - -fn qwen_internal_error(error: impl std::fmt::Display) -> BackendError { - BackendError::new(BackendErrorCode::Internal, error.to_string()) -} - -fn qwen_executable() -> Option { - crate::resources::detect_qwen_runtime_path() - .ok() - .filter(|path| qwen_executable_is_available(path)) -} - -fn qwen_executable_is_available(path: &std::path::Path) -> bool { - let Ok(metadata) = path.metadata() else { - return false; - }; - if !metadata.is_file() { - return false; - } - #[cfg(target_os = "linux")] - { - use std::os::unix::fs::PermissionsExt; - metadata.permissions().mode() & 0o111 != 0 - } - #[cfg(not(target_os = "linux"))] - true -} - -struct LinuxGenericAsrEngine { - root: std::sync::Arc>, - executable: Option, -} - -struct LinuxGenericLocalAsrRuntime { - root: std::sync::Arc>, - loaded_model: std::sync::Arc>>, - // ponytail: one shared cancellation flag serializes model operations; per-model tokens if parallel downloads are needed. - cancelled: std::sync::Arc, - executable: Option, -} - -impl Default for LinuxGenericLocalAsrRuntime { - fn default() -> Self { - Self::from_models_root(Self::default_root(), qwen_executable()) - } -} - -impl LinuxGenericLocalAsrRuntime { - const READY_SENTINEL: &'static str = openless_core::MODEL_READY_SENTINEL; - - fn default_root() -> std::path::PathBuf { - if let Some(data) = std::env::var_os("XDG_DATA_HOME") { - return std::path::PathBuf::from(data) - .join("OpenLess") - .join("models"); - } - std::env::var_os("HOME") - .map(std::path::PathBuf::from) - .unwrap_or_else(std::env::temp_dir) - .join(".local") - .join("share") - .join("OpenLess") - .join("models") - } - - fn from_models_root(root: std::path::PathBuf, executable: Option) -> Self { - Self { - root: std::sync::Arc::new(std::sync::Mutex::new(root)), - loaded_model: std::sync::Arc::new(std::sync::Mutex::new(None)), - cancelled: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)), - executable, - } - } - - fn is_ready_dir(dir: &std::path::Path) -> bool { - dir.join(Self::READY_SENTINEL).is_file() - } - - fn ensure_qwen_target(target: &openless_core::LocalAsrTarget) -> Result<(), BackendError> { - let supported = target.runtime == openless_core::LocalAsrRuntime::Generic - && openless_core::LocalAsrModelId::from_wire_id(target.model_id()) - .is_some_and(openless_core::LocalAsrModelId::is_qwen); - if supported { - Ok(()) - } else { - Err(BackendError::new( - BackendErrorCode::Unsupported, - "Linux local ASR currently supports Qwen models only", - )) - } - } -} - -pub(crate) fn qwen_engine_available() -> bool { - qwen_executable().is_some() -} - -impl openless_core::ModelRuntimeAdapter for LinuxGenericLocalAsrRuntime { - fn engine_available(&self, runtime: openless_core::LocalAsrRuntime) -> bool { - runtime == openless_core::LocalAsrRuntime::Generic - && self - .executable - .as_deref() - .is_some_and(qwen_executable_is_available) - } - - fn supports_model(&self, target: &openless_core::LocalAsrTarget) -> bool { - Self::ensure_qwen_target(target).is_ok() - } - - fn rebind_storage( - &self, - models_root: std::path::PathBuf, - ) -> BoxFuture<'static, Result> { - *self.root.lock().expect("Linux ASR root lock poisoned") = models_root; - Box::pin(async { Ok(openless_core::StorageRebind::Applied) }) - } - - fn runtime_status( - &self, - settings: openless_core::LocalAsrSettings, - _model_dir: std::path::PathBuf, - ) -> BoxFuture<'static, Result> { - let available = self.engine_available(settings.runtime); - let loaded_model = std::sync::Arc::clone(&self.loaded_model); - Box::pin(async move { - let loaded = loaded_model - .lock() - .expect("Linux ASR loaded lock poisoned") - .clone(); - let loaded = available.then_some(loaded).flatten(); - Ok(openless_core::LocalAsrRuntimeStatus { - runtime: settings.runtime, - provider_id: settings.provider_id, - available, - loaded: loaded.is_some(), - active_model: settings.active_model.clone(), - model_id: loaded, - keep_loaded_secs: settings.keep_loaded_secs, - runtime_source: settings.runtime_source, - endpoint: None, - operation: None, - error: (!available).then(|| "packaged Qwen ASR runtime is not available".into()), - last_error: None, - last_prepare_ms: None, - last_transcribe_ms: None, - last_audio_ms: None, - }) - }) - } - - fn prepare( - &self, - target: openless_core::LocalAsrTarget, - _source: openless_core::FoundryRuntimeSource, - dir: std::path::PathBuf, - _progress: openless_core::ModelPrepareProgressSink, - ) -> BoxFuture<'static, Result> { - if let Err(error) = Self::ensure_qwen_target(&target) { - return Box::pin(async move { Err(error) }); - } - if let Some(root) = dir.parent() { - *self.root.lock().expect("Linux ASR root lock poisoned") = root.to_path_buf(); - } - let executable = self.executable.clone(); - let loaded = std::sync::Arc::clone(&self.loaded_model); - let cancelled = std::sync::Arc::clone(&self.cancelled); - Box::pin(async move { - cancelled.store(false, std::sync::atomic::Ordering::Release); - if !Self::is_ready_dir(&dir) { - return Err(BackendError::new( - BackendErrorCode::InvalidState, - format!("local ASR model is not downloaded: {}", target.model_id()), - )); - } - let executable = executable.ok_or_else(|| { - BackendError::new( - BackendErrorCode::Unsupported, - "packaged Qwen ASR runtime is not available", - ) - })?; - let output = run_qwen_process( - executable, - vec!["--help".into()], - None, - Arc::clone(&cancelled), - QWEN_PREPARE_TIMEOUT, - ) - .await?; - if !output.status.success() { - return Err(BackendError::new( - BackendErrorCode::Unsupported, - "packaged Qwen ASR runtime failed its self-check", - )); - } - if cancelled.load(std::sync::atomic::Ordering::Acquire) { - return Err(BackendError::new( - BackendErrorCode::Cancelled, - "Qwen ASR operation cancelled", - )); - } - *loaded.lock().expect("Linux ASR loaded lock poisoned") = - Some(target.model_id().to_string()); - Ok(target.model_id().to_string()) - }) - } - - fn release( - &self, - runtime: openless_core::LocalAsrRuntime, - ) -> BoxFuture<'static, Result<(), BackendError>> { - let loaded = std::sync::Arc::clone(&self.loaded_model); - Box::pin(async move { - if runtime != openless_core::LocalAsrRuntime::Generic { - return Ok(()); - } - *loaded.lock().expect("Linux ASR loaded lock poisoned") = None; - Ok(()) - }) - } - - fn release_lease( - &self, - lease: openless_core::LocalAsrRuntimeLease, - ) -> BoxFuture<'static, Result<(), BackendError>> { - let loaded = Arc::clone(&self.loaded_model); - Box::pin(async move { - let mut current = loaded.lock().expect("Linux ASR loaded lock poisoned"); - if current.as_deref() == Some(lease.target.model_id()) { - *current = None; - } - Ok(()) - }) - } - - fn preload( - &self, - target: openless_core::LocalAsrTarget, - model_dir: std::path::PathBuf, - _provider_type: String, - ) -> BoxFuture<'static, Result<(), BackendError>> { - if let Err(error) = Self::ensure_qwen_target(&target) { - return Box::pin(async move { Err(error) }); - } - if let Some(root) = model_dir.parent() { - *self.root.lock().expect("Linux ASR root lock poisoned") = root.to_path_buf(); - } - let loaded = std::sync::Arc::clone(&self.loaded_model); - Box::pin(async move { - if loaded - .lock() - .expect("Linux ASR loaded lock poisoned") - .as_deref() - != Some(target.model_id()) - { - return Err(BackendError::new( - BackendErrorCode::InvalidState, - "prepare the selected local ASR model before preloading", - )); - } - Ok(()) - }) - } - - fn cancel_prepare( - &self, - runtime: openless_core::LocalAsrRuntime, - ) -> BoxFuture<'static, Result<(), BackendError>> { - if runtime != openless_core::LocalAsrRuntime::Generic { - return Box::pin(async { - Err(BackendError::new( - BackendErrorCode::Unsupported, - "Linux Generic/Qwen runtime only supports generic models", - )) - }); - } - self.cancelled - .store(true, std::sync::atomic::Ordering::Release); - Box::pin(async { Ok(()) }) - } - - fn test_model( - &self, - target: openless_core::LocalAsrTarget, - dir: std::path::PathBuf, - ) -> BoxFuture<'static, Result> { - if let Err(error) = Self::ensure_qwen_target(&target) { - return Box::pin(async move { Err(error) }); - } - let executable = self.executable.clone(); - let cancelled = Arc::clone(&self.cancelled); - Box::pin(async move { - if !Self::is_ready_dir(&dir) { - return Err(BackendError::new( - BackendErrorCode::InvalidState, - "local ASR model is not downloaded", - )); - } - let audio = std::env::var_os("OPENLESS_QWEN_ASR_TEST_AUDIO") - .map(std::path::PathBuf::from) - .filter(|path| path.is_file()) - .ok_or_else(|| { - BackendError::new( - BackendErrorCode::Unsupported, - "set OPENLESS_QWEN_ASR_TEST_AUDIO to an audio fixture for model testing", - ) - })?; - let executable = executable.ok_or_else(|| { - BackendError::new( - BackendErrorCode::Unsupported, - "packaged Qwen ASR runtime is not available", - ) - })?; - cancelled.store(false, std::sync::atomic::Ordering::Release); - let started = std::time::Instant::now(); - let output = run_qwen_process( - executable.clone(), - vec![ - "-d".into(), - dir.into_os_string(), - "-i".into(), - audio.into_os_string(), - "--silent".into(), - ], - None, - Arc::clone(&cancelled), - QWEN_TRANSCRIBE_TIMEOUT, - ) - .await?; - if cancelled.load(std::sync::atomic::Ordering::Acquire) { - return Err(BackendError::new( - BackendErrorCode::Cancelled, - "Qwen ASR operation cancelled", - )); - } - if !output.status.success() { - return Err(BackendError::new( - BackendErrorCode::Provider, - String::from_utf8_lossy(&output.stderr).trim().to_string(), - )); - } - Ok(openless_core::LocalAsrTestResult { - target, - backend: executable.to_string_lossy().into_owned(), - expected_text: std::env::var("OPENLESS_QWEN_ASR_TEST_EXPECTED").unwrap_or_default(), - transcribed_text: String::from_utf8_lossy(&output.stdout).trim().to_string(), - audio_ms: 0, - load_ms: 0, - transcribe_ms: started.elapsed().as_millis() as u64, - }) - }) - } -} - -struct LinuxGenericAsrSession { - model: Option, - root: std::sync::Arc>, - pcm: std::sync::Mutex>, - cancelled: std::sync::Arc, - executable: std::path::PathBuf, -} - -impl TranscriptionEngine for LinuxGenericAsrEngine { - fn start( - &self, - _session_id: openless_core::SessionId, - context: std::sync::Arc, - _partials: std::sync::Arc, - ) -> BoxFuture<'static, Result, BackendError>> { - let Some(executable) = self.executable.clone() else { - return Box::pin(async { - Err(BackendError::new( - BackendErrorCode::Unsupported, - "packaged Qwen ASR runtime is not available", - )) - }); - }; - let session: std::sync::Arc = - std::sync::Arc::new(LinuxGenericAsrSession { - model: context.asr.model.clone(), - root: std::sync::Arc::clone(&self.root), - pcm: std::sync::Mutex::new(Vec::new()), - cancelled: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)), - executable, - }); - Box::pin(async move { Ok(session) }) - } -} - -impl AudioConsumer for LinuxGenericAsrSession { - fn consume_pcm_chunk(&self, pcm: &[u8]) { - if !self.cancelled.load(std::sync::atomic::Ordering::Acquire) { - self.pcm - .lock() - .expect("Linux generic ASR PCM lock poisoned") - .extend_from_slice(pcm); - } - } -} - -impl TranscriptionSession for LinuxGenericAsrSession { - fn finish(&self) -> BoxFuture<'static, Result> { - let pcm = std::mem::take( - &mut *self - .pcm - .lock() - .expect("Linux generic ASR PCM lock poisoned"), - ); - let model = self.model.clone(); - let root = std::sync::Arc::clone(&self.root); - let cancelled = std::sync::Arc::clone(&self.cancelled); - let executable = self.executable.clone(); - Box::pin(async move { - if cancelled.load(std::sync::atomic::Ordering::Acquire) { - return Err(BackendError::new( - BackendErrorCode::Cancelled, - "ASR cancelled", - )); - } - let duration_ms = (pcm.len() as u64).saturating_mul(1000) / 32_000; - if pcm.is_empty() { - return Ok(TranscriptOutput { - text: String::new(), - duration_ms, - }); - } - let model = model - .filter(|model| !model.trim().is_empty()) - .ok_or_else(|| { - BackendError::new( - BackendErrorCode::InvalidState, - "Qwen ASR model is not selected", - ) - })?; - let model_dir = root - .lock() - .expect("Linux ASR root lock poisoned") - .join(&model); - if !LinuxGenericLocalAsrRuntime::is_ready_dir(&model_dir) { - return Err(BackendError::new( - BackendErrorCode::InvalidState, - format!("Linux local ASR model is not prepared: {model}"), - )); - } - let output = run_qwen_process( - executable, - vec![ - "--stdin".into(), - "--silent".into(), - "-d".into(), - model_dir.into_os_string(), - ], - Some(openless_core::encode_dictation_wav(&pcm)?), - Arc::clone(&cancelled), - QWEN_TRANSCRIBE_TIMEOUT, - ) - .await?; - if cancelled.load(std::sync::atomic::Ordering::Acquire) { - return Err(BackendError::new( - BackendErrorCode::Cancelled, - "ASR cancelled", - )); - } - if !output.status.success() { - let message = String::from_utf8_lossy(&output.stderr).trim().to_string(); - return Err(BackendError::new( - BackendErrorCode::Provider, - if message.is_empty() { - "Linux Generic/Qwen ASR failed".into() - } else { - message - }, - )); - } - let result = String::from_utf8_lossy(&output.stdout).trim().to_string(); - Ok(TranscriptOutput { - text: result, - duration_ms, - }) - }) - } - - fn cancel(&self) -> BoxFuture<'static, Result<(), BackendError>> { - self.cancelled - .store(true, std::sync::atomic::Ordering::Release); - self.pcm - .lock() - .expect("Linux generic ASR PCM lock poisoned") - .clear(); - Box::pin(async { Ok(()) }) - } -} - /// Assemble the non-UI Linux runtime from shared provider Interfaces. /// /// The egui team only supplies a repaint callback and consumes the returned @@ -677,7 +63,6 @@ pub struct LinuxBackendBuilder { services: Option, host_actions: Option>, settings_runtime: Option>, - local_asr_runtime: Option>, polish_failure_policy: PolishFailurePolicy, task_spawner: Option>, } @@ -701,10 +86,11 @@ impl LinuxBackendBuilder { // their isolation must also hold when linked against a production lib. if let Some(home_dir) = config.home_dir.as_deref() { if let Err(error) = store.migrate_legacy(home_dir) { - // A locked/unavailable Secret Service must not disable local ASR. - // The migration marker remains unset, so unlocking and restarting - // retries the original sources. Log only the classification, never - // a provider/keyring message that might contain secret values. + // A locked/unavailable Secret Service is not a reason to block + // construction: the migration marker remains unset, so unlocking + // and restarting retries the original sources. Log only the + // classification, never a provider/keyring message that might + // contain secret values. log::warn!( "Legacy credential migration is incomplete ({:?}); unlock the credential vault and restart to retry. Original credentials were retained.", error.code @@ -722,27 +108,8 @@ impl LinuxBackendBuilder { for provider_type in SHARED_CLOUD_ASR_PROVIDER_TYPES { transcription.register(*provider_type, Arc::clone(&cloud_transcription))?; } - let configured_models_root = - openless_core::PreferencesStore::open(config.data_dir.join("preferences.json")) - .ok() - .map(|preferences| preferences.get().local_asr_models_base_dir) - .filter(|base| !base.trim().is_empty()) - .map(std::path::PathBuf::from) - .filter(|base| base.is_absolute()) - .map(|base| base.join("OpenLess").join("models")) - .unwrap_or_else(LinuxGenericLocalAsrRuntime::default_root); - let qwen_executable = qwen_executable(); - let linux_local_runtime = Arc::new(LinuxGenericLocalAsrRuntime::from_models_root( - configured_models_root, - qwen_executable.clone(), - )); - let linux_local_asr: Arc = Arc::new(LinuxGenericAsrEngine { - root: Arc::clone(&linux_local_runtime.root), - executable: qwen_executable, - }); - for provider_id in ["local-qwen3", "local-qwen3-c"] { - transcription.register(provider_id, Arc::clone(&linux_local_asr))?; - } + // Linux has no local inference runtime: only shared cloud ASR providers + // are registered above, and local-ASR requests stay unsupported in Core. let polisher = Arc::new(TextPolisherRouter::default()); let cloud_polisher: Arc = Arc::new(SharedCloudTextPolisher::new(Arc::clone(&credential_store))); @@ -756,17 +123,6 @@ impl LinuxBackendBuilder { )); let mut services = BackendServices::unsupported(); - if let Ok(model_config) = ModelStoreConfig::new( - linux_local_runtime - .root - .lock() - .expect("Linux ASR root lock poisoned") - .clone(), - ) { - if let Ok(model_store) = ModelStore::new(model_config) { - services.configure_model_store(Arc::new(model_store)); - } - } services.provider = Arc::new(ProviderService::new( Arc::clone(&credential_store), Arc::clone(&task_spawner), @@ -780,7 +136,6 @@ impl LinuxBackendBuilder { .with_auxiliary_polisher(auxiliary_polisher) .with_credential_store(credential_store) .with_services(services) - .with_local_asr_runtime(linux_local_runtime) .with_marketplace_config(MarketplaceConfig::production()) .with_settings_runtime(Arc::new(LinuxSettingsRuntime::new(store)))) } @@ -804,7 +159,6 @@ impl LinuxBackendBuilder { services: None, host_actions: None, settings_runtime: None, - local_asr_runtime: None, polish_failure_policy: PolishFailurePolicy::UseRawText, task_spawner: None, } @@ -857,14 +211,6 @@ impl LinuxBackendBuilder { self } - pub fn with_local_asr_runtime( - mut self, - runtime: Arc, - ) -> Self { - self.local_asr_runtime = Some(runtime); - self - } - pub fn with_polish_failure_policy(mut self, policy: PolishFailurePolicy) -> Self { self.polish_failure_policy = policy; self @@ -876,15 +222,11 @@ impl LinuxBackendBuilder { None => Arc::new(LinuxTaskSpawner::capture_current()?), }; let repositories = BackendRepositories::open(&self.config.data_dir)?; - let recorder = self - .recorder - .unwrap_or_else(|| Arc::new(LinuxCpalRecorder::new(None)) as Arc); - let recorder: Arc = Arc::new(openless_core::AudioRecorderRouter::new( - recorder, - openless_core::ExternalAudioRecorder::with_recordings_directory( - self.config.data_dir.join("recordings"), - ), - )); + let recordings_dir = self.config.data_dir.join("recordings"); + let recorder = self.recorder.unwrap_or_else(|| { + Arc::new(LinuxCpalRecorder::with_recordings_dir(None, recordings_dir)) + as Arc + }); let text_inserter = self .text_inserter .unwrap_or_else(|| Arc::new(Fcitx5TextInserter::new(true)) as Arc); @@ -948,10 +290,10 @@ impl LinuxBackendBuilder { task_spawner, credential_store, services, - local_asr_runtime: Some( - self.local_asr_runtime - .unwrap_or_else(|| Arc::new(LinuxGenericLocalAsrRuntime::default())), - ), + // Linux ships no local inference runtime. Leaving this unset + // makes every local-ASR call resolve to Core's unsupported + // adapter instead of fabricating a local capability. + local_asr_runtime: None, marketplace_config: self.marketplace_config, selection_runtime: Some(Arc::new(LinuxSelectionRuntime::new())), selection_polisher: Some(selection_polisher), @@ -975,7 +317,7 @@ mod tests { }; use openless_core::{ BackendErrorCode, CodingAgentProvider, CodingAgentTestRequest, InMemoryCredentialStore, - InsertOutcome, ModelRuntimeAdapter, ProviderKind, ProviderRequest, + InsertOutcome, ProviderKind, ProviderRequest, }; use super::*; @@ -1114,8 +456,8 @@ mod tests { .services() .provider .list_models(ProviderRequest { - thinking_enabled: false, kind: ProviderKind::Llm, + thinking_enabled: false, channel_id: None, }) .await @@ -1231,248 +573,12 @@ mod tests { } #[tokio::test] - async fn generic_local_asr_runtime_tracks_real_model_files_and_lifecycle() { - let root = std::env::temp_dir().join(format!( - "openless-linux-local-asr-{}", - uuid::Uuid::new_v4().simple() - )); - let models_root = root.join("OpenLess").join("models"); - let runtime = LinuxGenericLocalAsrRuntime::from_models_root( - models_root.clone(), - Some(std::env::current_exe().unwrap()), - ); - let store = openless_core::ModelStore::new( - openless_core::ModelStoreConfig::new(models_root.clone()).unwrap(), - ) - .unwrap(); - let target = openless_core::LocalAsrTarget::parse( - openless_core::LocalAsrRuntime::Generic, - "qwen3-asr-0.6b", - ) - .unwrap(); - let model_dir = models_root.join(target.model_id()); - std::fs::create_dir_all(&model_dir).unwrap(); - std::fs::write( - model_dir.join(LinuxGenericLocalAsrRuntime::READY_SENTINEL), - b"ready", - ) - .unwrap(); - std::fs::write(model_dir.join("weights.bin"), [1_u8, 2, 3]).unwrap(); - - let models = store - .list_models(openless_core::LocalAsrRuntime::Generic) - .unwrap(); - let model = models - .iter() - .find(|model| model.target.model_id() == target.model_id()) - .unwrap(); - assert!(model.installed); - assert!(model.downloaded_bytes >= 3); - assert_eq!( - runtime - .prepare( - target.clone(), - openless_core::FoundryRuntimeSource::Auto, - model_dir.clone(), - Arc::new(|_| {}), - ) - .await - .unwrap(), - target.model_id() - ); - assert!( - runtime - .runtime_status( - openless_core::LocalAsrSettings { - runtime: openless_core::LocalAsrRuntime::Generic, - provider_id: "local-qwen3".into(), - active_model: target.model_id().into(), - mirror: openless_core::LocalAsrMirror::Huggingface, - models_base_dir: Some(root.clone()), - models_root_dir: models_root.clone(), - engine_available: false, - language_hint: None, - runtime_source: None, - keep_loaded_secs: 0, - }, - model_dir.clone() - ) - .await - .unwrap() - .loaded - ); - let next_target = openless_core::LocalAsrTarget::parse( - openless_core::LocalAsrRuntime::Generic, - "qwen3-asr-1.7b", - ) - .unwrap(); - let next_model_dir = models_root.join(next_target.model_id()); - std::fs::create_dir_all(&next_model_dir).unwrap(); - std::fs::write( - next_model_dir.join(LinuxGenericLocalAsrRuntime::READY_SENTINEL), - b"ready", - ) - .unwrap(); - runtime - .prepare( - next_target.clone(), - openless_core::FoundryRuntimeSource::Auto, - next_model_dir, - Arc::new(|_| {}), - ) - .await - .unwrap(); - runtime - .release_lease(openless_core::LocalAsrRuntimeLease { - target: target.clone(), - generation: 1, - }) - .await - .unwrap(); - assert_eq!( - runtime.loaded_model.lock().unwrap().as_deref(), - Some(next_target.model_id()) - ); - runtime - .release(openless_core::LocalAsrRuntime::Generic) - .await - .unwrap(); - store.delete_model(&target).unwrap(); - assert!(!model_dir.exists()); - let _ = std::fs::remove_dir_all(root); - } - - #[tokio::test] - async fn missing_qwen_runtime_never_reports_a_prepared_model() { - let root = std::env::temp_dir().join(format!( - "openless-linux-missing-qwen-runtime-{}", - uuid::Uuid::new_v4().simple() - )); - let target = openless_core::LocalAsrTarget::parse( - openless_core::LocalAsrRuntime::Generic, - "qwen3-asr-0.6b", - ) - .unwrap(); - let model_dir = root.join(target.model_id()); - std::fs::create_dir_all(&model_dir).unwrap(); - std::fs::write( - model_dir.join(LinuxGenericLocalAsrRuntime::READY_SENTINEL), - b"ready", - ) - .unwrap(); - let runtime = LinuxGenericLocalAsrRuntime::from_models_root(root.clone(), None); - - let error = runtime - .prepare( - target, - openless_core::FoundryRuntimeSource::Auto, - model_dir, - Arc::new(|_| {}), - ) - .await - .expect_err("model files alone must not fake a loaded runtime"); - - assert_eq!(error.code, BackendErrorCode::Unsupported); - assert!(runtime.loaded_model.lock().unwrap().is_none()); - let _ = std::fs::remove_dir_all(root); - } - - #[cfg(target_os = "linux")] - async fn wait_for_pid(path: &std::path::Path) -> i32 { - for _ in 0..100 { - if let Ok(value) = std::fs::read_to_string(path) { - if let Ok(pid) = value.trim().parse() { - return pid; - } - } - tokio::time::sleep(std::time::Duration::from_millis(10)).await; - } - panic!("fixture child PID was not written"); - } - - #[cfg(target_os = "linux")] - async fn assert_process_exited(pid: i32) { - for _ in 0..100 { - // SAFETY: signal 0 only checks whether the fixture process still exists. - if unsafe { libc::kill(pid, 0) } == -1 { - return; - } - tokio::time::sleep(std::time::Duration::from_millis(10)).await; - } - panic!("fixture child process {pid} survived"); - } - - #[cfg(target_os = "linux")] - #[tokio::test] - async fn qwen_runtime_cancellation_kills_the_process_group() { - let root = std::env::temp_dir().join(format!( - "openless-qwen-cancel-{}", - uuid::Uuid::new_v4().simple() - )); - std::fs::create_dir_all(&root).unwrap(); - let pid_file = root.join("pid"); - let cancelled = Arc::new(std::sync::atomic::AtomicBool::new(false)); - let task = tokio::spawn(run_qwen_process( - std::path::PathBuf::from("/bin/sh"), - vec![ - "-c".into(), - format!("sleep 30 & echo $! > '{}'; wait", pid_file.display()).into(), - ], - None, - Arc::clone(&cancelled), - std::time::Duration::from_secs(30), - )); - let child_pid = wait_for_pid(&pid_file).await; - cancelled.store(true, std::sync::atomic::Ordering::Release); - - let error = task.await.unwrap().unwrap_err(); - assert_eq!(error.code, BackendErrorCode::Cancelled); - assert_process_exited(child_pid).await; - let _ = std::fs::remove_dir_all(root); - } - - #[cfg(target_os = "linux")] - #[tokio::test] - async fn qwen_runtime_timeout_kills_the_process_group() { - let root = std::env::temp_dir().join(format!( - "openless-qwen-timeout-{}", - uuid::Uuid::new_v4().simple() - )); - std::fs::create_dir_all(&root).unwrap(); - let pid_file = root.join("pid"); - - let error = run_qwen_process( - std::path::PathBuf::from("/bin/sh"), - vec![ - "-c".into(), - format!("sleep 30 & echo $! > '{}'; wait", pid_file.display()).into(), - ], - None, - Arc::new(std::sync::atomic::AtomicBool::new(false)), - std::time::Duration::from_millis(100), - ) - .await - .unwrap_err(); - - assert!(error.message.contains("timed out")); - assert_process_exited(wait_for_pid(&pid_file).await).await; - let _ = std::fs::remove_dir_all(root); - } - - #[tokio::test] - async fn shared_builder_restores_custom_model_root_and_filters_unsupported_models() { + async fn shared_provider_build_reports_local_asr_unsupported() { let data_dir = std::env::temp_dir().join(format!( - "openless-linux-custom-model-root-{}", + "openless-linux-local-asr-unsupported-{}-{}", + std::process::id(), uuid::Uuid::new_v4().simple() )); - let custom = data_dir.join("external"); - std::fs::create_dir_all(&data_dir).unwrap(); - let preferences = - openless_core::PreferencesStore::open(data_dir.join("preferences.json")).unwrap(); - let mut value = preferences.get(); - value.local_asr_models_base_dir = custom.to_string_lossy().into_owned(); - preferences.set(value).unwrap(); - let runtime = LinuxBackendBuilder::from_shared_providers(BackendConfig { data_dir: data_dir.clone(), ..BackendConfig::default() @@ -1480,26 +586,17 @@ mod tests { .unwrap() .build() .unwrap(); - let storage = runtime - .backend - .services() - .local_asr - .storage_settings() - .await - .unwrap(); - assert_eq!( - storage.models_root_dir, - custom.join("OpenLess").join("models") - ); - let models = runtime + + // Linux ships no local inference runtime: Core must answer Unsupported + // rather than fabricate a Generic/Qwen capability. + let error = runtime .backend .services() .local_asr - .list_models(openless_core::LocalAsrRuntime::Generic) + .runtime_status(openless_core::LocalAsrRuntime::Generic) .await - .unwrap(); - assert_eq!(models.len(), 2); - assert!(models.iter().all(|model| model.family == "qwen3")); + .expect_err("Linux must not advertise a local ASR engine"); + assert_eq!(error.code, BackendErrorCode::Unsupported); let _ = std::fs::remove_dir_all(data_dir); } diff --git a/openless-all/app/linux-egui/src/capabilities.rs b/openless-all/app/linux-egui/src/capabilities.rs index ca80cd467..1d7867da9 100644 --- a/openless-all/app/linux-egui/src/capabilities.rs +++ b/openless-all/app/linux-egui/src/capabilities.rs @@ -29,7 +29,7 @@ impl LinuxCapabilitySnapshot { x11_display: Option<&str>, fcitx5_ready: bool, tray_available: bool, - package_kind: LinuxPackageKind, + _package_kind: LinuxPackageKind, ) -> Self { let session = if wayland_display.is_some_and(|value| !value.trim().is_empty()) { LinuxDesktopSession::Wayland @@ -48,10 +48,16 @@ impl LinuxCapabilitySnapshot { supports_tray: desktop && tray_available, supports_overlay: session == LinuxDesktopSession::X11, supports_ime_input: desktop && fcitx5_ready, - supports_local_asr: desktop, + // Linux ships no local inference engine (Generic/Qwen, MLX or + // Foundry). Report false on every desktop session so the UI and + // downstream gate on the honest answer. + supports_local_asr: false, supports_local_qwen3_mlx: false, supports_in_app_dictation: false, - supports_auto_update: package_kind == LinuxPackageKind::AppImage, + // AppImage detection alone is not an updater capability. Keep + // this false until transport and a pinned minisign verifier + // have both initialized successfully. + supports_auto_update: false, }, permissions: PermissionSnapshot { microphone: if desktop { @@ -64,16 +70,23 @@ impl LinuxCapabilitySnapshot { } } - pub fn detect(tray_available: bool, package_kind: LinuxPackageKind) -> Self { + pub fn detect( + tray_available: bool, + package_kind: LinuxPackageKind, + updater_available: bool, + ) -> Self { let wayland = std::env::var("WAYLAND_DISPLAY").ok(); let x11 = std::env::var("DISPLAY").ok(); - Self::from_environment( + let mut snapshot = Self::from_environment( wayland.as_deref(), x11.as_deref(), fcitx5_available(), tray_available, package_kind, - ) + ); + snapshot.capabilities.supports_auto_update = + package_kind == LinuxPackageKind::AppImage && updater_available; + snapshot } } @@ -192,12 +205,10 @@ impl PlatformApi for LinuxPlatformApi { #[cfg(target_os = "linux")] fn enumerate_microphones() -> Result, BackendError> { - use cpal::traits::{DeviceTrait, HostTrait}; + use cpal::traits::HostTrait; let host = cpal::default_host(); - let default_name = host - .default_input_device() - .and_then(|device| device.name().ok()); + let default_name = host.default_input_device().map(|device| device.to_string()); let devices = host.input_devices().map_err(|error| { BackendError::new( BackendErrorCode::Platform, @@ -207,12 +218,7 @@ fn enumerate_microphones() -> Result, BackendError> { devices .enumerate() .map(|(index, device)| { - let name = device.name().map_err(|error| { - BackendError::new( - BackendErrorCode::Platform, - format!("failed to read Linux microphone name: {error}"), - ) - })?; + let name = device.to_string(); Ok(MicrophoneDevice { id: format!("cpal:{index}:{name}"), is_default: default_name.as_deref() == Some(name.as_str()), @@ -237,7 +243,8 @@ mod tests { ); assert_eq!(x11.session, LinuxDesktopSession::X11); assert!(x11.capabilities.supports_overlay); - assert!(x11.capabilities.supports_auto_update); + assert!(!x11.capabilities.supports_local_asr); + assert!(!x11.capabilities.supports_auto_update); let wayland = LinuxCapabilitySnapshot::from_environment( Some("wayland-0"), diff --git a/openless-all/app/linux-egui/src/coding_agent.rs b/openless-all/app/linux-egui/src/coding_agent.rs index e2c71250d..0957608f7 100644 --- a/openless-all/app/linux-egui/src/coding_agent.rs +++ b/openless-all/app/linux-egui/src/coding_agent.rs @@ -110,12 +110,6 @@ pub(crate) fn isolate_process_group(command: &mut tokio::process::Command) { let _ = command; } -pub(crate) fn kill_process_group( - child: &mut tokio::process::Child, -) -> Result<(), openless_core::BackendError> { - kill_process_group_with_id(child, child.id()) -} - fn kill_process_group_with_id( child: &mut tokio::process::Child, _process_id: Option, diff --git a/openless-all/app/linux-egui/src/credentials.rs b/openless-all/app/linux-egui/src/credentials.rs index 0f506728e..fd39def51 100644 --- a/openless-all/app/linux-egui/src/credentials.rs +++ b/openless-all/app/linux-egui/src/credentials.rs @@ -447,9 +447,13 @@ impl CredentialStore for LinuxCredentialStore { OMNI_MODEL_ACCOUNT, ), }; + // Linux ships no local inference engine, so every native/local ASR + // provider id is reported unconfigured rather than gated on a Qwen + // runtime that is never present. let local_asr_configured = match asr_provider_type.as_str() { - "local-qwen3" | "local-qwen3-c" => Some(crate::backend::qwen_engine_available()), - "local-qwen3-mlx" + "local-qwen3" + | "local-qwen3-c" + | "local-qwen3-mlx" | "local-whisper" | "apple-speech" | "foundry-local-whisper" diff --git a/openless-all/app/linux-egui/src/desktop.rs b/openless-all/app/linux-egui/src/desktop.rs new file mode 100644 index 000000000..3f7207f68 --- /dev/null +++ b/openless-all/app/linux-egui/src/desktop.rs @@ -0,0 +1,463 @@ +//! Linux desktop integration that does not depend on Tauri. +//! +//! The functions in this module deliberately report failures instead of +//! treating a best-effort desktop operation as successful. Slow operations +//! (D-Bus and process execution) are blocking and should be dispatched with +//! `tokio::task::spawn_blocking` by the UI bridge. + +use std::fmt; +use std::fs::{self, File, OpenOptions}; +use std::io::{self, Write}; +use std::path::{Path, PathBuf}; +use std::process::{Command, ExitStatus}; +use std::time::Duration; + +const AUTOSTART_FILE: &str = "openless.desktop"; +const NOTIFICATION_TIMEOUT: Duration = Duration::from_secs(5); + +#[derive(Debug)] +pub enum DesktopError { + InvalidInput(String), + Io { + operation: &'static str, + source: io::Error, + }, + Dbus(String), + LauncherFailed { + program: String, + status: ExitStatus, + }, + LauncherUnavailable(Vec), +} + +impl fmt::Display for DesktopError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidInput(message) => f.write_str(message), + Self::Io { operation, source } => write!(f, "{operation}: {source}"), + Self::Dbus(message) => write!(f, "desktop notification D-Bus error: {message}"), + Self::LauncherFailed { program, status } => { + write!(f, "{program} exited unsuccessfully ({status})") + } + Self::LauncherUnavailable(errors) => { + write!( + f, + "no desktop URL launcher was available: {}", + errors.join("; ") + ) + } + } + } +} + +impl std::error::Error for DesktopError {} + +fn io_error(operation: &'static str, source: io::Error) -> DesktopError { + DesktopError::Io { operation, source } +} + +/// Manages the per-user XDG autostart entry for OpenLess. +#[derive(Debug, Clone)] +pub struct AutostartManager { + entry_path: PathBuf, + executable: PathBuf, +} + +impl AutostartManager { + pub fn detect(executable: PathBuf) -> Result { + let config_home = match std::env::var_os("XDG_CONFIG_HOME") { + Some(path) if !path.is_empty() => PathBuf::from(path), + _ => std::env::var_os("HOME") + .filter(|path| !path.is_empty()) + .map(PathBuf::from) + .map(|home| home.join(".config")) + .ok_or_else(|| { + DesktopError::InvalidInput( + "neither XDG_CONFIG_HOME nor HOME is available".into(), + ) + })?, + }; + Self::new( + config_home.join("autostart").join(AUTOSTART_FILE), + executable, + ) + } + + pub fn new(entry_path: PathBuf, executable: PathBuf) -> Result { + validate_executable(&executable)?; + if !entry_path.is_absolute() { + return Err(DesktopError::InvalidInput( + "autostart entry path must be absolute".into(), + )); + } + Ok(Self { + entry_path, + executable, + }) + } + + pub fn entry_path(&self) -> &Path { + &self.entry_path + } + + pub fn is_enabled(&self) -> Result { + match fs::symlink_metadata(&self.entry_path) { + Ok(metadata) if metadata.file_type().is_symlink() => Err(DesktopError::InvalidInput( + "refusing to trust a symlinked autostart entry".into(), + )), + Ok(metadata) if metadata.is_file() => Ok(true), + Ok(_) => Err(DesktopError::InvalidInput( + "autostart entry exists but is not a regular file".into(), + )), + Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(false), + Err(error) => Err(io_error("inspect autostart entry", error)), + } + } + + pub fn set_enabled(&self, enabled: bool) -> Result<(), DesktopError> { + if enabled { + // Inspect first so a hostile/pre-existing symlink is never silently + // replaced and reported as a successfully managed entry. + let _ = self.is_enabled()?; + let contents = desktop_entry(&self.executable)?; + atomic_write(&self.entry_path, contents.as_bytes(), Some(0o600)) + } else { + match fs::symlink_metadata(&self.entry_path) { + Ok(metadata) if metadata.file_type().is_symlink() => { + Err(DesktopError::InvalidInput( + "refusing to remove a symlinked autostart entry".into(), + )) + } + Ok(metadata) if metadata.is_file() => { + fs::remove_file(&self.entry_path) + .map_err(|error| io_error("remove autostart entry", error))?; + sync_parent(&self.entry_path) + } + Ok(_) => Err(DesktopError::InvalidInput( + "autostart entry exists but is not a regular file".into(), + )), + Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(io_error("inspect autostart entry", error)), + } + } + } +} + +fn validate_executable(executable: &Path) -> Result<(), DesktopError> { + if !executable.is_absolute() { + return Err(DesktopError::InvalidInput( + "autostart executable must be absolute".into(), + )); + } + let value = executable.as_os_str().to_string_lossy(); + if value.contains(['\n', '\r', '\0']) { + return Err(DesktopError::InvalidInput( + "autostart executable contains a forbidden control character".into(), + )); + } + Ok(()) +} + +fn desktop_entry(executable: &Path) -> Result { + validate_executable(executable)?; + let escaped = executable + .as_os_str() + .to_string_lossy() + .replace('\\', "\\\\") + .replace('"', "\\\"") + .replace('`', "\\`") + .replace('$', "\\$"); + Ok(format!( + "[Desktop Entry]\nType=Application\nVersion=1.0\nName=OpenLess\nComment=Start OpenLess in the background\nExec=\"{escaped}\" --minimized\nTerminal=false\nX-GNOME-Autostart-enabled=true\n" + )) +} + +/// A freedesktop.org desktop notification. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Notification<'a> { + pub summary: &'a str, + pub body: &'a str, + pub icon: &'a str, + /// Zero lets the notification server choose its default timeout. + pub timeout_ms: i32, +} + +/// Sends a notification and returns the notification server's identifier. +#[cfg(target_os = "linux")] +pub fn notify(request: Notification<'_>) -> Result { + use dbus::arg::PropMap; + use dbus::blocking::Connection; + + if request.summary.contains('\0') || request.body.contains('\0') { + return Err(DesktopError::InvalidInput( + "notification text contains a NUL byte".into(), + )); + } + let connection = + Connection::new_session().map_err(|error| DesktopError::Dbus(error.to_string()))?; + let proxy = connection.with_proxy( + "org.freedesktop.Notifications", + "/org/freedesktop/Notifications", + NOTIFICATION_TIMEOUT, + ); + let hints: PropMap = std::collections::HashMap::new(); + let (id,): (u32,) = proxy + .method_call( + "org.freedesktop.Notifications", + "Notify", + ( + "OpenLess", + 0u32, + request.icon, + request.summary, + request.body, + Vec::::new(), + hints, + request.timeout_ms, + ), + ) + .map_err(|error| DesktopError::Dbus(error.to_string()))?; + Ok(id) +} + +#[cfg(not(target_os = "linux"))] +pub fn notify(_request: Notification<'_>) -> Result { + Err(DesktopError::InvalidInput( + "desktop notifications are supported only on Linux".into(), + )) +} + +/// Opens an HTTP(S) URL using a desktop launcher and waits for the launcher to +/// acknowledge the request. Returning `Ok` never means merely "spawned". +pub fn open_external(url: &str) -> Result<(), DesktopError> { + validate_external_url(url)?; + open_external_with(url, &[("xdg-open", &[]), ("gio", &["open"])]) +} + +/// Opens a validated regular local file with the user's desktop handler. +pub fn open_local_file(path: &Path) -> Result<(), DesktopError> { + if !path.is_absolute() { + return Err(DesktopError::InvalidInput( + "local file path must be absolute".into(), + )); + } + let metadata = + fs::symlink_metadata(path).map_err(|error| io_error("inspect local file", error))?; + if metadata.file_type().is_symlink() || !metadata.is_file() { + return Err(DesktopError::InvalidInput( + "local file must be a regular non-symlink file".into(), + )); + } + let mut unavailable = Vec::new(); + for (program, prefix) in [("xdg-open", &[][..]), ("gio", &["open"][..])] { + match Command::new(program).args(prefix).arg(path).status() { + Ok(status) if status.success() => return Ok(()), + Ok(status) => { + return Err(DesktopError::LauncherFailed { + program: program.into(), + status, + }); + } + Err(error) if error.kind() == io::ErrorKind::NotFound => { + unavailable.push(format!("{program}: {error}")); + } + Err(error) => return Err(io_error("launch local file handler", error)), + } + } + Err(DesktopError::LauncherUnavailable(unavailable)) +} + +fn validate_external_url(url: &str) -> Result<(), DesktopError> { + if !(url.starts_with("https://") || url.starts_with("http://")) { + return Err(DesktopError::InvalidInput( + "only HTTP(S) external URLs are allowed".into(), + )); + } + if url.chars().any(char::is_control) { + return Err(DesktopError::InvalidInput( + "external URL contains a control character".into(), + )); + } + Ok(()) +} + +fn open_external_with(url: &str, launchers: &[(&str, &[&str])]) -> Result<(), DesktopError> { + let mut unavailable = Vec::new(); + for (program, prefix) in launchers { + match Command::new(program).args(*prefix).arg(url).status() { + Ok(status) if status.success() => return Ok(()), + Ok(status) => { + return Err(DesktopError::LauncherFailed { + program: (*program).into(), + status, + }) + } + Err(error) if error.kind() == io::ErrorKind::NotFound => { + unavailable.push(format!("{program}: {error}")); + } + Err(error) => return Err(io_error("start desktop URL launcher", error)), + } + } + Err(DesktopError::LauncherUnavailable(unavailable)) +} + +/// Validates a user-selected destination for a safe atomic save. +pub fn validate_save_path(path: &Path) -> Result { + if !path.is_absolute() || path.file_name().is_none() { + return Err(DesktopError::InvalidInput( + "save destination must be an absolute file path".into(), + )); + } + let parent = path.parent().ok_or_else(|| { + DesktopError::InvalidInput("save destination has no parent directory".into()) + })?; + let canonical_parent = parent + .canonicalize() + .map_err(|error| io_error("resolve save destination directory", error))?; + if !canonical_parent.is_dir() { + return Err(DesktopError::InvalidInput( + "save destination parent is not a directory".into(), + )); + } + match fs::symlink_metadata(path) { + Ok(metadata) if metadata.file_type().is_symlink() => Err(DesktopError::InvalidInput( + "refusing to overwrite a symlink".into(), + )), + Ok(metadata) if !metadata.is_file() => Err(DesktopError::InvalidInput( + "save destination exists but is not a regular file".into(), + )), + Ok(_) => Ok(canonical_parent.join(path.file_name().expect("checked above"))), + Err(error) if error.kind() == io::ErrorKind::NotFound => { + Ok(canonical_parent.join(path.file_name().expect("checked above"))) + } + Err(error) => Err(io_error("inspect save destination", error)), + } +} + +/// Atomically saves bytes without following an existing destination symlink. +pub fn atomic_save(path: &Path, bytes: &[u8]) -> Result { + let validated = validate_save_path(path)?; + atomic_write(&validated, bytes, Some(0o600))?; + Ok(validated) +} + +fn atomic_write(path: &Path, bytes: &[u8], unix_mode: Option) -> Result<(), DesktopError> { + let parent = path.parent().ok_or_else(|| { + DesktopError::InvalidInput("atomic-write destination has no parent".into()) + })?; + fs::create_dir_all(parent).map_err(|error| io_error("create destination directory", error))?; + let file_name = path.file_name().ok_or_else(|| { + DesktopError::InvalidInput("atomic-write destination has no filename".into()) + })?; + let temp = parent.join(format!( + ".{}.tmp-{}", + file_name.to_string_lossy(), + uuid::Uuid::new_v4() + )); + let result = (|| { + let mut file = OpenOptions::new() + .write(true) + .create_new(true) + .open(&temp) + .map_err(|error| io_error("create temporary file", error))?; + #[cfg(unix)] + if let Some(mode) = unix_mode { + use std::os::unix::fs::PermissionsExt; + file.set_permissions(fs::Permissions::from_mode(mode)) + .map_err(|error| io_error("set temporary file permissions", error))?; + } + file.write_all(bytes) + .map_err(|error| io_error("write temporary file", error))?; + file.sync_all() + .map_err(|error| io_error("sync temporary file", error))?; + fs::rename(&temp, path).map_err(|error| io_error("replace destination", error))?; + sync_parent(path) + })(); + if result.is_err() { + let _ = fs::remove_file(&temp); + } + result +} + +fn sync_parent(path: &Path) -> Result<(), DesktopError> { + let parent = path + .parent() + .ok_or_else(|| DesktopError::InvalidInput("destination has no parent directory".into()))?; + File::open(parent) + .and_then(|directory| directory.sync_all()) + .map_err(|error| io_error("sync destination directory", error)) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::{AtomicU64, Ordering}; + + static NEXT_TEMP: AtomicU64 = AtomicU64::new(0); + + fn temp_dir(name: &str) -> PathBuf { + let path = std::env::temp_dir().join(format!( + "openless-desktop-{name}-{}-{}", + std::process::id(), + NEXT_TEMP.fetch_add(1, Ordering::Relaxed) + )); + fs::create_dir_all(&path).unwrap(); + path + } + + #[test] + fn autostart_is_atomic_and_round_trips() { + let root = temp_dir("autostart"); + let entry = root.join("config/autostart/openless.desktop"); + let manager = + AutostartManager::new(entry.clone(), PathBuf::from("/opt/Open Less/openless")).unwrap(); + assert!(!manager.is_enabled().unwrap()); + manager.set_enabled(true).unwrap(); + assert!(manager.is_enabled().unwrap()); + let text = fs::read_to_string(&entry).unwrap(); + assert!(text.contains("Exec=\"/opt/Open Less/openless\" --minimized")); + assert_eq!(text.matches("[Desktop Entry]").count(), 1); + manager.set_enabled(false).unwrap(); + assert!(!manager.is_enabled().unwrap()); + fs::remove_dir_all(root).unwrap(); + } + + #[cfg(unix)] + #[test] + fn autostart_refuses_to_replace_or_remove_symlinks() { + use std::os::unix::fs::symlink; + let root = temp_dir("autostart-symlink"); + let target = root.join("target"); + fs::write(&target, b"keep").unwrap(); + let entry = root.join("openless.desktop"); + symlink(&target, &entry).unwrap(); + let manager = AutostartManager::new(entry, PathBuf::from("/usr/bin/openless")).unwrap(); + assert!(manager.set_enabled(false).is_err()); + assert!(manager.set_enabled(true).is_err()); + assert_eq!(fs::read(&target).unwrap(), b"keep"); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn save_path_is_canonical_and_atomic() { + let root = temp_dir("save"); + let nested = root.join("nested"); + fs::create_dir(&nested).unwrap(); + let destination = nested.join("export.json"); + let saved = atomic_save(&destination, b"first").unwrap(); + assert!(saved.is_absolute()); + assert_eq!(fs::read(&destination).unwrap(), b"first"); + atomic_save(&destination, b"second").unwrap(); + assert_eq!(fs::read(&destination).unwrap(), b"second"); + assert_eq!(fs::read_dir(&nested).unwrap().count(), 1); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn unsafe_save_and_url_inputs_are_rejected() { + assert!(validate_save_path(Path::new("relative.txt")).is_err()); + assert!(validate_external_url("file:///etc/passwd").is_err()); + assert!(validate_external_url("https://example.test/\nattack").is_err()); + assert!(validate_external_url("https://example.test/path").is_ok()); + } +} diff --git a/openless-all/app/linux-egui/src/fcitx5.rs b/openless-all/app/linux-egui/src/fcitx5.rs index e33aeb61f..57080b792 100644 --- a/openless-all/app/linux-egui/src/fcitx5.rs +++ b/openless-all/app/linux-egui/src/fcitx5.rs @@ -4,11 +4,11 @@ use std::time::Duration; use futures_util::future::BoxFuture; use openless_core::{ - BackendError, BackendErrorCode, InsertOutcome, InsertWriteResult, ResourceResolver, - TextInserter, TextInsertionSession, + BackendError, BackendErrorCode, InsertOutcome, InsertWriteResult, TextInserter, + TextInsertionSession, }; -use crate::{LinuxPackageKind, LinuxResourceLayout, FCITX_PLUGIN_CONFIG, FCITX_PLUGIN_LIBRARY}; +use crate::LinuxResourceLayout; #[cfg(target_os = "linux")] pub(crate) const DESTINATION: &str = "org.fcitx.Fcitx5"; @@ -21,35 +21,19 @@ const TIMEOUT: Duration = Duration::from_secs(3); #[derive(Debug, Clone, PartialEq, Eq)] pub struct FcitxPluginInstallPlan { - pub source_library: Option, - pub source_config: Option, pub target_library: PathBuf, pub target_config: PathBuf, - pub copy_required: bool, } impl FcitxPluginInstallPlan { pub fn for_layout(layout: &LinuxResourceLayout, home: &Path) -> Result { let target_library = home.join(".local/lib/fcitx5/libopenless.so"); let target_config = home.join(".local/share/fcitx5/addon/openless.conf"); - if layout.package_kind == LinuxPackageKind::AppImage { - let resolver = layout.resolver()?; - Ok(Self { - source_library: Some(resolver.resolve(Path::new(FCITX_PLUGIN_LIBRARY))?), - source_config: Some(resolver.resolve(Path::new(FCITX_PLUGIN_CONFIG))?), - target_library, - target_config, - copy_required: true, - }) - } else { - Ok(Self { - source_library: None, - source_config: None, - target_library, - target_config, - copy_required: false, - }) - } + let _ = layout; + Ok(Self { + target_library, + target_config, + }) } } @@ -57,137 +41,16 @@ impl FcitxPluginInstallPlan { pub enum FcitxPluginStatus { Ready, Missing, - Updated, } pub fn ensure_plugin_installed( plan: &FcitxPluginInstallPlan, ) -> Result { - if !plan.copy_required { - return if system_plugin_available() || user_plugin_available(plan) { - Ok(FcitxPluginStatus::Ready) - } else { - Ok(FcitxPluginStatus::Missing) - }; - } - let source_library = plan.source_library.as_ref().ok_or_else(|| { - BackendError::new( - BackendErrorCode::InvalidArgument, - "AppImage plugin plan is missing the bundled library", - ) - })?; - let source_config = plan.source_config.as_ref().ok_or_else(|| { - BackendError::new( - BackendErrorCode::InvalidArgument, - "AppImage plugin plan is missing the bundled config", - ) - })?; - let library = read_non_empty(source_library)?; - let config = read_non_empty(source_config)?; - let library_changed = target_differs(&plan.target_library, &library)?; - let config_changed = target_differs(&plan.target_config, &config)?; - if !library_changed && !config_changed { - return Ok(FcitxPluginStatus::Ready); - } - if library_changed { - atomic_write(&plan.target_library, &library, true)?; - } - if config_changed { - atomic_write(&plan.target_config, &config, false)?; - } - Ok(FcitxPluginStatus::Updated) -} - -fn read_non_empty(path: &Path) -> Result, BackendError> { - let bytes = std::fs::read(path).map_err(|error| { - BackendError::new( - BackendErrorCode::Platform, - format!("failed to read fcitx5 resource {}: {error}", path.display()), - ) - })?; - if bytes.is_empty() { - return Err(BackendError::new( - BackendErrorCode::Platform, - format!("fcitx5 resource {} is empty", path.display()), - )); - } - Ok(bytes) -} - -fn target_differs(path: &Path, expected: &[u8]) -> Result { - match std::fs::read(path) { - Ok(actual) => Ok(actual != expected), - Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(true), - Err(error) => Err(BackendError::new( - BackendErrorCode::Platform, - format!( - "failed to read existing fcitx5 file {}: {error}", - path.display() - ), - )), - } -} - -fn atomic_write(path: &Path, bytes: &[u8], executable: bool) -> Result<(), BackendError> { - let parent = path.parent().ok_or_else(|| { - BackendError::new( - BackendErrorCode::InvalidArgument, - "fcitx5 target has no parent directory", - ) - })?; - std::fs::create_dir_all(parent).map_err(|error| { - BackendError::new( - BackendErrorCode::Platform, - format!("failed to create fcitx5 target directory: {error}"), - ) - })?; - let temporary = parent.join(format!( - ".{}.{}.tmp", - path.file_name() - .and_then(|name| name.to_str()) - .unwrap_or("openless"), - std::process::id() - )); - std::fs::write(&temporary, bytes).map_err(|error| { - BackendError::new( - BackendErrorCode::Platform, - format!("failed to stage fcitx5 resource: {error}"), - ) - })?; - #[cfg(unix)] - if executable { - use std::os::unix::fs::PermissionsExt; - std::fs::set_permissions(&temporary, std::fs::Permissions::from_mode(0o755)).map_err( - |error| { - BackendError::new( - BackendErrorCode::Platform, - format!("failed to set fcitx5 plugin permissions: {error}"), - ) - }, - )?; - } - #[cfg(not(unix))] - let _ = executable; - // POSIX rename replaces an existing file atomically, so the previously - // working plugin remains available if staging or commit fails. The - // non-Unix branch exists only for portable unit tests/tooling, where the - // platform rename API may require explicitly removing the destination. - #[cfg(not(unix))] - if path.exists() { - std::fs::remove_file(path).map_err(|error| { - BackendError::new( - BackendErrorCode::Platform, - format!("failed to replace fcitx5 resource: {error}"), - ) - })?; + if system_plugin_available() || user_plugin_available(plan) { + Ok(FcitxPluginStatus::Ready) + } else { + Ok(FcitxPluginStatus::Missing) } - std::fs::rename(&temporary, path).map_err(|error| { - let _ = std::fs::remove_file(&temporary); - BackendError::new( - BackendErrorCode::Platform, - format!("failed to commit fcitx5 resource: {error}"), - ) - }) } fn user_plugin_available(plan: &FcitxPluginInstallPlan) -> bool { @@ -195,14 +58,31 @@ fn user_plugin_available(plan: &FcitxPluginInstallPlan) -> bool { } fn system_plugin_available() -> bool { - let library = [ - "/usr/lib/x86_64-linux-gnu/fcitx5/libopenless.so", - "/usr/lib64/fcitx5/libopenless.so", - "/usr/lib/fcitx5/libopenless.so", - ] - .iter() - .any(|path| Path::new(path).is_file()); - library && Path::new("/usr/share/fcitx5/addon/openless.conf").is_file() + let config_dirs = [ + std::env::var_os("FCITX5_ADDON_DIR").map(PathBuf::from), + std::env::var_os("FCITX_ADDON_DIR").map(PathBuf::from), + Some(PathBuf::from("/usr/share/fcitx5/addon")), + Some(PathBuf::from("/usr/local/share/fcitx5/addon")), + ]; + let config = config_dirs + .into_iter() + .flatten() + .find(|dir| dir.join("openless.conf").is_file()); + let Some(config) = config else { return false }; + let mut library_dirs = vec![ + PathBuf::from("/usr/lib64/fcitx5"), + PathBuf::from("/usr/lib/fcitx5"), + PathBuf::from("/usr/local/lib/fcitx5"), + ]; + if let Ok(entries) = std::fs::read_dir("/usr/lib") { + library_dirs.extend(entries.flatten().map(|entry| entry.path().join("fcitx5"))); + } + if let Some(parent) = config.parent() { + library_dirs.push(parent.to_path_buf()); + } + library_dirs + .iter() + .any(|dir| dir.join("libopenless.so").is_file()) } #[derive(Debug, Clone)] @@ -505,6 +385,23 @@ pub(crate) fn set_raw_hotkey(method: &str, symbol: u32, states: u32) -> Result<( send_message(method, |message| message.append2(symbol, states)) } +#[cfg(target_os = "linux")] +pub(crate) fn set_style_pack_hotkeys( + bindings: Vec<(String, u32, u32)>, +) -> Result<(), BackendError> { + send_message("SetStylePackHotkeys", |message| message.append1(bindings)) +} + +#[cfg(not(target_os = "linux"))] +pub(crate) fn set_style_pack_hotkeys( + _bindings: Vec<(String, u32, u32)>, +) -> Result<(), BackendError> { + Err(BackendError::new( + BackendErrorCode::Unsupported, + "fcitx5 hotkey settings are only available on Linux", + )) +} + #[cfg(not(target_os = "linux"))] pub(crate) fn set_raw_hotkey( _method: &str, @@ -725,13 +622,89 @@ pub fn available() -> bool { false } +/// Ask a running fcitx5 daemon to reload so it loads a freshly written +/// OpenLess addon, mirroring the legacy Tauri `linux_fcitx` adapter. +/// +/// Only an instance that currently owns the `org.fcitx.Fcitx5` DBus name is +/// restarted. On a first install fcitx5 may not be running yet; that is fine, +/// because the next fcitx5 start scans the per-user addon directory and loads +/// the addon on its own, so we never force-spawn a daemon (first-install +/// semantics are preserved). On an update the running instance is restarted so +/// the new `.so` is actually loaded (restart semantics). +/// +/// Failures are logged and never fatal: startup continues down the fcitx5 +/// DBus path instead of degrading to a global-hotkey fallback. Returns true +/// when a reload was issued against a live instance. #[cfg(target_os = "linux")] -fn copy_to_clipboard(text: &str) -> Result<(), BackendError> { - let mut clipboard = arboard::Clipboard::new() - .map_err(|error| platform_error(format!("failed to open Linux clipboard: {error}")))?; - clipboard - .set_text(text.to_string()) - .map_err(|error| platform_error(format!("failed to write Linux clipboard: {error}"))) +pub fn reload_running_fcitx5() -> bool { + if !fcitx5_name_has_owner() { + return false; + } + match std::process::Command::new("fcitx5").arg("-r").status() { + Ok(status) if status.success() => { + log::info!("[fcitx] reloaded fcitx5 after addon update"); + true + } + Ok(status) => { + log::warn!("[fcitx] fcitx5 -r failed with status {status}"); + false + } + Err(error) => { + log::warn!("[fcitx] could not run fcitx5 -r: {error}"); + false + } + } +} + +#[cfg(not(target_os = "linux"))] +pub fn reload_running_fcitx5() -> bool { + false +} + +/// Whether the fcitx5 daemon itself is registered on the session bus. This is +/// distinct from `available()` (which pings the OpenLess addon interface): the +/// daemon may be running without having loaded our addon yet, and that is +/// exactly the case where a reload is required. +#[cfg(target_os = "linux")] +fn fcitx5_name_has_owner() -> bool { + use dbus::blocking::BlockingSender; + let Ok(connection) = dbus::blocking::Connection::new_session() else { + return false; + }; + let Ok(message) = dbus::Message::new_method_call( + "org.freedesktop.DBus", + "/org/freedesktop/DBus", + "org.freedesktop.DBus", + "NameHasOwner", + ) else { + return false; + }; + connection + .send_with_reply_and_block(message.append1(DESTINATION), Duration::from_millis(1000)) + .map(|reply| reply.read1::().unwrap_or(false)) + .unwrap_or(false) +} + +#[cfg(target_os = "linux")] +pub fn copy_to_clipboard(text: &str) -> Result<(), BackendError> { + use dbus::blocking::BlockingSender; + let connection = dbus::blocking::Connection::new_session().map_err(dbus_error)?; + let message = + dbus::Message::new_method_call(DESTINATION, OBJECT_PATH, INTERFACE, "SetClipboardText") + .map_err(|error| { + platform_error(format!("failed to build fcitx5 clipboard call: {error}")) + })? + .append1(text.to_string()); + let reply = connection + .send_with_reply_and_block(message, TIMEOUT) + .map_err(dbus_error)?; + if reply.read1::().unwrap_or(false) { + Ok(()) + } else { + Err(platform_error( + "fcitx5 clipboard addon is unavailable".to_string(), + )) + } } #[cfg(target_os = "linux")] @@ -752,77 +725,19 @@ mod tests { use super::*; #[test] - fn appimage_plan_copies_only_from_the_versioned_resource_contract() { + fn plugin_plan_is_probe_only_for_system_packages() { let layout = LinuxResourceLayout { - package_kind: LinuxPackageKind::AppImage, - resource_root: PathBuf::from("/app/usr/lib/openless/resources"), + package_kind: crate::LinuxPackageKind::SystemPackage, + resource_root: PathBuf::from("/usr/lib/openless/resources"), }; let plan = FcitxPluginInstallPlan::for_layout(&layout, Path::new("/home/test")).unwrap(); - assert!(plan.copy_required); assert_eq!( - plan.source_library.unwrap(), - PathBuf::from("/app/usr/lib/openless/resources/linux-fcitx5-plugin/libopenless.so") + plan.target_library, + PathBuf::from("/home/test/.local/lib/fcitx5/libopenless.so") ); assert_eq!( plan.target_config, PathBuf::from("/home/test/.local/share/fcitx5/addon/openless.conf") ); } - - #[test] - fn system_packages_never_copy_bundled_plugins_into_home() { - let layout = LinuxResourceLayout { - package_kind: LinuxPackageKind::SystemPackage, - resource_root: PathBuf::from("/usr/lib/openless/resources"), - }; - let plan = FcitxPluginInstallPlan::for_layout(&layout, Path::new("/home/test")).unwrap(); - assert!(!plan.copy_required); - assert!(plan.source_library.is_none()); - assert!(plan.source_config.is_none()); - } - - #[test] - fn appimage_installer_copies_then_reports_ready() { - let root = std::env::temp_dir().join(format!( - "openless-fcitx-appimage-{}", - uuid::Uuid::new_v4().simple() - )); - let resources = root.join("resources"); - let home = root.join("home"); - std::fs::create_dir_all(resources.join("linux-fcitx5-plugin")).unwrap(); - std::fs::write(resources.join(FCITX_PLUGIN_LIBRARY), b"plugin").unwrap(); - std::fs::write(resources.join(FCITX_PLUGIN_CONFIG), b"config").unwrap(); - let plan = FcitxPluginInstallPlan::for_layout( - &LinuxResourceLayout { - package_kind: LinuxPackageKind::AppImage, - resource_root: resources, - }, - &home, - ) - .unwrap(); - - assert_eq!( - ensure_plugin_installed(&plan).unwrap(), - FcitxPluginStatus::Updated - ); - assert_eq!(std::fs::read(&plan.target_library).unwrap(), b"plugin"); - assert_eq!(std::fs::read(&plan.target_config).unwrap(), b"config"); - assert_eq!( - ensure_plugin_installed(&plan).unwrap(), - FcitxPluginStatus::Ready - ); - - std::fs::write(plan.source_library.as_ref().unwrap(), b"updated plugin").unwrap(); - assert_eq!( - ensure_plugin_installed(&plan).unwrap(), - FcitxPluginStatus::Updated - ); - assert_eq!( - std::fs::read(&plan.target_library).unwrap(), - b"updated plugin" - ); - assert_eq!(std::fs::read(&plan.target_config).unwrap(), b"config"); - - let _ = std::fs::remove_dir_all(root); - } } diff --git a/openless-all/app/linux-egui/src/hotkeys.rs b/openless-all/app/linux-egui/src/hotkeys.rs index aa1390ef5..b6ddcf472 100644 --- a/openless-all/app/linux-egui/src/hotkeys.rs +++ b/openless-all/app/linux-egui/src/hotkeys.rs @@ -64,6 +64,12 @@ pub enum LinuxHotkeyEvent { QaPressed, SelectionPolishPressed, TranslationPressed, + SwitchStylePressed, + OpenAppPressed, + StylePackPressed { + symbol: u32, + states: u32, + }, } pub struct Fcitx5HotkeyListener { @@ -325,6 +331,11 @@ fn event_from_signal( ("QaShortcutEvent", true) => Some(LinuxHotkeyEvent::QaPressed), ("SelectionPolishEvent", true) => Some(LinuxHotkeyEvent::SelectionPolishPressed), ("TranslationModifierEvent", true) => Some(LinuxHotkeyEvent::TranslationPressed), + ("SwitchStyleEvent", true) => Some(LinuxHotkeyEvent::SwitchStylePressed), + ("OpenAppEvent", true) => Some(LinuxHotkeyEvent::OpenAppPressed), + ("StylePackHotkeyEvent", true) => { + Some(LinuxHotkeyEvent::StylePackPressed { symbol, states }) + } _ => None, } } @@ -375,6 +386,21 @@ mod tests { event_from_signal("QaShortcutEvent", 0, 0, true, at, &press_ids), Some(LinuxHotkeyEvent::QaPressed) ); + assert_eq!( + event_from_signal("SwitchStyleEvent", 11, 12, true, at, &press_ids), + Some(LinuxHotkeyEvent::SwitchStylePressed) + ); + assert_eq!( + event_from_signal("OpenAppEvent", 13, 14, true, at, &press_ids), + Some(LinuxHotkeyEvent::OpenAppPressed) + ); + assert_eq!( + event_from_signal("StylePackHotkeyEvent", 15, 16, true, at, &press_ids), + Some(LinuxHotkeyEvent::StylePackPressed { + symbol: 15, + states: 16, + }) + ); let less_pressed = event_from_signal("LessComputerKeyEvent", 3, 4, true, at, &press_ids) .expect("Less Computer press"); let LinuxHotkeyEvent::LessComputerPressed { diff --git a/openless-all/app/linux-egui/src/i18n.rs b/openless-all/app/linux-egui/src/i18n.rs new file mode 100644 index 000000000..cb9727406 --- /dev/null +++ b/openless-all/app/linux-egui/src/i18n.rs @@ -0,0 +1,1370 @@ +//! Rust-native UI localization for the Linux egui host. +//! +//! This layer deliberately mirrors the Tauri UI's language choices +//! (`system`, `zh-CN`, `zh-TW`, `en`, `ja`, `ko`) so both UIs offer the same +//! set. The source of truth / fallback is `zh-CN`, exactly like the Tauri +//! `i18n/index.ts`; all five concrete locales are bundled statically so there +//! is no network fetch and no runtime loading. +//! +//! UI text is looked up through a typed catalog rather than string-typed +//! `format!` splices so the completeness/fallback contracts are enforceable +//! and a locale switch re-renders deterministically. + +use std::fmt::Display; + +/// The five concrete languages the Linux egui UI supports. +#[derive(Clone, Copy, PartialEq, Eq, Debug, Hash)] +pub enum Lang { + ZhCn, + ZhTw, + En, + Ja, + Ko, +} + +/// The persisted UI-locale preference. `System` means "follow the host OS +/// locale"; `Lang(lang)` is an explicit user choice, matching the Tauri +/// `setLocalePreference` model where only an explicit tag is stored. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum LocalePref { + System, + Lang(Lang), +} + +pub const LANGS: [Lang; 5] = [Lang::ZhCn, Lang::ZhTw, Lang::En, Lang::Ja, Lang::Ko]; + +/// JSON/wire tags, matching the Tauri `SUPPORTED_LOCALES`. +pub const FOLLOW_SYSTEM: &str = "system"; + +impl Lang { + /// Canonical BCP-47-ish tag for a concrete language. + pub fn tag(self) -> &'static str { + match self { + Lang::ZhCn => "zh-CN", + Lang::ZhTw => "zh-TW", + Lang::En => "en", + Lang::Ja => "ja", + Lang::Ko => "ko", + } + } + + /// Parse a BCP-47 tag / locale identifier into a supported language. + /// Handles region and script suffixes (`zh-Hant-TW`, `zh_TW`, `ja_JP`…). + pub fn parse(tag: &str) -> Option { + let normalized = tag.replace('-', "_").to_ascii_lowercase(); + if normalized.starts_with("zh") { + // Traditional markers win regardless of where they appear. + if normalized.contains("hant") + || normalized.contains("_tw") + || normalized.contains("_hk") + || normalized.contains("_mo") + { + return Some(Lang::ZhTw); + } + return Some(Lang::ZhCn); + } + if normalized.starts_with("ja") { + return Some(Lang::Ja); + } + if normalized.starts_with("ko") { + return Some(Lang::Ko); + } + if normalized.starts_with("en") { + return Some(Lang::En); + } + None + } +} + +impl LocalePref { + pub fn from_tag(tag: &str) -> LocalePref { + if tag.eq_ignore_ascii_case(FOLLOW_SYSTEM) { + LocalePref::System + } else if let Some(lang) = Lang::parse(tag) { + LocalePref::Lang(lang) + } else { + LocalePref::System + } + } + + pub fn to_tag(self) -> String { + match self { + LocalePref::System => FOLLOW_SYSTEM.to_string(), + LocalePref::Lang(lang) => lang.tag().to_string(), + } + } + + /// Resolve this preference against the running host into a concrete lang. + /// `System` falls through to `resolve_system_lang()`, mirroring Tauri's + /// `detectSystemLocale()`. + pub fn resolve(self) -> Lang { + match self { + LocalePref::System => resolve_system_lang(), + LocalePref::Lang(lang) => lang, + } + } +} + +/// Resolve the host OS locale into a supported language without touching any +/// UI. Uses the same precedence as typical Linux tooling: `LC_ALL`, then +/// `LC_MESSAGES`, then `LANG`; an unparseable or unset value falls back to `en` +/// rather than guessing. +pub fn resolve_system_lang() -> Lang { + for variable in ["LC_ALL", "LC_MESSAGES", "LANG"] { + if let Ok(value) = std::env::var(variable) { + if let Some(lang) = Lang::parse(&value) { + return lang; + } + } + } + Lang::En +} + +/// One catalog row: a stable key plus the text in all five concrete locales. +/// Index `0` is the `zh-CN` source of truth. +pub struct Msg { + pub key: &'static str, + pub text: [&'static str; 5], +} + +/// Order helper so callers can write rows positionally and stay readable. +/// `[zh, zh_tw, en, ja, ko]` is the single canonical order used everywhere. +#[allow(dead_code)] +const fn row( + zh: &'static str, + zh_tw: &'static str, + en: &'static str, + ja: &'static str, + ko: &'static str, +) -> [&'static str; 5] { + [zh, zh_tw, en, ja, ko] +} + +// Global catalog of every UI string the Linux egui host renders. +// +// Convention: +// * `zh` (zh-CN) is the source of truth and is never empty. +// * A `[&str;5]` value that is empty for a non-zh locale means "fall back to +// zh-CN for this key" (`tr` handles that); the completeness test asserts +// that the zh-CN column is fully populated and that every key actually +// referenced resolves. +pub const CATALOG: &[Msg] = &[ + // ---- Shell / navigation ------------------------------------------------ + Msg { + key: "shell.workspace", + text: row( + "工作台", + "工作臺", + "Workspace", + "ワークスペース", + "작업 공간", + ), + }, + Msg { + key: "shell.capabilities", + text: row("能力", "能力", "Capabilities", "機能", "기능"), + }, + Msg { + key: "nav.overview", + text: row("概览", "概覽", "Overview", "概要", "개요"), + }, + Msg { + key: "nav.history", + text: row("历史", "歷史", "History", "履歴", "기록"), + }, + Msg { + key: "nav.vocab", + text: row( + "词汇与纠错", + "詞彙與糾錯", + "Vocabulary & Correction", + "語彙と修正", + "어휘 및 교정", + ), + }, + Msg { + key: "nav.styles", + text: row( + "风格包", + "風格包", + "Style Packs", + "スタイルパック", + "스타일 팩", + ), + }, + Msg { + key: "nav.marketplace", + text: row( + "Marketplace", + "Marketplace", + "Marketplace", + "マーケットプレイス", + "마켓플레이스", + ), + }, + Msg { + key: "nav.providers", + text: row( + "Provider 与设置", + "Provider 與設定", + "Providers & Settings", + "プロバイダーと設定", + "프로바이더 및 설정", + ), + }, + Msg { + key: "nav.models", + text: row( + "本地模型", + "本機模型", + "Local Models", + "ローカルモデル", + "로컬 모델", + ), + }, + Msg { + key: "nav.assistant", + text: row( + "Less Computer", + "Less Computer", + "Less Computer", + "Less Computer", + "Less Computer", + ), + }, + Msg { + key: "nav.settings", + text: row("设置", "設定", "Settings", "設定", "설정"), + }, + // ---- Common controls ---------------------------------------------------- + Msg { + key: "btn.refresh", + text: row("刷新", "重新整理", "Refresh", "更新", "새로고침"), + }, + Msg { + key: "btn.retry", + text: row("重试", "重試", "Retry", "再試行", "다시 시도"), + }, + Msg { + key: "btn.start", + text: row("开始", "開始", "Start", "開始", "시작"), + }, + Msg { + key: "btn.stop", + text: row("停止", "停止", "Stop", "停止", "중지"), + }, + Msg { + key: "btn.cancel", + text: row("取消", "取消", "Cancel", "キャンセル", "취소"), + }, + Msg { + key: "btn.close", + text: row("关闭", "關閉", "Close", "閉じる", "닫기"), + }, + Msg { + key: "btn.send", + text: row("发送", "傳送", "Send", "送信", "보내기"), + }, + Msg { + key: "btn.insert", + text: row("插入", "插入", "Insert", "挿入", "삽입"), + }, + Msg { + key: "btn.confirm_replace", + text: row( + "确认替换", + "確認替換", + "Confirm replace", + "置換を確定", + "바꾸기 확인", + ), + }, + Msg { + key: "btn.undo", + text: row("撤销", "復原", "Undo", "元に戻す", "실행 취소"), + }, + Msg { + key: "btn.run", + text: row("运行", "執行", "Run", "実行", "실행"), + }, + Msg { + key: "btn.allow", + text: row("允许", "允許", "Allow", "許可", "허용"), + }, + Msg { + key: "btn.deny", + text: row("拒绝", "拒絕", "Deny", "拒否", "거부"), + }, + Msg { + key: "btn.end_recording", + text: row( + "结束录音", + "結束錄音", + "Stop recording", + "録音終了", + "녹음 종료", + ), + }, + Msg { + key: "btn.stop_recording", + text: row( + "停止录音", + "停止錄音", + "Stop recording", + "録音停止", + "녹음 중지", + ), + }, + Msg { + key: "btn.voice_ask", + text: row( + "语音提问", + "語音提問", + "Voice ask", + "音声で質問", + "음성 질문", + ), + }, + Msg { + key: "heading.dictation", + text: row("听写", "聽寫", "Dictation", "ディクテーション", "받아쓰기"), + }, + Msg { + key: "heading.qa", + text: row("问答", "問答", "Q&A", "Q&A", "Q&A"), + }, + Msg { + key: "heading.overview", + text: row("概览", "概覽", "Overview", "概要", "개요"), + }, + Msg { + key: "heading.selection_preview", + text: row( + "选区预览", + "選區預覽", + "Selection preview", + "選択範囲プレビュー", + "선택 영역 미리보기", + ), + }, + Msg { + key: "heading.insert_preview", + text: row( + "插入预览", + "插入預覽", + "Insert preview", + "挿入プレビュー", + "삽입 미리보기", + ), + }, + Msg { + key: "heading.qa_preview", + text: row( + "划词追问", + "劃詞追問", + "Ask on selection", + "選択範囲で質問", + "선택어 질문", + ), + }, + Msg { + key: "heading.recent", + text: row("最近识别", "最近辨識", "Recent", "最近の認識", "최근 기록"), + }, + Msg { + key: "heading.local_models", + text: row( + "本地模型", + "本機模型", + "Local Models", + "ローカルモデル", + "로컬 모델", + ), + }, + // ---- Dictation / empty states ------------------------------------------ + Msg { + key: "dictation.recording", + text: row("正在录音", "正在錄音", "Recording…", "録音中…", "녹음 중…"), + }, + Msg { + key: "dictation.no_transcript", + text: row( + "尚无转写结果", + "尚無轉寫結果", + "No transcription yet", + "まだ文字起こしはありません", + "아직 받아쓰기 결과가 없습니다", + ), + }, + Msg { + key: "less_computer.done", + text: row( + "Less Computer 已完成", + "Less Computer 已完成", + "Less Computer finished", + "Less Computer が完了しました", + "Less Computer 완료", + ), + }, + Msg { + key: "less_computer.cancelled", + text: row( + "Less Computer 已取消", + "Less Computer 已取消", + "Less Computer cancelled", + "Less Computer をキャンセルしました", + "Less Computer 취소됨", + ), + }, + Msg { + key: "less_computer.no_output", + text: row( + "尚无 Agent 输出", + "尚無 Agent 輸出", + "No agent output yet", + "まだエージェントの出力はありません", + "아직 에이전트 출력이 없습니다", + ), + }, + Msg { + key: "approval.submitted", + text: row( + "审批已提交", + "審批已提交", + "Approval submitted", + "承認を送信しました", + "승인이 제출되었습니다", + ), + }, + Msg { + key: "approval.request_run", + text: row( + "请求执行:{}", + "請求執行:{}", + "Requested execution: {}", + "実行リクエスト: {}", + "실행 요청: {}", + ), + }, + Msg { + key: "selection.replace_completed", + text: row( + "最近一次选区替换已完成", + "最近一次選區替換已完成", + "Last selection replace completed", + "最後の選択範囲置換が完了しました", + "마지막 선택 영역 바꾸기가 완료되었습니다", + ), + }, + Msg { + key: "qa.submitted", + text: row( + "问答已提交", + "問答已提交", + "Question submitted", + "質問を送信しました", + "질문이 제출되었습니다", + ), + }, + Msg { + key: "qa.closed", + text: row( + "问答已关闭", + "問答已關閉", + "Q&A closed", + "Q&A を閉じました", + "Q&A가 닫혔습니다", + ), + }, + Msg { + key: "qa.recording_updated", + text: row( + "问答录音状态已更新", + "問答錄音狀態已更新", + "Q&A recording updated", + "Q&A の録音状態を更新しました", + "Q&A 녹음 상태가 업데이트되었습니다", + ), + }, + Msg { + key: "selection.replaced", + text: row( + "选区替换已确认", + "選區替換已確認", + "Selection replace confirmed", + "選択範囲の置換を確認しました", + "선택 영역 바꾸기가 확인되었습니다", + ), + }, + Msg { + key: "selection.cancelled", + text: row( + "选区替换已取消", + "選區替換已取消", + "Selection replace cancelled", + "選択範囲の置換をキャンセルしました", + "선택 영역 바꾸기가 취소되었습니다", + ), + }, + Msg { + key: "selection.reverted", + text: row( + "选区替换已撤销", + "選區替換已復原", + "Selection replace reverted", + "選択範囲の置換を元に戻しました", + "선택 영역 바꾸기가 취소되었습니다", + ), + }, + Msg { + key: "voice.cancelled", + text: row( + "语音会话已取消", + "語音工作階段已取消", + "Voice session cancelled", + "音声セッションをキャンセルしました", + "음성 세션이 취소되었습니다", + ), + }, + // ---- Overview metrics --------------------------------------------------- + Msg { + key: "metric.chars_today", + text: row( + "今日字数", + "今日字數", + "Chars today", + "今日の文字数", + "오늘 문자 수", + ), + }, + Msg { + key: "metric.duration_today", + text: row( + "今日时长", + "今日時長", + "Time today", + "今日の時間", + "오늘 시간", + ), + }, + Msg { + key: "metric.avg_latency", + text: row( + "平均延迟", + "平均延遲", + "Avg latency", + "平均遅延", + "평균 지연", + ), + }, + Msg { + key: "metric.total", + text: row( + "累计记录", + "累計記錄", + "Total records", + "累計記録", + "누적 기록", + ), + }, + Msg { + key: "metric.no_data_today", + text: row( + "今日暂无", + "今日暫無", + "None today", + "今日はありません", + "오늘 없음", + ), + }, + Msg { + key: "metric.near7", + text: row( + "近7天 {} 段 · 近30天 {} 段", + "近7天 {} 段 · 近30天 {} 段", + "{} in 7d · {} in 30d", + "直近7日 {} 件 · 30日 {} 件", + "7일 {}건 · 30일 {}건", + ), + }, + Msg { + key: "metric.total_segments", + text: row( + "共 {} 段", + "共 {} 段", + "{} segments", + "合計 {} 件", + "총 {}건", + ), + }, + Msg { + key: "loading.overview", + text: row( + "正在加载概览数据…", + "正在載入概覽資料…", + "Loading overview…", + "概要を読み込み中…", + "개요를 불러오는 중…", + ), + }, + Msg { + key: "overview.load_failed", + text: row( + "概览加载失败", + "概覽載入失敗", + "Failed to load overview", + "概要の読み込みに失敗しました", + "개요를 불러오지 못했습니다", + ), + }, + Msg { + key: "overview.provider_cards", + text: row( + "ASR 语音识别", + "ASR 語音辨識", + "ASR speech", + "ASR 音声認識", + "ASR 음성 인식", + ), + }, + Msg { + key: "overview.provider_cards_llm", + text: row( + "LLM 大模型", + "LLM 大模型", + "LLM model", + "LLM モデル", + "LLM 모델", + ), + }, + Msg { + key: "overview.not_set", + text: row( + "(未设置)", + "(未設定)", + "(not set)", + "(未設定)", + "(설정 안 됨)", + ), + }, + Msg { + key: "overview.configured", + text: row("已配置", "已設定", "Configured", "設定済み", "설정됨"), + }, + Msg { + key: "overview.configured_dot", + text: row( + "● 已配置", + "● 已設定", + "● Configured", + "● 設定済み", + "● 설정됨", + ), + }, + Msg { + key: "overview.unconfigured", + text: row("未配置", "未設定", "Not configured", "未設定", "미설정"), + }, + Msg { + key: "overview.recent_empty", + text: row( + "暂无识别记录,点击上方「开始」说第一句吧。", + "暫無辨識紀錄,點按上方「開始」說第一句吧。", + "No recent dictation yet — hit Start above to begin.", + "まだ認識記録はありません。上の「開始」を押してください。", + "아직 받아쓰기 기록이 없습니다. 위의 시작을 눌러주세요.", + ), + }, + Msg { + key: "overview.no_text", + text: row( + "(无文本)", + "(無文字)", + "(no text)", + "(テキストなし)", + "(텍스트 없음)", + ), + }, + Msg { + key: "overview.heatmap_title", + text: row( + "近一年每日活动次数", + "近一年每日活動次數", + "Daily activity · past year", + "過去1年の日別活動回数", + "지난 1년 일별 활동 횟수", + ), + }, + Msg { + key: "overview.heatmap_empty", + text: row( + "暂无活动数据", + "暫無活動資料", + "No activity data yet", + "まだ活動データはありません", + "아직 활동 데이터가 없습니다", + ), + }, + Msg { + key: "overview.heatmap_less", + text: row("少", "少", "Less", "少", "적음"), + }, + Msg { + key: "overview.heatmap_more", + text: row("多", "多", "More", "多", "많음"), + }, + Msg { + key: "overview.heatmap_footnote", + text: row( + "(近 {} 天 · {} 天有记录)", + "(近 {} 天 · {} 天有記錄)", + "({} days · {} active)", + "({} 日間・{} 日記録あり)", + "({}일 · {}일 기록)", + ), + }, + // ---- Durations --------------------------------------------------------- + Msg { + key: "dur.ms", + text: row("{} 毫秒", "{} 毫秒", "{} ms", "{} ミリ秒", "{} 밀리초"), + }, + Msg { + key: "dur.sec", + text: row("{} 秒", "{} 秒", "{} s", "{} 秒", "{} 초"), + }, + Msg { + key: "dur.min_sec", + text: row( + "{} 分 {} 秒", + "{} 分 {} 秒", + "{}m {}s", + "{} 分 {} 秒", + "{}분 {}초", + ), + }, + // ---- Language selector -------------------------------------------------- + Msg { + key: "settings.language", + text: row("界面语言", "介面語言", "UI language", "UI 言語", "UI 언어"), + }, + Msg { + key: "settings.language_follow_system", + text: row( + "跟随系统", + "跟隨系統", + "Follow system", + "システムに従う", + "시스템 따르기", + ), + }, + Msg { + key: "lang.zh-CN", + text: row( + "简体中文", + "简体中文", + "Simplified Chinese", + "簡体中国語", + "중국어(간체)", + ), + }, + Msg { + key: "lang.zh-TW", + text: row( + "繁体中文", + "繁體中文", + "Traditional Chinese", + "繁体中国語", + "중국어(번체)", + ), + }, + Msg { + key: "lang.en", + text: row("English", "English", "English", "英語", "영어"), + }, + Msg { + key: "lang.ja", + text: row("日本語", "日本語", "Japanese", "日本語", "일본어"), + }, + Msg { + key: "lang.ko", + text: row("한국어", "한국어", "Korean", "韓国語", "한국어"), + }, + Msg { + key: "settings.locale_saved", + text: row( + "界面语言已更新", + "介面語言已更新", + "UI language updated", + "UI 言語を更新しました", + "UI 언어가 업데이트되었습니다", + ), + }, + // ---- Appearance (settings) --------------------------------------------- + Msg { + key: "settings.theme", + text: row("主题", "佈景主題", "Theme", "テーマ", "테마"), + }, + Msg { + key: "theme.light", + text: row("浅色", "淺色", "Light", "ライト", "라이트"), + }, + Msg { + key: "theme.dark", + text: row("深色", "深色", "Dark", "ダーク", "다크"), + }, + Msg { + key: "theme.system", + text: row( + "系统默认", + "系統預設", + "System default", + "システム既定", + "시스템 기본", + ), + }, + // ---- Status / host ------------------------------------------------------ + Msg { + key: "status.core_started", + text: row( + "Core 2.0 已启动", + "Core 2.0 已啟動", + "Core 2.0 ready", + "Core 2.0 起動済み", + "Core 2.0 시작됨", + ), + }, + Msg { + key: "status.startup_failed", + text: row( + "启动失败", + "啟動失敗", + "Startup failed", + "起動に失敗しました", + "시작 실패", + ), + }, + // ---- Buttons (models / providers / vocab / styles / marketplace / history) + Msg { key: "btn.create", text: row("创建", "建立", "Create", "作成", "생성") }, + Msg { key: "btn.delete", text: row("删除", "刪除", "Delete", "削除", "삭제") }, + Msg { key: "btn.confirm_delete", text: row("确认删除", "確認刪除", "Confirm delete", "削除を確認", "삭제 확인") }, + Msg { key: "btn.cancel_delete", text: row("取消删除", "取消刪除", "Cancel", "キャンセル", "삭제 취소") }, + Msg { key: "btn.enable", text: row("启用", "啟用", "Enable", "有効化", "사용") }, + Msg { key: "btn.disable", text: row("禁用", "停用", "Disable", "無効化", "사용 안 함") }, + Msg { key: "btn.move_up", text: row("上移", "上移", "Move up", "上へ移動", "위로 이동") }, + Msg { key: "btn.move_down", text: row("下移", "下移", "Move down", "下へ移動", "아래로 이동") }, + Msg { key: "btn.check_now", text: row("立即检查", "立即檢查", "Check now", "今すぐ確認", "지금 확인") }, + Msg { key: "btn.download_install", text: row("下载并安装", "下載並安裝", "Download & install", "ダウンロードしてインストール", "다운로드 및 설치") }, + Msg { key: "btn.open_releases", text: row("打开发布页", "開啟發布頁", "Open releases page", "リリースページを開く", "릴리스 페이지 열기") }, + Msg { key: "btn.accept", text: row("接受", "接受", "Accept", "承諾", "수락") }, + Msg { key: "btn.ignore", text: row("忽略", "忽略", "Ignore", "無視", "무시") }, + Msg { key: "btn.close_all", text: row("全部关闭", "全部關閉", "Dismiss all", "すべて閉じる", "모두 닫기") }, + Msg { key: "btn.apply", text: row("应用", "套用", "Apply", "適用", "적용") }, + Msg { key: "btn.hide_builtin", text: row("隐藏内置预设", "隱藏內建預設", "Hide built-in preset", "組み込みプリセットを非表示", "내장 프리셋 숨기기") }, + Msg { key: "btn.restore_builtin", text: row("恢复内置预设:{}", "還原內建預設:{}", "Restore built-in preset: {}", "組み込みプリセットを復元: {}", "내장 프리셋 복원: {}") }, + Msg { key: "btn.save_preset", text: row("保存预设", "儲存預設", "Save preset", "プリセットを保存", "프리셋 저장") }, + Msg { key: "btn.add", text: row("添加", "新增", "Add", "追加", "추가") }, + Msg { key: "btn.add_rule", text: row("添加规则", "新增規則", "Add rule", "ルールを追加", "규칙 추가") }, + Msg { key: "btn.save_style", text: row("保存风格包", "儲存風格包", "Save style pack", "スタイルパックを保存", "스타일 팩 저장") }, + Msg { key: "btn.cancel_edit", text: row("取消编辑", "取消編輯", "Cancel editing", "編集をキャンセル", "편집 취소") }, + Msg { key: "btn.new_style", text: row("新建风格包", "新增風格包", "New style pack", "新規スタイルパック", "새 스타일 팩") }, + Msg { key: "btn.import_zip", text: row("导入 ZIP", "匯入 ZIP", "Import ZIP", "ZIP をインポート", "ZIP 가져오기") }, + Msg { key: "btn.export_zip", text: row("导出 ZIP", "匯出 ZIP", "Export ZIP", "ZIP をエクスポート", "ZIP 내보내기") }, + Msg { key: "btn.preview_runtime", text: row("运行时 Prompt 预览", "執行期 Prompt 預覽", "Runtime prompt preview", "実行時プロンプトプレビュー", "실행 프롬프트 미리보기") }, + Msg { key: "btn.edit", text: row("编辑", "編輯", "Edit", "編集", "편집") }, + Msg { key: "btn.reset_builtin", text: row("恢复内置默认", "還原內建預設", "Restore built-in default", "組み込みデフォルトに戻す", "내장 기본값 복원") }, + Msg { key: "btn.set_active", text: row("设为 active", "設為 active", "Set active", "アクティブに設定", "활성 설정") }, + Msg { key: "btn.save_fields", text: row("保存字段/Secret", "儲存欄位/Secret", "Save fields/Secret", "欄位/Secret を保存", "필드/Secret 저장") }, + Msg { key: "btn.clear_secret", text: row("清除 Secret", "清除 Secret", "Clear Secret", "Secret を消去", "Secret 지우기") }, + Msg { key: "btn.validate", text: row("验证连接", "驗證連線", "Validate connection", "接続を検証", "연결 검증") }, + Msg { key: "btn.list_models", text: row("列出模型", "列出模型", "List models", "モデルを一覧表示", "모델 나열") }, + Msg { key: "btn.search_refresh", text: row("搜索/刷新", "搜尋/重新整理", "Search & refresh", "検索/更新", "검색/새로고침") }, + Msg { key: "btn.github_login", text: row("GitHub 登录", "GitHub 登入", "GitHub sign in", "GitHub にログイン", "GitHub 로그인") }, + Msg { key: "btn.logout", text: row("退出登录", "登出", "Sign out", "サインアウト", "로그아웃") }, + Msg { key: "btn.my_publish_like", text: row("我的发布/喜欢", "我的發布/喜歡", "My uploads / likes", "マイ投稿・お気に入り", "내 업로드/좋아요") }, + Msg { key: "btn.open_github", text: row("打开 GitHub", "開啟 GitHub", "Open GitHub", "GitHub を開く", "GitHub 열기") }, + Msg { key: "btn.check_auth", text: row("检查授权", "檢查授權", "Check authorization", "認証を確認", "인증 확인") }, + Msg { key: "btn.install", text: row("安装", "安裝", "Install", "インストール", "설치") }, + Msg { key: "btn.toggle_like", text: row("喜欢/取消喜欢", "喜歡/取消喜歡", "Like / unlike", "いいね/いいね解除", "좋아요/좋아요 취소") }, + Msg { key: "btn.detail", text: row("详情", "詳情", "Details", "詳細", "상세") }, + Msg { key: "btn.download_zip", text: row("下载 ZIP", "下載 ZIP", "Download ZIP", "ZIP をダウンロード", "ZIP 다운로드") }, + Msg { key: "btn.upload_update", text: row("上传/更新", "上傳/更新", "Upload / update", "アップロード/更新", "업로드/업데이트") }, + Msg { key: "btn.delete_publish", text: row("删除发布", "刪除發布", "Delete release", "リリースを削除", "배포 삭제") }, + Msg { key: "btn.clear_all", text: row("清空全部", "清空全部", "Clear all", "すべてクリア", "전체 지우기") }, + Msg { key: "btn.copy", text: row("复制", "複製", "Copy", "コピー", "복사") }, + Msg { key: "btn.repolish", text: row("重新润色", "重新潤飾", "Repolish", "再推敲", "다시 다듬기") }, + Msg { key: "btn.play_recording", text: row("播放录音", "播放錄音", "Play recording", "録音を再生", "녹음 재생") }, + Msg { key: "btn.export_recording", text: row("导出录音", "匯出錄音", "Export recording", "録音をエクスポート", "녹음 내보내기") }, + Msg { key: "btn.retranscribe", text: row("重新转写", "重新轉寫", "Retranscribe", "再文字起こし", "다시 받아쓰기") }, + Msg { key: "btn.preload_current", text: row("预加载当前模型", "預載目前模型", "Preload active model", "現在のモデルをプリロード", "현재 모델 미리 로드") }, + Msg { key: "btn.release_model", text: row("释放模型", "釋放模型", "Release model", "モデルを解放", "모델 해제") }, + Msg { key: "btn.cancel_prepare", text: row("取消准备", "取消準備", "Cancel preparation", "準備をキャンセル", "준비 취소") }, + Msg { key: "btn.download", text: row("下载", "下載", "Download", "ダウンロード", "다운로드") }, + Msg { key: "btn.cancel_download", text: row("取消下载", "取消下載", "Cancel download", "ダウンロードをキャンセル", "다운로드 취소") }, + Msg { key: "btn.verify_prepare", text: row("验证/准备", "驗證/準備", "Verify / prepare", "検証/準備", "검증/준비") }, + Msg { key: "btn.test", text: row("测试", "測試", "Test", "テスト", "테스트") }, + Msg { key: "btn.export_error_log", text: row("导出错误日志", "匯出錯誤日誌", "Export error log", "エラーログをエクスポート", "오류 로그 내보내기") }, + Msg { key: "btn.save_settings", text: row("保存设置", "儲存設定", "Save settings", "設定を保存", "설정 저장") }, + Msg { key: "btn.reset_pairing", text: row("重置配对码", "重置配對碼", "Reset pairing code", "ペアリングコードをリセット", "페어링 코드 재설정") }, + Msg { key: "btn.activate", text: row("激活", "啟用", "Activate", "アクティブ化", "활성화") }, + Msg { key: "btn.new_channel", text: row("新增渠道", "新增管道", "Add channel", "チャネルを追加", "채널 추가") }, + Msg { key: "btn.refresh_channel", text: row("刷新渠道", "重新整理管道", "Refresh channels", "チャネルを更新", "채널 새로고침") }, + Msg { key: "btn.restore_default", text: row("恢复内置默认", "還原內建預設", "Restore built-in default", "組み込みデフォルトに戻す", "내장 기본값 복원") }, + Msg { key: "btn.set_current", text: row("设为当前", "設為目前", "Set current", "現在に設定", "현재로 설정") }, + Msg { key: "btn.enable_label", text: row("启用", "啟用", "Enable", "有効化", "사용") }, + Msg { key: "btn.status_active", text: row(" · active", " · active", " · active", " · active", " · 활성") }, + Msg { key: "btn.status_disabled", text: row(" · 已禁用", " · 已停用", " · disabled", " · 無効", " · 비활성화됨") }, + // ---- Page / section headings + Msg { key: "head.software_update", text: row("软件更新", "軟體更新", "Software update", "ソフトウェア更新", "소프트웨어 업데이트") }, + Msg { key: "head.pending_corrections", text: row("待确认的手改建议", "待確認的手動修改建議", "Pending manual corrections", "保留中の手動修正候補", "대기 중인 수동 교정 제안") }, + Msg { key: "head.vocab_presets", text: row("词汇预设", "詞彙預設", "Vocabulary presets", "語彙プリセット", "어휘 프리셋") }, + Msg { key: "head.custom_vocab", text: row("自定义词汇", "自訂詞彙", "Custom vocabulary", "カスタム語彙", "사용자 어휘") }, + Msg { key: "head.correction_rules", text: row("纠错规则", "糾錯規則", "Correction rules", "修正ルール", "교정 규칙") }, + Msg { key: "head.style_pack_editor", text: row("风格包编辑器", "風格包編輯器", "Style pack editor", "スタイルパック編集", "스타일 팩 편집기") }, + Msg { key: "head.marketplace_mine", text: row("我的 Marketplace", "我的 Marketplace", "My Marketplace", "マイマーケットプレイス", "내 마켓플레이스") }, + Msg { key: "head.publish_local", text: row("发布本地风格包", "發佈本機風格包", "Publish a local style pack", "ローカルスタイルパックを公開", "로컬 스타일 팩 배포") }, + Msg { key: "head.marketplace_detail", text: row("详情:{}", "詳情:{}", "Details: {}", "詳細: {}", "상세: {}") }, + Msg { key: "head.history_empty", text: row("历史", "歷史", "History", "履歴", "기록") }, + // ---- Update UI + Msg { key: "update.available", text: row("可用版本:{}", "可用版本:{}", "Available version: {}", "利用可能なバージョン: {}", "사용 가능한 버전: {}") }, + Msg { key: "update.downloaded", text: row("已下载 {} 字节", "已下載 {} 位元組", "{} bytes downloaded", "{} バイトをダウンロード", "{}바이트 다운로드됨") }, + Msg { key: "update.manual_notice", text: row("deb/rpm 与开发构建由包管理器或发布页更新。", "deb/rpm 與開發建置由套件管理員或發布頁更新。", "deb/rpm and dev builds update via your package manager or the releases page.", "deb/rpm と開発ビルドはパッケージマネージャまたはリリースページで更新されます。", "deb/rpm 및 개발 빌드는 패키지 관리자 또는 릴리스 페이지로 업데이트됩니다.") }, + Msg { key: "update.system_managed", text: row("当前安装包由系统包管理器更新", "目前套件由系統套件管理員更新", "This build is updated by your system package manager", "このパッケージはシステムのパッケージマネージャで更新されます", "이 패키지는 시스템 패키지 관리자가 업데이트합니다") }, + Msg { key: "update.discovered", text: row("发现新版本 {}", "發現新版本 {}", "New version available: {}", "新しいバージョン: {}", "새 버전 발견: {}") }, + Msg { key: "update.up_to_date", text: row("当前已是最新版本", "目前已是最新版本", "You are up to date", "最新バージョン입니다", "최신 버전입니다") }, + Msg { key: "update.check_failed", text: row("检查更新失败:{}", "檢查更新失敗:{}", "Update check failed: {}", "更新確認に失敗: {}", "업데이트 확인 실패: {}") }, + Msg { key: "update.installed_restart", text: row("已安装 {},请重启 OpenLess", "已安裝 {},請重新啟動 OpenLess", "{} installed — restart OpenLess", "{} をインストールしました。OpenLess を再起動してください", "{} 설치됨 — OpenLess를 재시작하세요") }, + Msg { key: "update.install_failed", text: row("安装更新失败:{}", "安裝更新失敗:{}", "Update install failed: {}", "更新のインストールに失敗: {}", "업데이트 설치 실패: {}") }, + // ---- Settings / preferences + Msg { key: "settings.recording_input", text: row("录音与输入", "錄音與輸入", "Recording & input", "録音と入力", "녹음 및 입력") }, + Msg { key: "settings.rec_mode", text: row("录音方式", "錄音方式", "Recording mode", "録音方式", "녹음 방식") }, + Msg { key: "recmode.toggle", text: row("切换", "切換", "Toggle", "トグル", "전환") }, + Msg { key: "recmode.hold", text: row("按住说话", "按住說話", "Push to talk", "押して話す", "누르고 말하기") }, + Msg { key: "recmode.double_click", text: row("双击", "雙擊", "Double-click", "ダブルクリック", "더블 클릭") }, + Msg { key: "recmode.auto", text: row("自动识别", "自動辨識", "Auto detect", "自動認識", "자동 인식") }, + Msg { key: "settings.auto_stop", text: row("说完后自动停止", "說畢後自動停止", "Stop automatically after silence", "無音で自動停止", "침묵 시 자동 중지") }, + Msg { key: "settings.silence_duration", text: row("连续静音时长", "連續靜音時長", "Silence timeout", "無音の継続時間", "침묵 지속 시간") }, + Msg { key: "settings.seconds", text: row("{} 秒", "{} 秒", "{} s", "{} 秒", "{}초") }, + Msg { key: "settings.microphone", text: row("麦克风", "麥克風", "Microphone", "マイク", "마이크") }, + Msg { key: "settings.system_default", text: row("系统默认", "系統預設", "System default", "システム既定", "시스템 기본") }, + Msg { key: "settings.mute_while", text: row("录音期间暂时静音系统声音", "錄音期間暫時靜音系統聲音", "Mute system audio while recording", "録音中はシステム音声をミュート", "녹음 중 시스템 소리 음소거") }, + Msg { key: "settings.cue_audio", text: row("录音开始/结束播放提示音", "錄音開始/結束播放提示音", "Play cue sounds when recording starts/stops", "録音開始/終了時に合図音を再生", "녹음 시작/종료 시 알림음 재생") }, + Msg { key: "settings.appearance", text: row("外观", "外觀", "Appearance", "外観", "모양") }, + Msg { key: "theme.follow_system", text: row("跟随系统", "跟隨系統", "Follow system", "システムに従う", "시스템 따르기") }, + Msg { key: "settings.show_heatmap", text: row("显示活动热力图", "顯示活動熱力圖", "Show activity heatmap", "活動ヒートマップを表示", "활동 히트맵 표시") }, + Msg { key: "settings.hotkeys_group", text: row("fcitx5 快捷键", "fcitx5 快速鍵", "fcitx5 shortcuts", "fcitx5 ショートカット", "fcitx5 단축키") }, + Msg { key: "settings.streaming_insert", text: row("流式插入", "串流插入", "Streaming insert", "ストリーミング挿入", "스트리밍 삽입") }, + Msg { key: "settings.enable_coding_agent", text: row("启用 Less Computer", "啟用 Less Computer", "Enable Less Computer", "Less Computer を有効化", "Less Computer 사용") }, + Msg { key: "settings.start_minimized", text: row("启动时隐藏主窗口", "啟動時隱藏主視窗", "Hide main window on launch", "起動時にメインウィンドウを非表示", "시작 시 메인 창 숨기기") }, + Msg { key: "settings.launch_at_login", text: row("开机启动", "開機啟動", "Launch at login", "ログイン時に起動", "로그인 시 실행") }, + Msg { key: "settings.auto_update", text: row("自动检查更新", "自動檢查更新", "Automatically check for updates", "自動更新確認", "자동 업데이트 확인") }, + Msg { key: "settings.update_channel", text: row("更新渠道", "更新管道", "Update channel", "更新チャネル", "업데이트 채널") }, + Msg { key: "channel.stable", text: row("稳定版", "穩定版", "Stable", "安定版", "안정판") }, + Msg { key: "settings.enable_remote", text: row("启用远程输入", "啟用遠端輸入", "Enable remote input", "リモート入力を有効化", "원격 입력 사용") }, + Msg { key: "settings.port", text: row("端口 ", "連接埠 ", "Port ", "ポート ", "포트 ") }, + // ---- Hotkey control labels + Msg { key: "hotkey.dictation", text: row("听写", "聽寫", "Dictation", "ディクテーション", "받아쓰기") }, + Msg { key: "hotkey.translation", text: row("翻译修饰键", "翻譯修飾鍵", "Translate modifier", "翻訳修飾キー", "번역 수정자") }, + Msg { key: "hotkey.selection_polish", text: row("选区润色", "選區潤飾", "Polish selection", "選択範囲の推敲", "선택 다듬기") }, + Msg { key: "hotkey.switch_style", text: row("切换风格", "切換風格", "Switch style", "スタイル切替", "스타일 전환") }, + Msg { key: "hotkey.open_app", text: row("打开应用", "開啟應用", "Open app", "アプリを開く", "앱 열기") }, + Msg { key: "hotkey.coding_agent", text: row("Coding Agent 语音", "Coding Agent 語音", "Coding Agent voice", "Coding Agent 音声", "Coding Agent 음성") }, + Msg { key: "hotkey.enable", text: row("启用{}", "啟用{}", "Enable {}", "{} を有効化", "{} 사용") }, + // ---- Remote input + Msg { key: "remote.running", text: row("远程输入:运行中", "遠端輸入:執行中", "Remote input: running", "リモート入力: 実行中", "원격 입력: 실행 중") }, + Msg { key: "remote.starting", text: row("远程输入:启动中", "遠端輸入:啟動中", "Remote input: starting", "リモート入力: 起動中", "원격 입력: 시작 중") }, + Msg { key: "remote.stopped", text: row("远程输入:已停止", "遠端輸入:已停止", "Remote input: stopped", "リモート入力: 停止中", "원격 입력: 중지됨") }, + Msg { key: "remote.lang_conns", text: row("语言:{} · 连接数:{}", "語言:{} · 連線數:{}", "Language: {} · Connections: {}", "言語: {} · 接続数: {}", "언어: {} · 연결 수: {}") }, + Msg { key: "lbl.status_colon", text: row("状态:{}", "狀態:{}", "Status: {}", "状態: {}", "상태: {}") }, + Msg { key: "lbl.search", text: row("搜索", "搜尋", "Search", "検索", "검색") }, + Msg { key: "lbl.name", text: row("名称", "名稱", "Name", "名前", "이름") }, + Msg { key: "lbl.version", text: row("版本", "版本", "Version", "バージョン", "버전") }, + Msg { key: "lbl.description", text: row("描述", "描述", "Description", "説明", "설명") }, + Msg { key: "lbl.base_mode", text: row("基础模式", "基礎模式", "Base mode", "基本モード", "기본 모드") }, + Msg { key: "lbl.phrase", text: row("词语", "詞語", "Phrase", "語句", "단어") }, + Msg { key: "lbl.note", text: row("备注", "備註", "Note", "メモ", "메모") }, + Msg { key: "lbl.dictation_prompt", text: row("听写 Prompt", "聽寫 Prompt", "Dictation prompt", "ディクテーションプロンプト", "받아쓰기 프롬프트") }, + Msg { key: "lbl.selection_prompt", text: row("选区 Prompt(留空则使用 Core 默认)", "選區 Prompt(留空則使用 Core 預設)", "Selection prompt (blank uses Core default)", "選択範囲プロンプト(空欄は Core 既定)", "선택 프롬프트(비우면 Core 기본값)") }, + Msg { key: "lbl.current", text: row("当前", "目前", "Current", "現在", "현재") }, + Msg { key: "lbl.primary", text: row("主键", "主鍵", "Primary key", "主キー", "주 키") }, + Msg { key: "lbl.modifiers", text: row("修饰键(+ 分隔)", "修飾鍵(+ 分隔)", "Modifiers (separate with +)", "修飾キー(+ で区切る)", "수정자(+로 구분)") }, + Msg { key: "lbl.device_code", text: row("设备码:{}", "裝置碼:{}", "Device code: {}", "デバイスコード: {}", "기기 코드: {}") }, + Msg { key: "lbl.liked", text: row("喜欢的风格:{}", "喜歡的風格:{}", "Liked style packs: {}", "いいねしたスタイルパック: {}", "좋아요한 스타일 팩: {}") }, + Msg { key: "lbl.like_dl", text: row("喜欢 {} · 下载 {} · {}", "喜歡 {} · 下載 {} · {}", "{} likes · {} downloads · {}", "いいね {} · DL {} · {}", "좋아요 {} · 다운로드 {} · {}") }, + Msg { key: "lbl.published", text: row("已发布:{}", "已發佈:{}", "Published: {}", "公開済み: {}", "배포됨: {}") }, + Msg { key: "lbl.hits", text: row("命中 {}", "命中 {}", "Hits: {}", "ヒット数: {}", "적중: {}") }, + Msg { key: "lbl.preset_count", text: row("{} 个词", "{} 個詞", "{} phrases", "{} 語句", "{}개 단어") }, + Msg { key: "lbl.preset_note", text: row("预设由 Core 合并内置版本、用户覆盖和自定义内容。", "預設由 Core 合併內建版本、使用者覆蓋與自訂內容。", "Presets merge Core built-ins, user overrides and custom entries.", "プリセットは Core の内蔵版・ユーザー上書き・カスタムを統合します。", "프리셋은 Core 내장, 사용자 덮어쓰기, 사용자 지정을 통합합니다.") }, + Msg { key: "lbl.new_custom_preset", text: row("新建自定义预设", "新增自訂預設", "New custom preset", "新規カスタムプリセット", "새 사용자 프리셋") }, + Msg { key: "lbl.new_style_default", text: row("新风格", "新風格", "New style", "新規スタイル", "새 스타일") }, + Msg { key: "hint.preset_phrases", text: row("每行或逗号分隔一个词", "每行或逗號分隔一個詞", "One phrase per line or comma-separated", "各行に1語句、またはカンマ区切り", "한 줄에 하나 또는 쉼표로 구분") }, + Msg { key: "lbl.author_version", text: row("作者:{} · 版本 {}", "作者:{} · 版本 {}", "By {} · v{}", "作者: {} · バージョン {}", "작성자: {} · 버전 {}") }, + Msg { key: "lbl.style_note", text: row("风格包数据直接来自 Core repository;运行时 Prompt 由 Core 组合。", "風格包資料直接來自 Core repository;執行期 Prompt 由 Core 組合。", "Style packs come directly from the Core repository; Core composes runtime prompts.", "スタイルパックは Core リポジトリ由来で、実行時プロンプトは Core が組み立てます。", "스타일 팩은 Core 저장소에서 오며 Core가 실행 프롬프트를 구성합니다.") }, + Msg { key: "lbl.direct_hotkey", text: row("风格包直达快捷键", "風格包直達快速鍵", "Direct style-pack shortcut", "スタイルパック直接ショートカット", "스타일 팩 직접 단축키") }, + Msg { key: "lbl.choose_style", text: row("选择风格包", "選擇風格包", "Select a style pack", "スタイルパックを選択", "스타일 팩 선택") }, + Msg { key: "btn.save_direct_hotkey", text: row("保存直达快捷键", "儲存直達快速鍵", "Save direct shortcut", "直接ショートカットを保存", "직접 단축키 저장") }, + Msg { key: "btn.remove_direct_hotkey", text: row("移除直达快捷键", "移除直達快速鍵", "Remove direct shortcut", "直接ショートカットを削除", "직접 단축키 제거") }, + Msg { key: "lbl.choose_provider", text: row("选择 Provider", "選擇 Provider", "Select a provider", "プロバイダーを選択", "프로바이더 선택") }, + Msg { key: "providers.credentials", text: row("凭据渠道", "憑證管道", "Credential channels", "資格情報チャネル", "자격 증명 채널") }, + Msg { key: "providers.core_note", text: row("Provider 类型、默认 Endpoint/Model 与鉴权要求均来自 Core descriptor。", "Provider 類型、預設 Endpoint/Model 與鑑權要求皆來自 Core descriptor。", "Provider type, default Endpoint/Model and auth requirements come from the Core descriptor.", "Provider 種別・既定 Endpoint/Model・認証要件は Core descriptor 由来です。", "Provider 유형, 기본 Endpoint/Model 및 인증 요구 사항은 Core descriptor에서 옵니다.") }, + Msg { key: "providers.empty", text: row("尚无渠道;先从上方 Core Provider 列表创建一个。", "尚無管道;請先從上方 Core Provider 清單建立一個。", "No channels yet — create one from the Core provider list above.", "チャネルがありません。上の Core Provider 一覧から作成してください。", "채널이 없습니다. 위 Core Provider 목록에서 생성하세요.") }, + Msg { key: "providers.loading_dir", text: row("正在读取 Core 渠道目录…", "正在讀取 Core 管道目錄…", "Reading the Core channel catalog…", "Core チャネル目録を読み込み中…", "Core 채널 목록을 읽는 중…") }, + Msg { key: "providers.reading_channel", text: row("正在读取 {} 渠道 {}…", "正在讀取 {} 管道 {}…", "Reading {} channel {}…", "{} チャネル {} を読み込み中…", "{} 채널 {} 읽는 중…") }, + Msg { key: "providers.editing", text: row("编辑渠道 {}", "編輯管道 {}", "Edit channel {}", "チャネル {} を編集", "채널 {} 편집") }, + Msg { key: "providers.auth_probe", text: row("鉴权:{} · 探针:{}", "鑑權:{} · 探測:{}", "Auth: {} · Probe: {}", "認証: {} · プローブ: {}", "인증: {} · 프로브: {}") }, + Msg { key: "providers.name", text: row("名称", "名稱", "Name", "名前", "이름") }, + Msg { key: "providers.model_list", text: row("模型列表(点击填入):", "模型清單(點擊填入):", "Models (click to fill):", "モデル一覧(クリックで入力)", "모델 목록(클릭하여 입력)") }, + Msg { key: "providers.no_cloud_note", text: row("此 Provider 不使用云凭据;模型由本地模型面板管理。", "此 Provider 不使用雲端憑證;模型由本機模型面板管理。", "This provider uses no cloud credentials; models are managed in Local Models.", "この Provider はクラウド資格情報を使いません。モデルはローカルモデルで管理します。", "이 프로바이더는 클라우드 자격 증명을 사용하지 않습니다. 모델은 로컬 모델에서 관리합니다.") }, + Msg { key: "providers.oauth_note", text: row("此 Provider 使用 OAuth;Linux egui 不读取或显示 OAuth token。", "此 Provider 使用 OAuth;Linux egui 不讀取或顯示 OAuth token。", "This provider uses OAuth; the Linux egui UI never reads or shows the OAuth token.", "この Provider は OAuth を使用します。Linux egui は OAuth トークンを読み取らず表示もしません。", "이 프로바이더는 OAuth를 사용합니다. Linux egui는 OAuth 토큰을 읽거나 표시하지 않습니다.") }, + Msg { key: "providers.api_key_hint", text: row("API Key(留空表示不修改)", "API Key(留空表示不修改)", "API Key (blank leaves unchanged)", "API Key(空欄なら変更しない)", "API Key(비우면 변경 안 함)") }, + Msg { key: "auth.none", text: row("无需 Secret", "無需 Secret", "No secret", "Secret 不要", "Secret 불필요") }, + Msg { key: "auth.api_key", text: row("API Key", "API Key", "API Key", "API キー", "API 키") }, + Msg { key: "auth.endpoint_model_optional", text: row("Endpoint + Model,API Key 可选", "Endpoint + Model,API Key 可選", "Endpoint + Model, optional API Key", "Endpoint + Model、API Key は任意", "Endpoint + Model, API Key 선택") }, + Msg { key: "auth.api_key_unless_custom", text: row("公共 Endpoint 需要 API Key;自建 Endpoint 可无 Key", "公共 Endpoint 需要 API Key;自建 Endpoint 可無 Key", "Public endpoints need an API Key; self-hosted may omit it", "公開 Endpoint は API Key が必要。自前 Endpoint は不要", "공개 엔드포인트는 API 키 필요, 자체 엔드포인트는 불필요") }, + Msg { key: "auth.volcengine", text: row("火山引擎凭据", "火山引擎憑證", "Volcengine credentials", "Volcengine 資格情報", "Volcengine 자격 증명") }, + Msg { key: "auth.xfyun", text: row("讯飞 AppID + API Key", "訊飛 AppID + API Key", "iFlytek AppID + API Key", "讯飛 AppID + API Key", "iFlytek AppID + API Key") }, + Msg { key: "auth.oauth", text: row("OAuth", "OAuth", "OAuth", "OAuth", "OAuth") }, + // ---- Empty / info labels + Msg { key: "history.empty", text: row("暂无历史记录", "暫無歷史紀錄", "No history yet", "履歴はありません", "기록이 없습니다") }, + Msg { key: "history.inserted", text: row("已插入", "已插入", "Inserted", "挿入済み", "삽입됨") }, + Msg { key: "history.copied_fallback", text: row("已复制", "已複製", "Copied", "コピー済み", "복사됨") }, + Msg { key: "history.paste_sent", text: row("已发送粘贴", "已傳送貼上", "Paste sent", "貼り付け送信", "붙여넣기 전송됨") }, + Msg { key: "history.failed", text: row("失败", "失敗", "Failed", "失敗", "실패") }, + Msg { key: "history.not_requested", text: row("未请求插入", "未請求插入", "Not requested", "挿入未要求", "삽입 요청 안 됨") }, + Msg { key: "models.loading_dir", text: row("正在加载模型目录…", "正在載入模型目錄…", "Loading the model catalog…", "モデル目録を読み込み中…", "모델 목록을 불러오는 중…") }, + Msg { key: "models.empty", text: row("模型目录未返回任何可用模型", "模型目錄未傳回任何可用模型", "No usable models were returned", "利用可能なモデルがありません", "사용 가능한 모델이 없습니다") }, + Msg { key: "models.installed", text: row("已安装", "已安裝", "Installed", "インストール済み", "설치됨") }, + Msg { key: "models.not_installed", text: row("未安装", "未安裝", "Not installed", "未インストール", "미설치") }, + Msg { key: "marketplace.not_loaded", text: row("尚未加载 Marketplace;点击“搜索/刷新”。", "尚未載入 Marketplace;點按「搜尋/重新整理」。", "Marketplace not loaded yet — use Search & refresh.", "Marketplace は未読込です。「検索/更新」を押してください。", "마켓플레이스가 아직 로드되지 않았습니다. 검색/새로고침을 누르세요.") }, + // ---- Status / toast messages + Msg { key: "status.done_chars", text: row("完成:{} 字", "完成:{} 字", "Done: {} chars", "完了:{} 文字", "완료: {} 글자") }, + Msg { key: "status.dictation_cancelled", text: row("听写已取消", "聽寫已取消", "Dictation cancelled", "ディクテーションをキャンセル", "받아쓰기 취소됨") }, + Msg { key: "status.auto_stopped", text: row("录音已自动结束", "錄音已自動結束", "Recording auto-stopped", "録音を自動終了", "녹음 자동 종료") }, + Msg { key: "status.less_compacted", text: row("Less Computer 已压缩上下文", "Less Computer 已壓縮上下文", "Less Computer compacted context", "Less Computer がコンテキストを圧縮", "Less Computer 컨텍스트 압축됨") }, + Msg { key: "status.less_waiting", text: row("Less Computer 等待审批", "Less Computer 等待審批", "Less Computer awaiting approval", "Less Computer が承認待ち", "Less Computer 승인 대기 중") }, + Msg { key: "status.less_tool", text: row("Less Computer 正在使用工具:{}", "Less Computer 正在使用工具:{}", "Less Computer is using a tool: {}", "Less Computer がツールを使用中: {}", "Less Computer 도구 사용 중: {}") }, + Msg { key: "status.less_running", text: row("Less Computer 正在运行", "Less Computer 正在執行", "Less Computer is running", "Less Computer 実行中", "Less Computer 실행 중") }, + Msg { key: "status.provider_models_loaded", text: row("已读取 {} 个模型", "已讀取 {} 個模型", "Loaded {} models", "{} 個のモデルを読み込み", "모델 {}개 로드됨") }, + Msg { key: "status.marketplace_loaded", text: row("Marketplace 已加载 {} 个风格包", "Marketplace 已載入 {} 個風格包", "Marketplace loaded {} style packs", "Marketplace が {} 個のスタイルパックを読込", "마켓플레이스 스타일 팩 {}개 로드됨") }, + Msg { key: "status.device_code", text: row("GitHub 设备码:{}", "GitHub 裝置碼:{}", "GitHub device code: {}", "GitHub デバイスコード: {}", "GitHub 기기 코드: {}") }, + Msg { key: "status.logged_in", text: row("Marketplace 已登录:{}", "Marketplace 已登入:{}", "Marketplace signed in as {}", "Marketplace に {} でログイン", "마켓플레이스에 {}로 로그인됨") }, + Msg { key: "status.logout_done", text: row("Marketplace 已退出登录", "Marketplace 已登出", "Marketplace signed out", "Marketplace をサインアウト", "마켓플레이스 로그아웃됨") }, + Msg { key: "status.oauth_pending", text: row("GitHub 授权仍在等待", "GitHub 授權仍在等待", "Waiting for GitHub authorization", "GitHub の認可を待機中", "GitHub 인가 대기 중") }, + Msg { key: "status.oauth_slowdown", text: row("GitHub 要求降低检查频率", "GitHub 要求降低檢查頻率", "GitHub asks you to slow down checks", "GitHub が確認頻度を下げるよう求めています", "GitHub가 확인 빈도를 낮추라고 요청함") }, + Msg { key: "status.detail_loaded", text: row("已加载风格详情:{}", "已載入風格詳情:{}", "Loaded style details: {}", "スタイル詳細を読込: {}", "스타일 상세 로드됨: {}") }, + Msg { key: "status.my_publish_likes", text: row("我的发布 {} 个,喜欢 {} 个", "我的發布 {} 個,喜歡 {} 個", "{} of my uploads · {} liked", "マイ投稿 {} 件・いいね {} 件", "내 업로드 {}개 · 좋아요 {}개") }, + Msg { key: "status.settings_saved", text: row("设置已保存", "設定已儲存", "Settings saved", "設定を保存しました", "설정 저장됨") }, + Msg { key: "status.remote_updated", text: row("远程输入状态已更新", "遠端輸入狀態已更新", "Remote input updated", "リモート入力を更新しました", "원격 입력 업데이트됨") }, + Msg { key: "status.preloaded", text: row("当前模型已预加载", "目前模型已預載", "Active model preloaded", "現在のモデルをプリロードしました", "현재 모델 미리 로드됨") }, + Msg { key: "status.model_released", text: row("模型已释放", "模型已釋放", "Model released", "モデルを解放しました", "모델 해제됨") }, + Msg { key: "status.cancel_prepare_ok", text: row("已请求取消模型准备", "已請求取消模型準備", "Cancellation requested", "準備のキャンセルを要求しました", "준비 취소 요청됨") }, + Msg { key: "status.activated", text: row("本地模型已激活并预加载", "本機模型已啟用並預載", "Local model activated and preloaded", "ローカルモデルをアクティブ化してプリロードしました", "로컬 모델 활성화 및 미리 로드됨") }, + Msg { key: "status.download_done", text: row("模型下载完成", "模型下載完成", "Model download finished", "モデルのダウンロードが完了", "모델 다운로드 완료") }, + Msg { key: "status.download_cancel_requested", text: row("已请求取消模型下载", "已請求取消模型下載", "Download cancellation requested", "ダウンロードのキャンセルを要求しました", "다운로드 취소 요청됨") }, + Msg { key: "status.download_cancelled", text: row("模型下载已取消", "模型下載已取消", "Model download cancelled", "モデルのダウンロードをキャンセル", "모델 다운로드 취소됨") }, + Msg { key: "status.prepare_done", text: row("模型验证完成:{}", "模型驗證完成:{}", "Model verification done: {}", "モデル検証が完了: {}", "모델 검증 완료: {}") }, + Msg { key: "status.test_done", text: row("模型测试完成:{}({} ms)", "模型測試完成:{}({} ms)", "Model test done: {} ({} ms)", "モデルテスト完了: {}({} ms)", "모델 테스트 완료: {}({}ms)") }, + Msg { key: "status.model_deleted", text: row("模型已删除", "模型已刪除", "Model deleted", "モデルを削除しました", "모델 삭제됨") }, + Msg { key: "status.channel_created", text: row("渠道已创建", "管道已建立", "Channel created", "チャネルを作成しました", "채널 생성됨") }, + Msg { key: "status.channel_active", text: row("active 渠道已更新", "active 管道已更新", "Active channel updated", "アクティブチャネルを更新しました", "활성 채널 업데이트됨") }, + Msg { key: "status.channel_enabled", text: row("渠道启用状态已更新", "管道啟用狀態已更新", "Channel enabled state updated", "チャネルの有効状態を更新しました", "채널 사용 상태 업데이트됨") }, + Msg { key: "status.channel_reordered", text: row("渠道顺序已更新", "管道順序已更新", "Channel order updated", "チャネルの順序を更新しました", "채널 순서 업데이트됨") }, + Msg { key: "status.channel_deleted", text: row("渠道已删除", "管道已刪除", "Channel deleted", "チャネルを削除しました", "채널 삭제됨") }, + Msg { key: "status.provider_type_updated", text: row("Provider 类型已更新", "Provider 類型已更新", "Provider type updated", "Provider 種別を更新しました", "Provider 유형 업데이트됨") }, + Msg { key: "status.channel_saved", text: row("渠道配置已保存", "管道設定已儲存", "Channel configuration saved", "チャネル設定を保存しました", "채널 설정 저장됨") }, + Msg { key: "status.secret_cleared", text: row("渠道 Secret 已清除", "管道 Secret 已清除", "Channel Secret cleared", "チャネルの Secret を消去しました", "채널 Secret 지워짐") }, + Msg { key: "status.provider_validated", text: row("Provider 验证通过({} ms)", "Provider 驗證通過({} ms)", "Provider validated ({} ms)", "Provider 検証成功({} ms)", "Provider 검증 통과({}ms)") }, + Msg { key: "status.export_log_done", text: row("错误日志已导出", "錯誤日誌已匯出", "Error log exported", "エラーログをエクスポートしました", "오류 로그 내보냄") }, + Msg { key: "status.hotkey_handled", text: row("已处理快捷键", "已處理快速鍵", "Hotkey handled", "ホットキーを処理しました", "단축키 처리됨") }, + Msg { key: "status.launch_handled", text: row("已处理启动请求", "已處理啟動請求", "Launch request handled", "起動要求を処理しました", "시작 요청 처리됨") }, + Msg { key: "status.request_restart", text: row("请手动重启 OpenLess", "請手動重新啟動 OpenLess", "Please restart OpenLess manually", "OpenLess を手動で再起動してください", "OpenLess를 수동으로 재시작하세요") }, + Msg { key: "status.tray_stopped", text: row("系统托盘已停止:{}", "系統托盤已停止:{}", "System tray stopped: {}", "システムトレイを停止: {}", "시스템 트레이 중지됨: {}") }, + Msg { key: "status.style_switched", text: row("已切换风格:{}", "已切換風格:{}", "Switched style: {}", "スタイルを切替: {}", "스타일 전환됨: {}") }, + Msg { key: "status.no_previous_style", text: row("没有可切换的上一风格", "沒有可切換的上一風格", "No previous style to switch to", "切替可能な前スタイルがありません", "전환할 이전 스타일이 없습니다") }, + Msg { key: "status.mic_selected", text: row("已选择麦克风:{}", "已選擇麥克風:{}", "Microphone selected: {}", "マイクを選択: {}", "마이크 선택됨: {}") }, + Msg { key: "status.preset_updated", text: row("词汇预设已更新", "詞彙預設已更新", "Vocabulary preset updated", "語彙プリセットを更新しました", "어휘 프리셋 업데이트됨") }, + Msg { key: "status.preset_gone", text: row("词汇预设已不存在", "詞彙預設已不存在", "Vocabulary preset no longer exists", "語彙プリセットはもうありません", "어휘 프리셋이 더 이상 없음") }, + Msg { key: "status.suggestion_handled", text: row("词汇建议已处理", "詞彙建議已處理", "Vocabulary suggestion handled", "語彙提案を処理しました", "어휘 제안 처리됨") }, + Msg { key: "status.vocab_saved", text: row("词汇已保存", "詞彙已儲存", "Vocabulary saved", "語彙を保存しました", "어휘 저장됨") }, + Msg { key: "status.vocab_updated", text: row("词汇已更新", "詞彙已更新", "Vocabulary updated", "語彙を更新しました", "어휘 업데이트됨") }, + Msg { key: "status.correction_saved", text: row("纠错规则已保存", "糾錯規則已儲存", "Correction rule saved", "修正ルールを保存しました", "교정 규칙 저장됨") }, + Msg { key: "status.correction_updated", text: row("纠错规则已更新", "糾錯規則已更新", "Correction rule updated", "修正ルールを更新しました", "교정 규칙 업데이트됨") }, + Msg { key: "status.style_hotkey_saved", text: row("风格包快捷键已更新", "風格包快速鍵已更新", "Style-pack shortcut updated", "スタイルパックのショートカットを更新しました", "스타일 팩 단축키 업데이트됨") }, + Msg { key: "status.style_imported", text: row("已导入风格包:{}", "已匯入風格包:{}", "Imported style pack: {}", "スタイルパックを読込: {}", "스타일 팩 가져옴: {}") }, + Msg { key: "status.style_saved", text: row("风格包已保存:{}", "風格包已儲存:{}", "Style pack saved: {}", "スタイルパックを保存: {}", "스타일 팩 저장됨: {}") }, + Msg { key: "status.style_updated", text: row("风格包已更新", "風格包已更新", "Style pack updated", "スタイルパックを更新しました", "스타일 팩 업데이트됨") }, + Msg { key: "status.style_preview", text: row("{}:单轮 {} 字,多轮 {} 字,热词 {} 个", "{}:單輪 {} 字,多輪 {} 字,熱詞 {} 個", "{}: {} chars/single · {} multi · {} hotwords", "{}: 単発 {} 文字・多発 {} 文字・ホットワード {} 個", "{}: 단일 {}자 · 다중 {}자 · 핫워드 {}개") }, + Msg { key: "status.marketplace_installed", text: row("已安装风格包:{}", "已安裝風格包:{}", "Installed style pack: {}", "スタイルパックをインストール: {}", "스타일 팩 설치됨: {}") }, + Msg { key: "status.marketplace_like", text: row("喜欢数:{}", "喜歡數:{}", "Likes: {}", "いいね数: {}", "좋아요 수: {}") }, + Msg { key: "status.marketplace_zip_saved", text: row("Marketplace ZIP 已保存", "Marketplace ZIP 已儲存", "Marketplace ZIP saved", "Marketplace ZIP を保存しました", "마켓플레이스 ZIP 저장됨") }, + Msg { key: "status.marketplace_published", text: row("发布状态:{} · {}", "發佈狀態:{} · {}", "Publish status: {} · {}", "公開状態: {} · {}", "배포 상태: {} · {}") }, + Msg { key: "status.marketplace_deleted", text: row("Marketplace 发布已删除", "Marketplace 發佈已刪除", "Marketplace release deleted", "Marketplace のリリースを削除しました", "마켓플레이스 배포 삭제됨") }, + Msg { key: "status.history_copied", text: row("历史文本已复制", "歷史文字已複製", "Text copied to clipboard", "クリップボードにコピーしました", "텍스트가 복사됨") }, + Msg { key: "status.copy_failed", text: row("复制失败:{}", "複製失敗:{}", "Copy failed: {}", "コピー失敗: {}", "복사 실패: {}") }, + Msg { key: "status.repolish_done", text: row("重新润色完成:{}", "重新潤飾完成:{}", "Repolish done: {}", "再推敲完了: {}", "다시 다듬기 완료: {}") }, + Msg { key: "status.history_deleted", text: row("历史记录已删除", "歷史紀錄已刪除", "History entry deleted", "履歴を削除しました", "기록 삭제됨") }, + Msg { key: "status.opened_player", text: row("已交给系统播放器", "已交給系統播放器", "Opened in the system player", "システムプレーヤーで開きました", "시스템 플레이어에서 열림") }, + Msg { key: "status.recording_exported", text: row("录音已导出:{}", "錄音已匯出:{}", "Recording exported: {}", "録音をエクスポート: {}", "녹음 내보냄: {}") }, + Msg { key: "status.retranscribed", text: row("重新转写完成:{}", "重新轉寫完成:{}", "Retranscription done: {}", "再文字起こし完了: {}", "다시 받아쓰기 완료: {}") }, + Msg { key: "status.dictation_phase", text: row("听写:{}", "聽寫:{}", "Dictation: {}", "ディクテーション: {}", "받아쓰기: {}") }, + Msg { key: "status.dictation_done", text: row("听写完成:{}", "聽寫完成:{}", "Dictation done: {}", "ディクテーション完了: {}", "받아쓰기 완료: {}") }, + Msg { key: "status.model_progress", text: row("模型 {}:{} {}/{}", "模型 {}:{} {}/{}", "Model {}: {} {}/{}", "モデル {}: {} {}/{}", "모델 {}: {} {}/{}") }, + Msg { key: "status.backlog_reset", text: row("事件积压 {} 条,已重置派生界面并重放可用事件", "事件積壓 {} 條,已重置衍生介面並重放可用事件", "{} events backlogged — reset derived UI and replayed available events", "{} 件のイベントが滞り、派生UIをリセットして再送しました", "이벤트 {}건 밀림 — 파생 UI 재설정 및 재생됨") }, + Msg { key: "status.backlog_replay", text: row("事件积压 {} 条,已从 Core 重放补齐", "事件積壓 {} 條,已從 Core 重放補齊", "{} events backlogged — replayed from Core", "{} 件のイベントが滞り、Core から再送しました", "이벤트 {}건 밀림 — Core에서 재생됨") }, + Msg { key: "status.dialog_cancelled", text: row("操作已取消", "操作已取消", "Operation cancelled", "操作をキャンセルしました", "작업 취소됨") }, + Msg { key: "status.voice_cancelled_ok", text: row("语音会话已取消", "語音工作階段已取消", "Voice session cancelled", "音声セッションをキャンセルしました", "음성 세션 취소됨") }, + Msg { key: "popup.ignore_no_session", text: row("已忽略没有活动会话的弹窗操作", "已忽略沒有活動工作階段的彈窗操作", "Ignored popup action without an active session", "アクティブなセッションのないポップアップ操作を無視しました", "활성 세션이 없는 팝업 동작 무시됨") }, + Msg { key: "popup.ignore_stale", text: row("已忽略迟到、重复或跨类型的弹窗操作", "已忽略遲到、重複或跨類型的彈窗操作", "Ignored late, duplicate or cross-kind popup action", "遅延・重複・異種のポップアップ操作を無視しました", "지연/중복/유형 오류 팝업 동작 무시됨") }, + Msg { key: "popup.ignore_late_qa", text: row("已忽略迟到的问答弹窗操作", "已忽略遲到的問答彈窗操作", "Ignored a late Q&A popup action", "遅れた Q&A ポップアップ操作を無視しました", "지연된 Q&A 팝업 동작 무시됨") }, + Msg { key: "popup.protocol_error", text: row("原生弹窗协议错误:{}", "原生彈窗協定錯誤:{}", "Native popup protocol error: {}", "ネイティブポップアップのプロトコルエラー: {}", "네이티브 팝업 프로토콜 오류: {}") }, + Msg { key: "popup.spawn_failed", text: row("原生弹窗启动失败:{}", "原生彈窗啟動失敗:{}", "Failed to start native popup: {}", "ネイティブポップアップの起動に失敗: {}", "네이티브 팝업 시작 실패: {}") }, + Msg { key: "popup.exited", text: row("原生弹窗异常退出:{}", "原生彈窗異常結束:{}", "Native popup exited unexpectedly: {}", "ネイティブポップアップが異常終了: {}", "네이티브 팝업 비정상 종료: {}") }, + Msg { key: "popup.start_failed", text: row("无法启动原生弹窗:{}", "無法啟動原生彈窗:{}", "Could not start the native popup: {}", "ネイティブポップアップを起動できません: {}", "네이티브 팝업을 시작할 수 없음: {}") }, + Msg { key: "popup.channel_rebuild", text: row("原生弹窗通道重建:{}", "原生彈窗通道重建:{}", "Rebuilt native popup channel: {}", "ネイティブポップアップのチャネルを再構築: {}", "네이티브 팝업 채널 재구축: {}") }, + Msg { key: "popup.recover_failed", text: row("原生弹窗恢复失败:{}", "原生彈窗恢復失敗:{}", "Native popup recovery failed: {}", "ネイティブポップアップの復元に失敗: {}", "네이티브 팝업 복구 실패: {}") }, + Msg { key: "popup.session_invalid", text: row("弹窗 session 无效:{}", "彈窗 session 無效:{}", "Invalid popup session: {}", "無効なポップアップセッション: {}", "잘못된 팝업 세션: {}") }, + Msg { key: "status.from_preset", text: row("预设:{}", "預設:{}", "Preset: {}", "プリセット: {}", "프리셋: {}") }, + Msg { key: "dialog.export_log_cancelled", text: row("日志导出已取消", "日誌匯出已取消", "Log export cancelled", "ログのエクスポートをキャンセル", "로그 내보내기 취소됨") }, + Msg { key: "dialog.style_import_cancelled", text: row("风格包导入已取消", "風格包匯入已取消", "Style-pack import cancelled", "スタイルパックのインポートをキャンセル", "스타일 팩 가져오기 취소됨") }, + Msg { key: "dialog.style_export_cancelled", text: row("风格包导出已取消", "風格包匯出已取消", "Style-pack export cancelled", "スタイルパックのエクスポートをキャンセル", "스타일 팩 내보내기 취소됨") }, + Msg { key: "dialog.recording_export_cancelled", text: row("录音导出已取消", "錄音匯出已取消", "Recording export cancelled", "録音のエクスポートをキャンセル", "녹음 내보내기 취소됨") }, + Msg { key: "dialog.marketplace_zip_cancelled", text: row("Marketplace 下载已取消", "Marketplace 下載已取消", "Marketplace download cancelled", "Marketplace のダウンロードをキャンセル", "마켓플레이스 다운로드 취소됨") }, + Msg { key: "status.history_cleared", text: row("历史已清空", "歷史已清空", "History cleared", "履歴をクリアしました", "기록이 지워졌습니다") }, + Msg { key: "status.remote_pin_reset", text: row("远程输入配对码已重置", "遠端輸入配對碼已重設", "Remote input pairing code reset", "リモート入力のペアリングコードを再発行しました", "원격 입력 페어링 코드 재설정됨") }, + Msg { key: "tray.show", text: row("显示 OpenLess", "顯示 OpenLess", "Show OpenLess", "OpenLess を表示", "OpenLess 표시") }, + Msg { key: "tray.previous_style", text: row("切换到上一风格", "切換到上一風格", "Switch to previous style", "前のスタイルに切り替え", "이전 스타일로 전환") }, + Msg { key: "tray.quit", text: row("退出", "結束", "Quit", "終了", "종료") }, +]; + +fn lang_index(lang: Lang) -> usize { + match lang { + Lang::ZhCn => 0, + Lang::ZhTw => 1, + Lang::En => 2, + Lang::Ja => 3, + Lang::Ko => 4, + } +} + +fn find_entry<'a>(entries: &'a [Msg], key: &str) -> Option<&'a Msg> { + entries.iter().find(|entry| entry.key == key) +} + +/// Translate a catalog key into the chosen language, falling back to the +/// `zh-CN` source of truth for any key whose requested locale is untranslated, +/// and finally to the bare key when the key is not present at all. +pub fn tr<'a, L: IntoLang>(entries: &'a [Msg], lang: L, key: &'a str) -> &'a str { + let lang = lang.into_lang(); + match find_entry(entries, key) { + Some(entry) => { + let value = entry.text[lang_index(lang)]; + if value.is_empty() && lang != Lang::ZhCn { + entry.text[0] + } else if value.is_empty() { + // zh-CN is the source of truth and should never be empty. + key + } else { + value + } + } + None => key, + } +} + +/// Translate and substitute `{}` (sequential) and `{n}` (positional) +/// placeholders. Reuses the same fallback rules as [`tr`]. +pub fn fmt(entries: &[Msg], lang: L, key: &str, args: &[&dyn Display]) -> String { + let template = tr(entries, lang, key); + let mut out = String::with_capacity(template.len()); + let mut rest = template; + let mut auto_index = 0usize; + while let Some(open) = rest.find('{') { + if let Some(relative_close) = rest[open + 1..].find('}') { + let close = open + 1 + relative_close; + let field = &rest[open + 1..close]; + let argument = if field.is_empty() { + let index = auto_index; + auto_index += 1; + index + } else if let Ok(index) = field.parse::() { + index + } else { + // Not a placeholder we understand (e.g. `{name}`): keep it. + out.push_str(&rest[..open]); + out.push('{'); + out.push_str(field); + out.push('}'); + rest = &rest[close + 1..]; + continue; + }; + out.push_str(&rest[..open]); + if let Some(value) = args.get(argument) { + out.push_str(&value.to_string()); + } + rest = &rest[close + 1..]; + continue; + } + // No closing brace: keep the `{` literally and make progress. + out.push_str(&rest[..open]); + out.push('{'); + rest = &rest[open + 1..]; + } + out.push_str(rest); + out +} + +/// Global lookup against [`CATALOG`]. +pub fn tr_catalog(lang: L, key: &'static str) -> &'static str { + tr(CATALOG, lang, key) +} + +/// Global formatted lookup against [`CATALOG`]. +pub fn fmt_catalog(lang: L, key: &str, args: &[&dyn Display]) -> String { + fmt(CATALOG, lang, key, args) +} + +/// Ergonomic conversion for the call sites that already hold a concrete +/// [`Lang`] or a [`LocalePref`]. Kept tiny so UI code stays readable. +pub trait IntoLang { + fn into_lang(self) -> Lang; +} + +impl IntoLang for Lang { + fn into_lang(self) -> Lang { + self + } +} + +impl IntoLang for LocalePref { + fn into_lang(self) -> Lang { + self.resolve() + } +} + +impl IntoLang for &LocalePref { + fn into_lang(self) -> Lang { + self.resolve() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const TEST_CATALOG: &[Msg] = &[ + Msg { + key: "k.full", + text: row("完整", "完整", "Full", "完全", "전체"), + }, + // `k.missing_en` deliberately leaves `en` empty to exercise fallback. + Msg { + key: "k.missing_en", + text: row("来源", "來源", "", "ソース", "출처"), + }, + ]; + + #[test] + fn fallback_uses_zh_cn_source_of_truth_when_locale_is_empty() { + assert_eq!(tr(TEST_CATALOG, Lang::En, "k.missing_en"), "来源"); + assert_eq!(tr(TEST_CATALOG, Lang::ZhCn, "k.missing_en"), "来源"); + // Fully translated keys return their own locale. + assert_eq!(tr(TEST_CATALOG, Lang::En, "k.full"), "Full"); + } + + #[test] + fn unknown_key_returns_the_key_itself() { + assert_eq!(tr(TEST_CATALOG, Lang::Ja, "k.unknown"), "k.unknown"); + } + + #[test] + fn zh_cn_source_of_truth_is_fully_populated_and_complete() { + for entry in CATALOG { + assert!( + !entry.text[0].is_empty(), + "zh-CN must never be empty for key {}", + entry.key + ); + } + } + + #[test] + fn every_catalog_key_is_translated_in_all_supported_locales() { + for entry in CATALOG { + for lang in LANGS { + assert!( + !entry.text[lang_index(lang)].is_empty(), + "{} is missing a translation for {}", + entry.key, + lang.tag() + ); + } + } + } + + #[test] + fn catalog_keys_are_unique() { + for (i, left) in CATALOG.iter().enumerate() { + for right in &CATALOG[i + 1..] { + assert_ne!(left.key, right.key); + } + } + } + + #[test] + fn system_locale_detection_maps_common_locale_envs() { + assert_eq!(Lang::parse("zh_CN.UTF-8"), Some(Lang::ZhCn)); + assert_eq!(Lang::parse("zh-Hant-TW"), Some(Lang::ZhTw)); + assert_eq!(Lang::parse("zh_TW"), Some(Lang::ZhTw)); + assert_eq!(Lang::parse("ja_JP.UTF-8"), Some(Lang::Ja)); + assert_eq!(Lang::parse("ko_KR"), Some(Lang::Ko)); + assert_eq!(Lang::parse("en_US"), Some(Lang::En)); + assert_eq!(Lang::parse("fr_FR"), None); + assert_eq!(Lang::parse(""), None); + } + + #[test] + fn system_preference_resolves_from_the_host_locale() { + for (variable, value, expected) in [ + ("LC_ALL", "zh_TW.UTF-8", Lang::ZhTw), + ("LC_MESSAGES", "ko_KR.UTF-8", Lang::Ko), + ("LANG", "ja_JP", Lang::Ja), + ] { + // LocalePref::System must route through env-based detection. + let prev = std::env::var(variable).ok(); + std::env::set_var(variable, value); + assert_eq!(LocalePref::System.resolve(), expected); + match prev { + Some(value) => std::env::set_var(variable, value), + None => std::env::remove_var(variable), + } + } + } + + #[test] + fn locale_preference_roundtrips_through_wire_tags() { + assert_eq!(LocalePref::System, LocalePref::from_tag("system")); + assert_eq!( + LocalePref::Lang(Lang::ZhTw), + LocalePref::from_tag(Lang::ZhTw.tag()) + ); + // Unknown tags degrade to System (follow OS), never to a wrong guess. + assert_eq!(LocalePref::from_tag("xx_YY"), LocalePref::System); + } + + #[test] + fn positional_placeholders_are_substituted_in_any_locale() { + assert_eq!( + fmt_catalog(Lang::ZhCn, "metric.near7", &[&3, &9]), + "近7天 3 段 · 近30天 9 段" + ); + assert_eq!( + fmt_catalog(Lang::En, "metric.near7", &[&3, &9]), + "3 in 7d · 9 in 30d" + ); + assert_eq!( + fmt_catalog(Lang::Ja, "metric.near7", &[&3, &9]), + "直近7日 3 件 · 30日 9 件" + ); + // Unknown keys fall back to the key text with no substitution. + assert_eq!(fmt_catalog(Lang::En, "k.unknown", &[&1]), "k.unknown"); + } +} diff --git a/openless-all/app/linux-egui/src/lib.rs b/openless-all/app/linux-egui/src/lib.rs index 228339b2e..c92acfa27 100644 --- a/openless-all/app/linux-egui/src/lib.rs +++ b/openless-all/app/linux-egui/src/lib.rs @@ -5,35 +5,62 @@ //! and semantic events between that backend and the UI. mod audio; +mod audio_cue; +mod audio_mute; mod backend; mod capabilities; mod coding_agent; mod credentials; +mod desktop; mod fcitx5; mod host_actions; mod hotkeys; +mod i18n; +mod logging; mod marketplace; +mod popup; mod qa; +mod recordings; mod remote_input; mod resources; mod runtime; mod selection; mod settings; mod single_instance; +mod tray; +mod ui_state; +mod updater; pub use audio::LinuxCpalRecorder; +pub use audio_cue::{play_cue_start, play_cue_stop, CueTone}; +pub use audio_mute::AudioMuteGuard; pub use backend::{LinuxBackendBuilder, LinuxBackendRuntime}; pub use capabilities::{LinuxCapabilitySnapshot, LinuxDesktopSession, LinuxPlatformApi}; pub use credentials::LinuxCredentialStore; +pub use desktop::{ + atomic_save, notify, open_external, open_local_file, validate_save_path, AutostartManager, + DesktopError, Notification, +}; pub use fcitx5::{ available as fcitx5_available, commit_text as fcitx5_commit_text, - ensure_plugin_installed as ensure_fcitx5_plugin_installed, + copy_to_clipboard as fcitx5_copy_to_clipboard, + ensure_plugin_installed as ensure_fcitx5_plugin_installed, reload_running_fcitx5, selection_text as fcitx5_selection_text, set_hotkeys as set_fcitx5_hotkeys, set_less_computer_hotkey_raw as set_fcitx5_less_computer_hotkey_raw, Fcitx5TextInserter, FcitxPluginInstallPlan, FcitxPluginStatus, }; pub use host_actions::LinuxHostActions; pub use hotkeys::{Fcitx5HotkeyListener, LinuxHotkeyEvent}; +pub use i18n::{fmt_catalog as fmt_l10n, tr_catalog as tr_l10n, Lang, LocalePref, LANGS}; +pub use logging::{export_error_log, init_file_logger, log_path}; +pub use popup::{ + read_jsonl, run_popup, write_jsonl, ApplyOutcome as PopupApplyOutcome, CapsulePopupState, + HostToPopup, PopupActionGuard, PopupChatMessage, PopupKind, PopupSendError, PopupState, + PopupSupervisor, PopupSupervisorEvent, PopupToHost, PreviewPopupState, + ProtocolError as PopupProtocolError, ProtocolErrorKind as PopupProtocolErrorKind, QaPopupState, + MAX_JSONL_LINE_BYTES, POPUP_PROTOCOL_VERSION, +}; +pub use recordings::{read_recording_wav, recording_path, recording_pcm, RecordingError}; pub use resources::{ LinuxPackageKind, LinuxResourceLayout, LinuxResourceResolver, FCITX_PLUGIN_CONFIG, FCITX_PLUGIN_LIBRARY, @@ -44,6 +71,16 @@ pub use settings::{LinuxSettingsEffects, LinuxSettingsRuntime}; pub use single_instance::{ LinuxLaunchIntent, SingleInstanceBroker, SingleInstanceGuard, SingleInstanceRole, }; +pub use tray::{LinuxTray, TrayCommand, TrayError, TrayMicrophone}; +pub use ui_state::{load_locale_pref, save_locale_pref, ui_state_dir, ui_state_path, UiStateError}; +pub use updater::{ + install_verified_appimage, install_verified_appimage_with_limit, manifest_urls, AppImageTarget, + AppImageUpdater, CheckReason, DownloadProgress, InstalledUpdate, LinuxUpdateSupport, + PinnedMinisignVerifier, SignatureVerifier, UnavailableSignatureVerifier, UpdateChannel, + UpdateError, UpdateManifest, UpdateSchedule, BETA_RELEASES_API, DEFAULT_MAX_APPIMAGE_BYTES, + DEFAULT_MAX_MANIFEST_BYTES, DIRECT_RELEASE_BASE, MANIFEST_HOST, MANIFEST_SCHEMA_VERSION, + PERIODIC_CHECK_INTERVAL, PINNED_MINISIGN_PUBLIC_KEY, RELEASES_URL, STARTUP_CHECK_DELAY, +}; pub use openless_core::contract::*; @@ -367,6 +404,35 @@ impl LinuxHost { } Ok(None) } + LinuxHotkeyEvent::SwitchStylePressed => { + self.backend.activate_previous_style_pack()?; + Ok(None) + } + LinuxHotkeyEvent::OpenAppPressed => { + self.backend.request_host_action(HostAction::ShowMain)?; + self.backend.request_host_action(HostAction::FocusMain)?; + Ok(None) + } + LinuxHotkeyEvent::StylePackPressed { symbol, states } => { + let preferences = self.backend.get_preferences(); + let pack_id = preferences + .style_pack_hotkeys + .iter() + .find_map(|hotkey| { + crate::settings::shortcut_to_raw(&hotkey.binding) + .ok() + .filter(|raw| *raw == (symbol, states)) + .map(|_| hotkey.pack_id.clone()) + }) + .ok_or_else(|| { + BackendError::new( + BackendErrorCode::Cancelled, + "style-pack hotkey no longer matches current settings", + ) + })?; + self.backend.activate_style_pack(&pack_id)?; + Ok(None) + } } } diff --git a/openless-all/app/linux-egui/src/logging.rs b/openless-all/app/linux-egui/src/logging.rs new file mode 100644 index 000000000..ed7418812 --- /dev/null +++ b/openless-all/app/linux-egui/src/logging.rs @@ -0,0 +1,83 @@ +use std::path::{Path, PathBuf}; + +const ROTATE_LIMIT_BYTES: u64 = 5 * 1024 * 1024; + +pub fn log_path(data_dir: &Path) -> PathBuf { + data_dir.join("logs").join("openless.log") +} + +pub fn init_file_logger(data_dir: &Path) -> Result { + use simplelog::{ + ColorChoice, CombinedLogger, ConfigBuilder, LevelFilter, TermLogger, TerminalMode, + WriteLogger, + }; + + let path = log_path(data_dir); + let parent = path.parent().ok_or("log path has no parent")?; + std::fs::create_dir_all(parent).map_err(|error| error.to_string())?; + rotate_if_needed(&path).map_err(|error| error.to_string())?; + let file = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(&path) + .map_err(|error| error.to_string())?; + let config = ConfigBuilder::new().set_time_format_rfc3339().build(); + CombinedLogger::init(vec![ + TermLogger::new( + LevelFilter::Info, + config.clone(), + TerminalMode::Mixed, + ColorChoice::Auto, + ), + WriteLogger::new(LevelFilter::Info, config, file), + ]) + .map_err(|error| error.to_string())?; + log::info!("Linux egui file logger ready: {}", path.display()); + Ok(path) +} + +fn rotate_if_needed(path: &Path) -> std::io::Result<()> { + let Ok(metadata) = std::fs::metadata(path) else { + return Ok(()); + }; + if metadata.len() <= ROTATE_LIMIT_BYTES { + return Ok(()); + } + let archive = path.with_file_name("openless.log.1"); + match std::fs::remove_file(&archive) { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(error), + } + std::fs::rename(path, archive) +} + +pub fn export_error_log(source: &Path, destination: &Path) -> Result<(), crate::DesktopError> { + let bytes = std::fs::read(source).map_err(|source| crate::DesktopError::Io { + operation: "read error log", + source, + })?; + crate::atomic_save(destination, &bytes).map(|_| ()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn exports_the_current_log_with_atomic_save() { + let root = std::env::temp_dir().join(format!( + "openless-linux-log-export-{}", + uuid::Uuid::new_v4().simple() + )); + let source = log_path(&root); + std::fs::create_dir_all(source.parent().unwrap()).unwrap(); + std::fs::write(&source, b"diagnostic\n").unwrap(); + let destination = root.join("exported.log"); + + export_error_log(&source, &destination).unwrap(); + + assert_eq!(std::fs::read(destination).unwrap(), b"diagnostic\n"); + std::fs::remove_dir_all(root).unwrap(); + } +} diff --git a/openless-all/app/linux-egui/src/main.rs b/openless-all/app/linux-egui/src/main.rs index 2aff6bae9..8de9b9b8b 100644 --- a/openless-all/app/linux-egui/src/main.rs +++ b/openless-all/app/linux-egui/src/main.rs @@ -1,37 +1,43 @@ -#[cfg(any(target_os = "linux", test))] -mod ui_state; - #[cfg(not(target_os = "linux"))] fn main() { eprintln!("openless-linux-egui is only available on Linux"); } +#[cfg(target_os = "linux")] +mod ui; + #[cfg(target_os = "linux")] mod linux_app { - use super::ui_state::{Navigation, Page}; use std::future::Future; use std::sync::mpsc; use std::sync::Arc; use std::time::Duration; + use crate::ui::frontend::{self, view_model::FrontendViewModel}; + use crate::ui::{shell, theme}; + use chrono::Datelike; use eframe::egui; use openless_core::{ BackendConfig, BackendError, BackendEvent, BackendEventKind, BackendSnapshot, - DictationPhase, HistoryInsertStatus, HostAction, LessComputerEventKind, LocalAsrModel, - LocalAsrRuntime, QaStateEvent, QaStateKind, SelectionPhase, SelectionSnapshot, - TranscriptAccumulator, UserPreferences, + DictationPhase, HistoryInsertStatus, HostAction, LessComputerEventKind, QaStateEvent, + QaStateKind, SelectionPhase, SelectionSnapshot, TranscriptAccumulator, UserPreferences, + }; + use openless_linux_egui::{ + drain_events, ensure_fcitx5_plugin_installed, fcitx5_copy_to_clipboard, notify, + open_external, reload_running_fcitx5, write_jsonl, EventDrainOutcome, Fcitx5HotkeyListener, + FcitxPluginInstallPlan, FcitxPluginStatus, HostToPopup, LinuxBackendBuilder, + LinuxCapabilitySnapshot, LinuxLaunchIntent, LinuxNativeRuntime, LinuxPackageKind, + LinuxResourceLayout, LinuxUpdateSupport, Notification, PopupActionGuard, PopupChatMessage, + PopupKind, PopupState, PopupSupervisor, PopupSupervisorEvent, PopupToHost, + SingleInstanceBroker, SingleInstanceRole, UpdateManifest, UpdateSchedule, + POPUP_PROTOCOL_VERSION, }; use openless_linux_egui::{ - drain_events, ensure_fcitx5_plugin_installed, EventDrainOutcome, Fcitx5HotkeyListener, - FcitxPluginInstallPlan, FcitxPluginStatus, LinuxBackendBuilder, LinuxCapabilitySnapshot, - LinuxDesktopSession, LinuxLaunchIntent, LinuxNativeRuntime, LinuxPackageKind, - LinuxResourceLayout, SingleInstanceBroker, SingleInstanceRole, + fmt_l10n, load_locale_pref, save_locale_pref, tr_l10n, Lang, LocalePref, LANGS, }; enum UiResult { - Environment(LinuxCapabilitySnapshot), Message(String), - Models(Result, String>), Remote(Result<(openless_core::RemoteInputStatus, String), String>), Providers(Result), ProviderEditor { @@ -45,13 +51,18 @@ mod linux_app { result: Result, String>, }, ProviderMutation(Result), - } - - #[derive(Clone)] - enum ModelsState { - Loading, - Loaded(Vec), - Failed(String), + Library(Result), + SettingsSaved(Box>), + Marketplace(Result, String>), + MarketplaceFlow(Result), + MarketplaceAuthPoll(Result), + MarketplaceDetail(Result), + MarketplaceMine(Result<(Vec, Vec), String>), + Microphones(Result, String>), + Overview(Result), + UpdateCheck(Result, String>), + UpdateProgress(openless_linux_egui::DownloadProgress), + UpdateInstalled(Result), } #[derive(Clone)] @@ -62,6 +73,165 @@ mod linux_app { active_provider: String, } + struct LibraryPanel { + vocabulary: Vec, + correction_rules: Vec, + style_packs: Vec, + vocab_preset_store: openless_core::VocabPresetStore, + vocab_presets: Vec, + } + + #[derive(Default, Clone, Copy)] + struct SettingsDirty { + streaming_insert: bool, + coding_agent_enabled: bool, + start_minimized: bool, + launch_at_login: bool, + auto_update_check: bool, + update_channel: bool, + remote_input_enabled: bool, + remote_input_port: bool, + recording: bool, + microphone: bool, + appearance: bool, + hotkeys: bool, + } + + impl SettingsDirty { + fn any(&self) -> bool { + self.streaming_insert + || self.coding_agent_enabled + || self.start_minimized + || self.launch_at_login + || self.auto_update_check + || self.update_channel + || self.remote_input_enabled + || self.remote_input_port + || self.recording + || self.microphone + || self.appearance + || self.hotkeys + } + + fn merge(&self, latest: &UserPreferences, draft: &UserPreferences) -> UserPreferences { + let mut merged = latest.clone(); + if self.streaming_insert { + merged.streaming_insert = draft.streaming_insert; + } + if self.coding_agent_enabled { + merged.coding_agent_enabled = draft.coding_agent_enabled; + } + if self.start_minimized { + merged.start_minimized = draft.start_minimized; + } + if self.launch_at_login { + merged.launch_at_login = draft.launch_at_login; + } + if self.auto_update_check { + merged.auto_update_check = draft.auto_update_check; + } + if self.update_channel { + merged.update_channel = draft.update_channel; + } + if self.remote_input_enabled { + merged.remote_input_enabled = draft.remote_input_enabled; + } + if self.remote_input_port { + merged.remote_input_port = draft.remote_input_port; + } + if self.recording { + merged.hotkey.mode = draft.hotkey.mode; + merged.silence_auto_stop_enabled = draft.silence_auto_stop_enabled; + merged.silence_auto_stop_seconds = draft.silence_auto_stop_seconds; + merged.mute_during_recording = draft.mute_during_recording; + merged.audio_cue_on_record = draft.audio_cue_on_record; + } + if self.microphone { + merged.microphone_device_name = draft.microphone_device_name.clone(); + } + if self.appearance { + merged.theme_mode = draft.theme_mode; + merged.show_overview_activity_heatmap = draft.show_overview_activity_heatmap; + } + if self.hotkeys { + merged.dictation_hotkey = draft.dictation_hotkey.clone(); + merged.hotkey = draft.hotkey.clone(); + merged.qa_hotkey = draft.qa_hotkey.clone(); + merged.translation_hotkey = draft.translation_hotkey.clone(); + merged.switch_style_hotkey = draft.switch_style_hotkey.clone(); + merged.open_app_hotkey = draft.open_app_hotkey.clone(); + merged.selection_polish_hotkey = draft.selection_polish_hotkey.clone(); + merged.coding_agent_voice_hotkey = draft.coding_agent_voice_hotkey.clone(); + } + merged + } + } + + // ---- Native Overview summary (Tauri parity) ----------------------------- + // + // The Tauri Overview derives its dashboard from three real Core sources: + // * `CredentialsStatus` -> active ASR/LLM provider and its configured state + // * `HistoryStore` -> today's metrics, total count and recent entries + // * `ActivityStore` -> trailing-window aggregates + daily heatmap + // Fetching happens off the egui frame in a tokio task (`load_overview`); the + // pure helpers below only shape already-loaded data and are unit tested + // without a runtime, a backend or any UI. + + /// Raw snapshot fetched asynchronously from Core for the Overview tab. + #[derive(Clone, Debug)] + struct OverviewData { + credentials: openless_core::CredentialsStatus, + history: Vec, + activity: Vec, + } + + #[derive(Clone, Debug, Default)] + struct RecentEntry { + created_at: String, + final_text: String, + duration_ms: Option, + } + + /// Activity aggregate over a trailing calendar window. Zero days that never + /// recorded activity are absent from the store, so a window may cover more + /// calendar days than `active_days`. + #[derive(Clone, Debug, Default, PartialEq, Eq)] + struct ActivityAggregate { + active_days: usize, + segments: u64, + chars: u64, + duration_ms: u64, + } + + /// Fully derived, display-ready Overview summary (computed purely, tested). + #[derive(Clone, Debug, Default)] + struct OverviewSummary { + asr_provider: String, + llm_provider: String, + asr_configured: bool, + llm_configured: bool, + chars_today: u64, + segments_today: usize, + duration_ms_today: u64, + avg_latency_ms: u64, + history_total: usize, + recent: Vec, + last_7: ActivityAggregate, + last_30: ActivityAggregate, + /// GitHub-style weekly columns (Sunday-first). Chronological oldest + /// first; a partial leading week keeps left-edge calendar alignment. + heatmap_weeks: Vec<[u32; 7]>, + heatmap_days: u32, + activity_days_total: usize, + } + + #[derive(Clone, Debug)] + enum OverviewState { + Loading, + Loaded(OverviewData), + Failed(String), + } + #[derive(Clone)] enum ProvidersState { Loading, @@ -69,6 +239,319 @@ mod linux_app { Failed(String), } + /// Trailing annual window rendered by the Overview heatmap. + const OVERVIEW_HEATMAP_DAYS: i64 = 364; + + impl OverviewState { + fn summary(&self, today: chrono::NaiveDate) -> Option { + match self { + OverviewState::Loaded(data) => Some(overview_summary(data, today)), + OverviewState::Loading | OverviewState::Failed(_) => None, + } + } + } + + /// RFC3339 history timestamp -> local calendar date. A value that cannot + /// be parsed simply yields `None` and contributes nothing to the summary. + fn history_local_date(created_at: &str) -> Option { + chrono::DateTime::parse_from_rfc3339(created_at) + .ok() + .map(|instant| instant.with_timezone(&chrono::Local).date_naive()) + } + + /// Sum one activity window's segments/chars/duration over `[today-days+1, today]`. + fn aggregate_window( + by_date: &std::collections::BTreeMap, + today: chrono::NaiveDate, + days: i64, + ) -> ActivityAggregate { + let start = today - chrono::Duration::days(days - 1); + let mut aggregate = ActivityAggregate::default(); + for (_, day) in by_date.range(start..=today) { + aggregate.active_days += 1; + aggregate.segments += u64::from(day.count); + aggregate.chars += day.chars; + aggregate.duration_ms += day.duration_ms; + } + aggregate + } + + /// Build a Sunday-first weekly heatmap grid over the trailing `days` window + /// (inclusive) ending at `today`. Columns are chronological weeks; the first + /// column may be partial so weekday edges align like a GitHub contribution + /// graph. Dates absent from the store render as an inactive (0) cell. + fn build_heatmap_weeks( + by_date: &std::collections::BTreeMap, + today: chrono::NaiveDate, + days: i64, + ) -> Vec<[u32; 7]> { + let mut weeks: Vec<[u32; 7]> = Vec::new(); + let mut date = today - chrono::Duration::days(days - 1); + while date <= today { + let weekday = date.weekday().num_days_from_sunday() as usize; + if weekday == 0 || weeks.is_empty() { + // A fresh column. Sunday starts a new week; a partial leading + // column is created on the first non-Sunday date instead. + weeks.push([0u32; 7]); + } + weeks + .last_mut() + .expect("a heatmap week always exists before writing a cell")[weekday] = + by_date.get(&date).map(|day| day.count).unwrap_or(0); + date += chrono::Duration::days(1); + } + weeks + } + + /// Shape the fetched Core snapshot into the display summary. Pure and free of + /// any runtime/IO so it can be exercised by focused unit tests. + fn overview_summary(data: &OverviewData, today: chrono::NaiveDate) -> OverviewSummary { + let mut segments_today = 0usize; + let mut chars_today = 0u64; + let mut duration_ms_today = 0u64; + for session in &data.history { + if history_local_date(&session.created_at) == Some(today) { + segments_today += 1; + chars_today += session.final_text.chars().count() as u64; + duration_ms_today += session.duration_ms.unwrap_or(0); + } + } + let avg_latency_ms = if segments_today > 0 { + duration_ms_today / segments_today as u64 + } else { + 0 + }; + + // Newest five entries. `created_at` is RFC3339 in a constant UTC offset, + // so lexicographic ordering is a valid chronological ordering. + let mut recent: Vec = data + .history + .iter() + .map(|session| RecentEntry { + created_at: session.created_at.clone(), + final_text: session.final_text.clone(), + duration_ms: session.duration_ms, + }) + .collect(); + recent.sort_by(|a, b| b.created_at.cmp(&a.created_at)); + recent.truncate(5); + + let mut by_date: std::collections::BTreeMap< + chrono::NaiveDate, + &openless_core::ActivityDay, + > = std::collections::BTreeMap::new(); + for day in &data.activity { + if let Ok(date) = chrono::NaiveDate::parse_from_str(&day.date, "%Y-%m-%d") { + by_date.insert(date, day); + } + } + + OverviewSummary { + asr_provider: data.credentials.active_asr_provider.clone(), + llm_provider: data.credentials.active_llm_provider.clone(), + asr_configured: data.credentials.asr_configured, + llm_configured: data.credentials.llm_configured, + chars_today, + segments_today, + duration_ms_today, + avg_latency_ms, + history_total: data.history.len(), + recent, + last_7: aggregate_window(&by_date, today, 7), + last_30: aggregate_window(&by_date, today, 30), + heatmap_weeks: build_heatmap_weeks(&by_date, today, OVERVIEW_HEATMAP_DAYS), + heatmap_days: OVERVIEW_HEATMAP_DAYS as u32, + activity_days_total: by_date.len(), + } + } + + fn format_duration(ms: u64, lang: Lang) -> String { + if ms < 1000 { + fmt_l10n(lang, "dur.ms", &[&ms]) + } else if ms < 60_000 { + fmt_l10n(lang, "dur.sec", &[&format!("{:.1}", ms as f64 / 1000.0)]) + } else { + let minutes = ms / 60_000; + let seconds = (ms % 60_000) / 1000; + fmt_l10n(lang, "dur.min_sec", &[&minutes, &seconds]) + } + } + + fn overview_provider_cards(ui: &mut egui::Ui, summary: &OverviewSummary, lang: Lang) { + ui.columns(2, |columns| { + overview_provider_card( + &mut columns[0], + tr_l10n(lang, "overview.provider_cards"), + &summary.asr_provider, + summary.asr_configured, + lang, + ); + overview_provider_card( + &mut columns[1], + tr_l10n(lang, "overview.provider_cards_llm"), + &summary.llm_provider, + summary.llm_configured, + lang, + ); + }); + } + + fn overview_provider_card( + ui: &mut egui::Ui, + kind: &str, + provider: &str, + configured: bool, + lang: Lang, + ) { + egui::Frame::group(ui.style()).show(ui, |ui| { + ui.set_min_width(140.0); + ui.label(egui::RichText::new(kind).weak()); + let name = if provider.is_empty() { + tr_l10n(lang, "overview.not_set").to_string() + } else { + provider.to_string() + }; + ui.label(egui::RichText::new(name).strong()); + if configured { + ui.colored_label( + egui::Color32::from_rgb(60, 160, 90), + tr_l10n(lang, "overview.configured_dot"), + ); + } else { + ui.label(tr_l10n(lang, "overview.unconfigured")); + } + }); + } + + fn overview_metric(ui: &mut egui::Ui, label: &str, value: String, trend: &str) { + ui.vertical(|ui| { + ui.set_min_width(120.0); + ui.label(egui::RichText::new(label).weak()); + ui.label(egui::RichText::new(value).strong().size(18.0)); + if !trend.is_empty() { + ui.label(egui::RichText::new(trend).small().weak()); + } + }); + } + + fn overview_metric_row(ui: &mut egui::Ui, summary: &OverviewSummary, lang: Lang) { + let latency_trend = if summary.segments_today > 0 { + "".to_string() + } else { + tr_l10n(lang, "metric.no_data_today").to_string() + }; + egui::Grid::new("overview_metric_row") + .num_columns(4) + .spacing([16.0, 8.0]) + .show(ui, |ui| { + overview_metric( + ui, + tr_l10n(lang, "metric.chars_today"), + summary.chars_today.to_string(), + &fmt_l10n(lang, "metric.total_segments", &[&summary.segments_today]), + ); + overview_metric( + ui, + tr_l10n(lang, "metric.duration_today"), + format_duration(summary.duration_ms_today, lang), + "", + ); + overview_metric( + ui, + tr_l10n(lang, "metric.avg_latency"), + format_duration(summary.avg_latency_ms, lang), + &latency_trend, + ); + overview_metric( + ui, + tr_l10n(lang, "metric.total"), + summary.history_total.to_string(), + &fmt_l10n( + lang, + "metric.near7", + &[&summary.last_7.segments, &summary.last_30.segments], + ), + ); + ui.end_row(); + }); + } + + fn overview_recent(ui: &mut egui::Ui, summary: &OverviewSummary, lang: Lang) { + ui.label(egui::RichText::new(tr_l10n(lang, "heading.recent")).strong()); + if summary.recent.is_empty() { + ui.label(tr_l10n(lang, "overview.recent_empty")); + return; + } + for entry in &summary.recent { + egui::Frame::group(ui.style()).show(ui, |ui| { + ui.label(format!( + "{} · {}", + entry.created_at, + format_duration(entry.duration_ms.unwrap_or(0), lang) + )); + let text = if entry.final_text.trim().is_empty() { + tr_l10n(lang, "overview.no_text").to_string() + } else { + entry.final_text.clone() + }; + ui.label(text); + }); + ui.add_space(4.0); + } + } + + fn heat_color(count: u32) -> egui::Color32 { + match count { + 0 => egui::Color32::from_gray(60), + 1..=2 => egui::Color32::from_rgb(80, 140, 220), + 3..=5 => egui::Color32::from_rgb(90, 120, 235), + 6..=10 => egui::Color32::from_rgb(110, 100, 235), + _ => egui::Color32::from_rgb(150, 90, 235), + } + } + + fn overview_heatmap(ui: &mut egui::Ui, summary: &OverviewSummary, lang: Lang) { + ui.label(egui::RichText::new(tr_l10n(lang, "overview.heatmap_title")).strong()); + let weeks = &summary.heatmap_weeks; + if weeks.is_empty() { + ui.label(tr_l10n(lang, "overview.heatmap_empty")); + return; + } + let cell = 10.0f32; + let gap = 2.0f32; + let width = gap + weeks.len() as f32 * (cell + gap); + let height = gap + 7.0f32 * (cell + gap); + let (rect, _) = ui.allocate_exact_size(egui::vec2(width, height), egui::Sense::hover()); + let painter = ui.painter(); + for (column, week) in weeks.iter().enumerate() { + for (row, count) in week.iter().enumerate() { + let min = egui::pos2( + rect.left() + gap + column as f32 * (cell + gap), + rect.top() + gap + row as f32 * (cell + gap), + ); + painter.rect_filled( + egui::Rect::from_min_size(min, egui::vec2(cell, cell)), + 2.0, + heat_color(*count), + ); + } + } + ui.horizontal(|ui| { + ui.label(tr_l10n(lang, "overview.heatmap_less")); + for count in [0u32, 1, 4, 8, 15] { + let (swatch, _) = + ui.allocate_exact_size(egui::vec2(10.0, 10.0), egui::Sense::hover()); + ui.painter().rect_filled(swatch, 2.0, heat_color(count)); + } + ui.label(tr_l10n(lang, "overview.heatmap_more")); + ui.label(fmt_l10n( + lang, + "overview.heatmap_footnote", + &[&summary.heatmap_days, &summary.activity_days_total], + )); + }); + } + #[derive(Clone)] struct ProviderEditor { kind: openless_core::ChannelKind, @@ -79,7 +562,6 @@ mod linux_app { model: String, auth_mode: String, resource_id: String, - app_id: String, // Secret inputs are intentionally write-only. Loading an editor never // exposes an existing key into egui state, logs or screenshots. primary_secret: String, @@ -98,21 +580,18 @@ mod linux_app { } pub struct OpenLessEguiApp { - navigation: Navigation, - environment: Option, - environment_refreshing: bool, - plugin_check: Option>, - less_computer_running: bool, - remote_error: Option, tokio: Arc, native: Option, subscription: Option, snapshot: Option, preferences: Option, - models: ModelsState, + settings_dirty: SettingsDirty, + overview: OverviewState, + microphones: Vec, transcript: String, transcript_state: TranscriptAccumulator, transcript_session: Option, + recording_phase_active: bool, last_event_sequence: u64, less_computer_input: String, less_computer_output: String, @@ -134,8 +613,46 @@ mod linux_app { new_provider_type: String, new_channel_name: String, pending_channel_delete: Option, + vocabulary: Vec, + correction_rules: Vec, + style_packs: Vec, + vocabulary_phrase: String, + vocabulary_note: String, + correction_pattern: String, + correction_replacement: String, + vocab_preset_store: openless_core::VocabPresetStore, + vocab_presets: Vec, + vocab_preset_name: String, + vocab_preset_phrases: String, + history_search: String, + qa_popup: Option, + preview_popup: Option, + capsule_popup: Option, + popup_action_guard: PopupActionGuard, + tray: Option, + exit_requested: bool, + update_support: LinuxUpdateSupport, + update_schedule: UpdateSchedule, + update_started: std::time::Instant, + update_manifest: Option, + update_busy: bool, + update_progress: Option, + marketplace_items: Vec, + marketplace_query: String, + marketplace_flow: Option, + marketplace_detail: Option, + marketplace_my_packs: Vec, + marketplace_my_likes: Vec, + style_editor: Option, + style_hotkey_pack_id: String, + style_hotkey_primary: String, + style_hotkey_modifiers: String, status: String, startup_error: Option, + locale_pref: LocalePref, + lang: Lang, + active_page: shell::Page, + frontend_vm: FrontendViewModel, tx: mpsc::Sender, rx: mpsc::Receiver, } @@ -144,8 +661,18 @@ mod linux_app { fn new( tokio: Arc, native: Result, + tray: Option, + update_support: LinuxUpdateSupport, ) -> Self { let (tx, rx) = mpsc::channel(); + let locale_pref = load_locale_pref(); + let lang = locale_pref.resolve(); + if let Some(tray) = tray.as_ref() { + // The tray renders labels in the resolved UI language. It runs + // in its own worker, so push the resolved language through the + // same control channel that updates microphone checkmarks. + let _ = tray.set_lang(lang); + } match native { Ok(native) => { let backend = native.host().backend(); @@ -153,21 +680,18 @@ mod linux_app { let preferences = backend.get_preferences(); let subscription = backend.subscribe(); let app = Self { - navigation: Navigation::default(), - environment: None, - environment_refreshing: false, - plugin_check: None, - less_computer_running: false, - remote_error: None, tokio, native: Some(native), subscription: Some(subscription), snapshot: Some(snapshot), preferences: Some(preferences), - models: ModelsState::Loading, + settings_dirty: SettingsDirty::default(), + overview: OverviewState::Loading, + microphones: Vec::new(), transcript: String::new(), transcript_state: TranscriptAccumulator::default(), transcript_session: None, + recording_phase_active: false, last_event_sequence: 0, less_computer_input: String::new(), less_computer_output: String::new(), @@ -189,32 +713,69 @@ mod linux_app { new_provider_type: String::new(), new_channel_name: String::new(), pending_channel_delete: None, - status: "Core 2.0 已启动".to_string(), + vocabulary: Vec::new(), + correction_rules: Vec::new(), + style_packs: Vec::new(), + vocabulary_phrase: String::new(), + vocabulary_note: String::new(), + correction_pattern: String::new(), + correction_replacement: String::new(), + vocab_preset_store: openless_core::VocabPresetStore::default(), + vocab_presets: Vec::new(), + vocab_preset_name: String::new(), + vocab_preset_phrases: String::new(), + history_search: String::new(), + qa_popup: None, + preview_popup: None, + capsule_popup: None, + popup_action_guard: PopupActionGuard::default(), + tray, + exit_requested: false, + update_support, + update_schedule: UpdateSchedule::new(Duration::ZERO), + update_started: std::time::Instant::now(), + update_manifest: None, + update_busy: false, + update_progress: None, + marketplace_items: Vec::new(), + marketplace_query: String::new(), + marketplace_flow: None, + marketplace_detail: None, + marketplace_my_packs: Vec::new(), + marketplace_my_likes: Vec::new(), + style_editor: None, + style_hotkey_pack_id: String::new(), + style_hotkey_primary: String::new(), + style_hotkey_modifiers: String::new(), + status: tr_l10n(lang, "status.core_started").to_string(), startup_error: None, + locale_pref, + lang, + active_page: shell::Page::Overview, + frontend_vm: FrontendViewModel::default(), tx, rx, }; - app.load_models(); app.load_remote_status(); app.load_providers(openless_core::ChannelKind::Asr); + app.load_library(); + app.load_microphones(); + app.load_overview(); app } Err(error) => Self { - navigation: Navigation::default(), - environment: None, - environment_refreshing: false, - plugin_check: None, - less_computer_running: false, - remote_error: None, tokio, native: None, subscription: None, snapshot: None, preferences: None, - models: ModelsState::Loading, + settings_dirty: SettingsDirty::default(), + overview: OverviewState::Loading, + microphones: Vec::new(), transcript: String::new(), transcript_state: TranscriptAccumulator::default(), transcript_session: None, + recording_phase_active: false, last_event_sequence: 0, less_computer_input: String::new(), less_computer_output: String::new(), @@ -236,8 +797,46 @@ mod linux_app { new_provider_type: String::new(), new_channel_name: String::new(), pending_channel_delete: None, - status: "启动失败".to_string(), + vocabulary: Vec::new(), + correction_rules: Vec::new(), + style_packs: Vec::new(), + vocabulary_phrase: String::new(), + vocabulary_note: String::new(), + correction_pattern: String::new(), + correction_replacement: String::new(), + vocab_preset_store: openless_core::VocabPresetStore::default(), + vocab_presets: Vec::new(), + vocab_preset_name: String::new(), + vocab_preset_phrases: String::new(), + history_search: String::new(), + qa_popup: None, + preview_popup: None, + capsule_popup: None, + popup_action_guard: PopupActionGuard::default(), + tray, + exit_requested: false, + update_support, + update_schedule: UpdateSchedule::new(Duration::ZERO), + update_started: std::time::Instant::now(), + update_manifest: None, + update_busy: false, + update_progress: None, + marketplace_items: Vec::new(), + marketplace_query: String::new(), + marketplace_flow: None, + marketplace_detail: None, + marketplace_my_packs: Vec::new(), + marketplace_my_likes: Vec::new(), + style_editor: None, + style_hotkey_pack_id: String::new(), + style_hotkey_primary: String::new(), + style_hotkey_modifiers: String::new(), + status: tr_l10n(lang, "status.startup_failed").to_string(), startup_error: Some(error), + locale_pref, + lang, + active_page: shell::Page::Overview, + frontend_vm: FrontendViewModel::default(), tx, rx, }, @@ -250,1847 +849,4762 @@ mod linux_app { .map(|native| Arc::clone(native.host().backend())) } - fn spawn(&self, future: F) - where - F: Future> + Send + 'static, - { - let tx = self.tx.clone(); - self.tokio.spawn(async move { - let message = future.await.unwrap_or_else(|error| error.to_string()); - let _ = tx.send(UiResult::Message(message)); - }); + fn popup_slot(&mut self, kind: PopupKind) -> &mut Option { + match kind { + PopupKind::Qa => &mut self.qa_popup, + PopupKind::Preview => &mut self.preview_popup, + PopupKind::Capsule => &mut self.capsule_popup, + } } - fn load_models(&self) { - let Some(backend) = self.backend() else { + fn ensure_popup(&mut self, kind: PopupKind) { + let lang = self.lang; + if self.popup_slot(kind).is_some() { return; - }; - let tx = self.tx.clone(); - self.tokio.spawn(async move { - let models = backend - .services() - .local_asr - .list_models(LocalAsrRuntime::Generic) - .await - .map_err(|error| error.to_string()); - let _ = tx.send(UiResult::Models(models)); - }); + } + match std::env::current_exe() { + Ok(executable) => { + self.popup_action_guard.reset(kind); + let supervisor = PopupSupervisor::spawn(self.tokio.handle(), executable, kind); + *self.popup_slot(kind) = Some(supervisor); + } + Err(error) => self.status = fmt_l10n(lang, "popup.start_failed", &[&error]), + } } - fn load_remote_status(&self) { - let Some(backend) = self.backend() else { - return; - }; - let tx = self.tx.clone(); - self.tokio.spawn(async move { - let result = async { - let status = backend.services().remote_input.status()?; - let pin = if status.enabled { - backend - .services() - .remote_input - .read_pairing_pin() - .await? - .into_exposed() - } else { - String::new() - }; - Ok::<_, BackendError>((status, pin)) + fn send_popup(&mut self, kind: PopupKind, message: HostToPopup) { + let lang = self.lang; + let retry = message.clone(); + if let Some(supervisor) = self.popup_slot(kind) { + if let Err(error) = supervisor.try_send(message) { + self.status = fmt_l10n(lang, "popup.channel_rebuild", &[&format!("{error:?}")]); + *self.popup_slot(kind) = None; + self.ensure_popup(kind); + if let Some(supervisor) = self.popup_slot(kind) { + if let Err(retry_error) = supervisor.try_send(retry) { + self.status = fmt_l10n( + lang, + "popup.recover_failed", + &[&format!("{retry_error:?}")], + ); + } + } } - .await - .map_err(|error| error.to_string()); - let _ = tx.send(UiResult::Remote(result)); - }); + } } - fn load_providers(&self, kind: openless_core::ChannelKind) { - let Some(backend) = self.backend() else { - return; - }; - let tx = self.tx.clone(); - self.tokio.spawn(async move { - let result = async { - let provider_kind = provider_kind(kind); - let mut channels = backend.list_channels(kind).await?; - channels.sort_by_key(|channel| channel.order); - Ok::<_, BackendError>(ProviderPanel { - kind, - descriptors: openless_core::provider_rules::provider_descriptors( - provider_kind, - ), - channels, - active_provider: backend.active_provider(provider_slot(kind)).await?, - }) - } - .await - .map_err(|error| error.to_string()); - let _ = tx.send(UiResult::Providers(result)); - }); + fn hide_popup(&mut self, kind: PopupKind, session_id: String, sequence: u64) { + self.send_popup( + kind, + HostToPopup::Hide { + version: POPUP_PROTOCOL_VERSION, + session_id, + sequence, + }, + ); } - fn load_provider_editor( - &self, - kind: openless_core::ChannelKind, - channel: openless_core::ChannelSummary, - descriptor: openless_core::ProviderDescriptor, - ) { - let Some(backend) = self.backend() else { + fn expected_popup_session(&self, kind: PopupKind) -> Option { + match kind { + PopupKind::Qa => self + .qa_state + .as_ref() + .map(|state| state.session_id.clone().unwrap_or_else(|| "qa".to_string())), + PopupKind::Preview => self + .selection + .as_ref() + .and_then(|selection| selection.session_id) + .map(|session_id| session_id.to_string()), + PopupKind::Capsule => self + .snapshot + .as_ref() + .and_then(|snapshot| snapshot.dictation.session_id) + .map(|session_id| session_id.to_string()), + } + } + + fn show_qa_popup(&mut self) { + self.ensure_popup(PopupKind::Qa); + let Some(state) = self.qa_state.clone() else { return; }; - let tx = self.tx.clone(); - let channel_id = channel.id.clone(); - self.tokio.spawn(async move { - let result = load_provider_editor(backend, kind, channel, descriptor) - .await - .map_err(|error| error.to_string()); - let _ = tx.send(UiResult::ProviderEditor { - kind, - channel_id, - result: Box::new(result), - }); - }); + self.send_popup( + PopupKind::Qa, + HostToPopup::QaSnapshot { + version: POPUP_PROTOCOL_VERSION, + session_id: state.session_id.unwrap_or_else(|| "qa".to_string()), + sequence: self.last_event_sequence.saturating_mul(2), + phase: format!("{:?}", state.kind), + messages: state + .messages + .unwrap_or_default() + .into_iter() + .map(|message| PopupChatMessage { + role: message.role, + content: message.content, + selection_text: message.selection_text, + }) + .collect(), + selection_preview: state.selection_preview, + streaming_answer: state.chunk.unwrap_or_default(), + error: state.error, + }, + ); } - fn spawn_provider_mutation(&self, future: F) - where - F: Future> + Send + 'static, - { - let tx = self.tx.clone(); - self.tokio.spawn(async move { - let _ = tx.send(UiResult::ProviderMutation( - future.await.map_err(|error| error.to_string()), - )); - }); + fn show_selection_popup(&mut self) { + self.ensure_popup(PopupKind::Preview); + let Some(selection) = self.selection.clone() else { + return; + }; + let Some(session_id) = selection.session_id else { + return; + }; + self.send_popup( + PopupKind::Preview, + HostToPopup::Preview { + version: POPUP_PROTOCOL_VERSION, + session_id: session_id.to_string(), + sequence: self.last_event_sequence.saturating_mul(2), + text: selection.preview_text.unwrap_or_default(), + source: selection.source_text.unwrap_or_default(), + }, + ); } - fn request_provider_models(&self, kind: openless_core::ChannelKind, channel_id: String) { - let Some(backend) = self.backend() else { + fn show_capsule_popup(&mut self) { + self.ensure_popup(PopupKind::Capsule); + let Some(snapshot) = self + .snapshot + .as_ref() + .map(|snapshot| snapshot.dictation.clone()) + else { return; }; - let tx = self.tx.clone(); - self.tokio.spawn(async move { - let result = backend - .services() - .provider - .list_models(openless_core::ProviderRequest { - thinking_enabled: backend.get_preferences().llm_thinking_enabled, - kind: provider_kind(kind), - channel_id: Some(channel_id.clone()), - }) - .await - .map(|models| models.models) - .map_err(|error| error.to_string()); - let _ = tx.send(UiResult::ProviderModels { - kind, - channel_id, - result, - }); - }); + let Some(session_id) = snapshot.session_id else { + return; + }; + self.send_popup( + PopupKind::Capsule, + HostToPopup::Capsule { + version: POPUP_PROTOCOL_VERSION, + session_id: session_id.to_string(), + sequence: self.last_event_sequence.saturating_mul(2), + phase: format!("{:?}", snapshot.phase), + text: snapshot.message.unwrap_or_default(), + audio_level: Some(snapshot.level), + }, + ); } - fn apply_event(&mut self, event: BackendEvent) { - if event.sequence <= self.last_event_sequence { - return; - } - self.last_event_sequence = event.sequence; - let session_id = event.session_id; - match event.kind { - BackendEventKind::DictationStateChanged(state) => { - self.navigation.notify(Page::Dictation); - if state.phase == DictationPhase::Starting { - self.transcript_state = TranscriptAccumulator::default(); - self.transcript.clear(); - self.transcript_session = state.session_id; - } - self.status = format!("听写:{:?}", state.phase); - } - BackendEventKind::TranscriptDelta(delta) - if session_id == self.transcript_session => - { - if self.transcript_state.apply(&delta).is_ok() { - self.transcript = self.transcript_state.text().to_string(); + fn poll_popup_supervisors(&mut self) { + let lang = self.lang; + let mut events = Vec::new(); + for kind in [PopupKind::Qa, PopupKind::Preview, PopupKind::Capsule] { + if let Some(supervisor) = self.popup_slot(kind) { + while let Ok(event) = supervisor.try_recv() { + events.push((kind, event)); } } - BackendEventKind::PolishDelta(delta) if delta.is_final => { - self.transcript = delta.text; - } - BackendEventKind::DictationCompleted(result) => { - self.navigation.notify(Page::Dictation); - self.transcript = result.polished_text; - self.status = format!("听写完成:{:?}", result.inserted); - } - BackendEventKind::RecordingControlRequested(request) => { - if let Some(backend) = self.backend() { - self.spawn(async move { - match request.action { - openless_core::RecordingControlAction::Stop => { - backend.stop_dictation_session(request.session_id).await?; - } - openless_core::RecordingControlAction::Cancel => { - backend.cancel_dictation(Some(request.session_id)).await?; - } - } - Ok("录音已自动结束".to_string()) - }); + } + for (kind, event) in events { + if let PopupSupervisorEvent::Message(message) = &event { + let Some(expected_session) = self.expected_popup_session(kind) else { + self.status = tr_l10n(lang, "popup.ignore_no_session").to_string(); + continue; + }; + if !self + .popup_action_guard + .accept(kind, message, &expected_session) + { + self.status = tr_l10n(lang, "popup.ignore_stale").to_string(); + continue; } } - BackendEventKind::LessComputerEvent(event) => { - // Voice capture has its own session, preceding a chat User - // turn. Keep a navigation notice without assigning it to - // the current chat or inventing microphone readiness. - if matches!(&event.kind, LessComputerEventKind::VoiceState { .. }) { - self.navigation.notify(Page::Agent); - return; - } - // Less Computer events may complete after a newer turn has - // already started. Session ownership, not arrival time, - // decides whether a delta/terminal may mutate this view. - if let LessComputerEventKind::User { text, fresh } = &event.kind { - // Every User starts a new turn UUID, including a - // continuation. `fresh` describes conversation history, - // never whether this turn is allowed to receive output. - self.less_computer_session = session_id; - self.less_computer_running = true; - self.pending_approval = None; - if *fresh { - self.less_computer_output.clear(); - } else if !self.less_computer_output.is_empty() { - self.less_computer_output.push_str("\n\n"); + match event { + PopupSupervisorEvent::Message(PopupToHost::SubmitQa { + session_id, + text, + .. + }) if self + .qa_state + .as_ref() + .and_then(|state| state.session_id.as_deref()) + == Some(session_id.as_str()) => + { + if let Some(backend) = self.backend() { + self.spawn(async move { + backend.services().qa.submit_text(text).await?; + Ok(tr_l10n(lang, "qa.submitted").to_string()) + }); } - self.less_computer_turn_start = self.less_computer_output.len(); - self.less_computer_input = text.clone(); - } else if session_id != self.less_computer_session { - return; } - self.navigation.notify(Page::Agent); - match event.kind { - // Linux已有独立录音显示;新typed反馈供接手Host/UI团队继续接入。 - LessComputerEventKind::VoiceState { .. } => {} - LessComputerEventKind::User { .. } => {} - LessComputerEventKind::Started => { - self.less_computer_running = true; - self.status = "Less Computer 正在运行".to_string(); - } - LessComputerEventKind::Delta { text } => { - self.less_computer_output.push_str(&text); + PopupSupervisorEvent::Message(PopupToHost::ToggleQaRecording { + session_id, + .. + }) if self + .qa_state + .as_ref() + .and_then(|state| state.session_id.as_deref()) + == Some(session_id.as_str()) => + { + if let Some(backend) = self.backend() { + self.spawn(async move { + backend.services().qa.toggle_recording().await?; + Ok(tr_l10n(lang, "qa.recording_updated").to_string()) + }); } - LessComputerEventKind::Tool { name } => { - self.status = format!("Less Computer 正在使用工具:{name}"); + } + PopupSupervisorEvent::Message(PopupToHost::DismissQa { + session_id, .. + }) if self + .qa_state + .as_ref() + .and_then(|state| state.session_id.as_deref()) + == Some(session_id.as_str()) => + { + if let Some(backend) = self.backend() { + self.spawn(async move { + backend.services().qa.dismiss().await?; + Ok(tr_l10n(lang, "qa.closed").to_string()) + }); } - LessComputerEventKind::Compaction => { - self.status = "Less Computer 已压缩上下文".to_string(); + } + PopupSupervisorEvent::Message(PopupToHost::ConfirmPreview { + session_id, + text, + .. + }) => match session_id.parse::() { + Ok(session_id) => { + let session_id = openless_core::SessionId::from_uuid(session_id); + if let Some(backend) = self.backend() { + self.spawn(async move { + backend + .services() + .selection + .confirm(session_id, Some(text)) + .await?; + Ok(tr_l10n(lang, "selection.replaced").to_string()) + }); + } } - LessComputerEventKind::Completed { text, .. } => { - self.less_computer_running = false; - // A terminal is authoritative even for final-only - // providers or after a missed partial event. - self.less_computer_output - .truncate(self.less_computer_turn_start); - self.less_computer_output.push_str(&text); - self.pending_approval = None; - self.status = "Less Computer 已完成".to_string(); + Err(error) => { + self.status = fmt_l10n(lang, "popup.session_invalid", &[&error]) } - LessComputerEventKind::Approval { token, command, .. } => { - self.pending_approval = Some((token, command)); - self.status = "Less Computer 等待审批".to_string(); + }, + PopupSupervisorEvent::Message(PopupToHost::CancelPreview { + session_id, + .. + }) => match session_id.parse::() { + Ok(session_id) => { + let session_id = openless_core::SessionId::from_uuid(session_id); + if let Some(backend) = self.backend() { + self.spawn(async move { + backend + .services() + .selection + .cancel(Some(session_id)) + .await?; + Ok(tr_l10n(lang, "selection.cancelled").to_string()) + }); + } } - LessComputerEventKind::Error { message } => { - self.less_computer_running = false; - self.pending_approval = None; - self.status = message; + Err(error) => { + self.status = fmt_l10n(lang, "popup.session_invalid", &[&error]) } - LessComputerEventKind::Cancelled => { - self.less_computer_running = false; - self.pending_approval = None; - self.status = "Less Computer 已取消".to_string(); + }, + PopupSupervisorEvent::Message(PopupToHost::Ready { .. }) => match kind { + PopupKind::Qa => self.show_qa_popup(), + PopupKind::Preview => self.show_selection_popup(), + PopupKind::Capsule => self.show_capsule_popup(), + }, + PopupSupervisorEvent::Message(PopupToHost::DismissCapsule { .. }) => { + if let Some(snapshot) = self.snapshot.as_mut() { + snapshot.dictation.message = None; } } - } - BackendEventKind::LocalAsrDownloadProgress(progress) => { - self.navigation.notify(Page::Models); - self.status = format!( - "模型 {}:{:?} {}/{}", - progress.model_id, - progress.phase, - progress.bytes_downloaded, - progress.bytes_total - ); - if matches!( - progress.phase, - openless_core::LocalAsrDownloadPhase::Finished - | openless_core::LocalAsrDownloadPhase::Failed - | openless_core::LocalAsrDownloadPhase::Cancelled - ) { - self.models = ModelsState::Loading; - self.load_models(); + PopupSupervisorEvent::Message( + PopupToHost::SubmitQa { .. } + | PopupToHost::ToggleQaRecording { .. } + | PopupToHost::DismissQa { .. }, + ) => { + self.status = tr_l10n(lang, "popup.ignore_late_qa").to_string(); } - } - BackendEventKind::PreferencesChanged(_) => { - if let Some(backend) = self.backend() { - self.preferences = Some(backend.get_preferences()); + PopupSupervisorEvent::ProtocolError(error) => { + self.status = fmt_l10n(lang, "popup.protocol_error", &[&error]); } - self.load_remote_status(); - } - BackendEventKind::QaState(state) => { - if state.kind == QaStateKind::AnswerDelta { - if let Some(current) = self - .qa_state - .as_mut() - .filter(|current| current.session_id == state.session_id) - { - self.navigation.notify(Page::Qa); - // Core deltas deliberately omit messages. Preserve - // the conversation and append only this turn's text; - // the following Answer replaces it with Core history. - current.kind = state.kind; - current - .chunk - .get_or_insert_with(String::new) - .push_str(state.chunk.as_deref().unwrap_or_default()); - } - } else if matches!( - state.kind, - QaStateKind::Idle - | QaStateKind::Loading - | QaStateKind::Thinking - | QaStateKind::Recording - ) || self - .qa_state - .as_ref() - .is_none_or(|current| current.session_id == state.session_id) - { - self.navigation.notify(Page::Qa); - self.qa_state = Some(state); + PopupSupervisorEvent::SpawnFailed(error) => { + self.status = fmt_l10n(lang, "popup.spawn_failed", &[&error]); + *self.popup_slot(kind) = None; } - } - BackendEventKind::SelectionStateChanged(snapshot) => { - self.navigation.notify(Page::Selection); - if snapshot.phase == SelectionPhase::Preview { - self.selection_draft = snapshot.preview_text.clone().unwrap_or_default(); - self.selection_preview_visible = true; + PopupSupervisorEvent::Exited { code, crashed } => { + if crashed { + self.status = fmt_l10n(lang, "popup.exited", &[&format!("{code:?}")]); + } + *self.popup_slot(kind) = None; + if crashed { + match kind { + PopupKind::Qa if self.qa_visible => self.show_qa_popup(), + PopupKind::Preview if self.selection_preview_visible => { + self.show_selection_popup(); + } + PopupKind::Capsule + if self.snapshot.as_ref().is_some_and(|snapshot| { + snapshot.dictation.phase != DictationPhase::Idle + }) => + { + self.show_capsule_popup(); + } + _ => {} + } + } } - self.selection = Some(snapshot); - } - BackendEventKind::RemoteInputStatusChanged(_) - | BackendEventKind::RemoteInputFailed(_) => { - self.navigation.notify(Page::Remote); - self.load_remote_status(); } - _ => {} } } - fn poll(&mut self, ctx: &egui::Context) { - if let Some(native) = &self.native { - let (launch_intents, hotkey_events, errors) = native.drain_native_events(); - let host = native.host_arc(); - for intent in launch_intents { - let host = Arc::clone(&host); - self.spawn(async move { - host.dispatch_launch_intent(intent).await?; - Ok("已处理启动请求".to_string()) - }); - } - for event in hotkey_events { - let host = Arc::clone(&host); - self.spawn(async move { - host.dispatch_hotkey_event(event).await?; - Ok("已处理快捷键".to_string()) - }); - } - if let Some(error) = errors.last() { - self.status = error.to_string(); + fn spawn(&self, future: F) + where + F: Future> + Send + 'static, + { + let tx = self.tx.clone(); + self.tokio.spawn(async move { + let message = future.await.unwrap_or_else(|error| error.to_string()); + let _ = tx.send(UiResult::Message(message)); + }); + } + + fn load_remote_status(&self) { + let Some(backend) = self.backend() else { + return; + }; + let tx = self.tx.clone(); + self.tokio.spawn(async move { + let result = async { + let status = backend.services().remote_input.status()?; + let pin = if status.enabled { + backend + .services() + .remote_input + .read_pairing_pin() + .await? + .into_exposed() + } else { + String::new() + }; + Ok::<_, BackendError>((status, pin)) } + .await + .map_err(|error| error.to_string()); + let _ = tx.send(UiResult::Remote(result)); + }); + } - let mut actions = Vec::new(); - native.host_actions().drain(|action| actions.push(action)); - // HostAction controls only native visibility/focus/effects. - // QA and Selection contents and terminal ownership always come - // back through sequenced Core events handled above. - for action in actions { - match action { - HostAction::ShowMain => { - ctx.send_viewport_cmd(egui::ViewportCommand::Visible(true)); - } - HostAction::ShowLessComputer => { - self.navigation.open(Page::Agent); - ctx.send_viewport_cmd(egui::ViewportCommand::Visible(true)); - } - HostAction::FocusMain => { - ctx.send_viewport_cmd(egui::ViewportCommand::Focus); - } - HostAction::Notify(message) => self.status = message, - HostAction::OpenExternalUrl(url) | HostAction::OpenSystemSettings(url) => { - std::thread::spawn(move || { - let _ = std::process::Command::new("xdg-open").arg(url).status(); + fn load_providers(&self, kind: openless_core::ChannelKind) { + let Some(backend) = self.backend() else { + return; + }; + let tx = self.tx.clone(); + self.tokio.spawn(async move { + let result = async { + let provider_kind = provider_kind(kind); + let mut channels = backend.list_channels(kind).await?; + channels.sort_by_key(|channel| channel.order); + Ok::<_, BackendError>(ProviderPanel { + kind, + descriptors: openless_core::provider_rules::provider_descriptors( + provider_kind, + ), + channels, + active_provider: backend.active_provider(provider_slot(kind)).await?, + }) + } + .await + .map_err(|error| error.to_string()); + let _ = tx.send(UiResult::Providers(result)); + }); + } + + fn load_library(&self) { + let Some(backend) = self.backend() else { + return; + }; + let tx = self.tx.clone(); + self.tokio.spawn(async move { + let result = (|| { + let preferences = backend.get_preferences(); + let vocab_preset_store = backend.list_vocabulary_presets()?; + let vocab_presets = openless_core::resolve_vocab_presets(&vocab_preset_store); + Ok::<_, BackendError>(LibraryPanel { + vocabulary: backend.list_vocabulary()?, + correction_rules: backend.list_correction_rules()?, + style_packs: backend.list_style_packs(&preferences.active_style_pack_id)?, + vocab_preset_store, + vocab_presets, + }) + })() + .map_err(|error| error.to_string()); + let _ = tx.send(UiResult::Library(result)); + }); + } + + fn load_marketplace(&self) { + let Some(backend) = self.backend() else { + return; + }; + let query = self.marketplace_query.trim().to_string(); + let tx = self.tx.clone(); + self.tokio.spawn(async move { + let result = backend + .services() + .marketplace + .list(openless_core::MarketplaceQuery { + query: (!query.is_empty()).then_some(query), + sort: Some("updated".to_string()), + limit: Some(100), + }) + .await + .map_err(|error| error.to_string()); + let _ = tx.send(UiResult::Marketplace(result)); + }); + } + + fn load_marketplace_mine(&self) { + let Some(backend) = self.backend() else { + return; + }; + let tx = self.tx.clone(); + self.tokio.spawn(async move { + let result = async { + let packs = backend.services().marketplace.my_packs().await?; + let likes = backend.services().marketplace.my_likes().await?; + Ok::<_, BackendError>((packs, likes)) + } + .await + .map_err(|error| error.to_string()); + let _ = tx.send(UiResult::MarketplaceMine(result)); + }); + } + + fn load_microphones(&self) { + let Some(backend) = self.backend() else { + return; + }; + let tx = self.tx.clone(); + self.tokio.spawn(async move { + let result = backend + .services() + .platform + .microphone_devices() + .await + .map_err(|error| error.to_string()); + let _ = tx.send(UiResult::Microphones(result)); + }); + } + + /// Load the real Core-backed Overview data off the egui frame. The only + /// blocking reads (`list_history`, `list_activity`) are pushed to a + /// blocking task so an egui frame never waits on disk/repository IO. + fn load_overview(&self) { + let Some(backend) = self.backend() else { + return; + }; + let tx = self.tx.clone(); + self.tokio.spawn(async move { + let result = async { + let credentials = backend.get_credentials_status().await?; + let history_backend = Arc::clone(&backend); + let activity_backend = Arc::clone(&backend); + let history = + tokio::task::spawn_blocking(move || history_backend.list_history()) + .await + .map_err(|error| { + BackendError::new( + openless_core::BackendErrorCode::Internal, + error.to_string(), + ) + })??; + let activity = + tokio::task::spawn_blocking(move || activity_backend.list_activity()) + .await + .map_err(|error| { + BackendError::new( + openless_core::BackendErrorCode::Internal, + error.to_string(), + ) + })??; + Ok::<_, BackendError>(OverviewData { + credentials, + history, + activity, + }) + } + .await + .map_err(|error| error.to_string()); + let _ = tx.send(UiResult::Overview(result)); + }); + } + + fn request_update_check(&mut self, channel: openless_core::shared_types::UpdateChannel) { + let lang = self.lang; + let LinuxUpdateSupport::AppImage(updater) = self.update_support.clone() else { + self.status = tr_l10n(lang, "update.system_managed").to_string(); + return; + }; + if self.update_busy { + return; + } + self.update_busy = true; + self.update_progress = None; + let tx = self.tx.clone(); + self.tokio.spawn(async move { + let result = updater + .check(channel) + .await + .map_err(|error| error.to_string()); + let _ = tx.send(UiResult::UpdateCheck(result)); + }); + } + + fn install_update(&mut self) { + let (LinuxUpdateSupport::AppImage(updater), Some(manifest)) = + (self.update_support.clone(), self.update_manifest.clone()) + else { + return; + }; + if self.update_busy { + return; + } + self.update_busy = true; + self.update_progress = Some(openless_linux_egui::DownloadProgress { + downloaded: 0, + content_length: None, + }); + let tx = self.tx.clone(); + self.tokio.spawn(async move { + let progress_tx = tx.clone(); + let result = updater + .download_and_install(manifest, move |progress| { + let _ = progress_tx.send(UiResult::UpdateProgress(progress)); + }) + .await + .map_err(|error| error.to_string()); + let _ = tx.send(UiResult::UpdateInstalled(result)); + }); + } + + fn drain_tray(&mut self, ctx: &egui::Context) { + let lang = self.lang; + let mut commands = Vec::new(); + if let Some(tray) = &self.tray { + tray.drain(|command| commands.push(command)); + if let Some(error) = tray.take_error() { + self.status = fmt_l10n(lang, "status.tray_stopped", &[&error]); + self.tray = None; + } + } + for command in commands { + match command { + openless_linux_egui::TrayCommand::ShowMain => { + ctx.send_viewport_cmd(egui::ViewportCommand::Visible(true)); + ctx.send_viewport_cmd(egui::ViewportCommand::Focus); + } + openless_linux_egui::TrayCommand::ActivatePreviousStyle => { + if let Some(backend) = self.backend() { + self.spawn(async move { + let pack = backend.activate_previous_style_pack()?; + Ok(match pack { + Some(pack) => { + fmt_l10n(lang, "status.style_switched", &[&pack.name]) + } + None => tr_l10n(lang, "status.no_previous_style").to_string(), + }) + }); + } + } + openless_linux_egui::TrayCommand::SelectMicrophone(name) => { + if let Some(backend) = self.backend() { + let selected = if name.is_empty() { + tr_l10n(lang, "settings.system_default").to_string() + } else { + name.clone() + }; + self.spawn(async move { + backend.select_microphone_device(name)?; + Ok(fmt_l10n(lang, "status.mic_selected", &[&selected])) + }); + } + } + openless_linux_egui::TrayCommand::Quit => { + self.exit_requested = true; + ctx.send_viewport_cmd(egui::ViewportCommand::Close); + } + } + } + } + + fn load_provider_editor( + &self, + kind: openless_core::ChannelKind, + channel: openless_core::ChannelSummary, + descriptor: openless_core::ProviderDescriptor, + ) { + let Some(backend) = self.backend() else { + return; + }; + let tx = self.tx.clone(); + let channel_id = channel.id.clone(); + self.tokio.spawn(async move { + let result = load_provider_editor(backend, kind, channel, descriptor) + .await + .map_err(|error| error.to_string()); + let _ = tx.send(UiResult::ProviderEditor { + kind, + channel_id, + result: Box::new(result), + }); + }); + } + + fn spawn_provider_mutation(&self, future: F) + where + F: Future> + Send + 'static, + { + let tx = self.tx.clone(); + self.tokio.spawn(async move { + let _ = tx.send(UiResult::ProviderMutation( + future.await.map_err(|error| error.to_string()), + )); + }); + } + + fn request_provider_models(&self, kind: openless_core::ChannelKind, channel_id: String) { + let Some(backend) = self.backend() else { + return; + }; + let tx = self.tx.clone(); + self.tokio.spawn(async move { + let result = backend + .services() + .provider + .list_models(openless_core::ProviderRequest { + kind: provider_kind(kind), + thinking_enabled: false, + channel_id: Some(channel_id.clone()), + }) + .await + .map(|models| models.models) + .map_err(|error| error.to_string()); + let _ = tx.send(UiResult::ProviderModels { + kind, + channel_id, + result, + }); + }); + } + + /// Play the native recording start/stop cue on a worker thread, gated by + /// the `audio_cue_on_record` preference. The start cue is additionally + /// suppressed while `mute_during_recording` is active: playing into a + /// deliberately muted sink is both inaudible and a needless PipeWire/ + /// KDE sink-input blip. The stop cue plays after output has been + /// restored. Absent preferences default to Core's defaults (cue on, + /// mute off). + fn play_record_cue(&self, at_start: bool) { + let enabled = self + .preferences + .as_ref() + .map(|prefs| prefs.audio_cue_on_record) + .unwrap_or(true); + if !enabled { + return; + } + if at_start + && self + .preferences + .as_ref() + .map(|prefs| prefs.mute_during_recording) + .unwrap_or(false) + { + return; + } + if at_start { + openless_linux_egui::play_cue_start(); + } else { + openless_linux_egui::play_cue_stop(); + } + } + + fn apply_event(&mut self, event: BackendEvent) { + let lang = self.lang; + if event.sequence <= self.last_event_sequence { + return; + } + let event_sequence = event.sequence; + self.last_event_sequence = event.sequence; + let session_id = event.session_id; + match event.kind { + BackendEventKind::DictationStateChanged(state) => { + // Native start/stop audio cues are a Linux host effect (no + // webview to synthesize them), gated by `audio_cue_on_record` + // and muted-aware. They must never block this frame, so the + // cue module plays on its own worker thread. + let was_recording = self.recording_phase_active; + self.recording_phase_active = state.phase == DictationPhase::Recording; + if state.phase == DictationPhase::Recording && !was_recording { + self.play_record_cue(true); + } else if !self.recording_phase_active && was_recording { + self.play_record_cue(false); + } + if state.phase == DictationPhase::Starting { + self.transcript_state = TranscriptAccumulator::default(); + self.transcript.clear(); + self.transcript_session = state.session_id; + } + self.status = fmt_l10n( + lang, + "status.dictation_phase", + &[&format!("{:?}", state.phase)], + ); + if let Some(session_id) = state.session_id { + self.send_popup( + PopupKind::Capsule, + HostToPopup::Capsule { + version: POPUP_PROTOCOL_VERSION, + session_id: session_id.to_string(), + sequence: event_sequence.saturating_mul(2), + phase: format!("{:?}", state.phase), + text: state.message.unwrap_or_default(), + audio_level: Some(state.level), + }, + ); + } + } + BackendEventKind::TranscriptDelta(delta) + if session_id == self.transcript_session + && self.transcript_state.apply(&delta).is_ok() => + { + self.transcript = self.transcript_state.text().to_string(); + } + BackendEventKind::PolishDelta(delta) if delta.is_final => { + self.transcript = delta.text; + } + BackendEventKind::DictationCompleted(result) => { + self.transcript = result.polished_text; + self.status = fmt_l10n( + lang, + "status.dictation_done", + &[&format!("{:?}", result.inserted)], + ); + } + BackendEventKind::RecordingControlRequested(request) => { + if let Some(backend) = self.backend() { + self.spawn(async move { + match request.action { + openless_core::RecordingControlAction::Stop => { + backend.stop_dictation_session(request.session_id).await?; + } + openless_core::RecordingControlAction::Cancel => { + backend.cancel_dictation(Some(request.session_id)).await?; + } + } + Ok(tr_l10n(lang, "status.auto_stopped").to_string()) + }); + } + } + BackendEventKind::LessComputerEvent(event) => { + // Less Computer events may complete after a newer turn has + // already started. Session ownership, not arrival time, + // decides whether a delta/terminal may mutate this view. + if let LessComputerEventKind::User { text, fresh } = &event.kind { + // Every User starts a new turn UUID, including a + // continuation. `fresh` describes conversation history, + // never whether this turn is allowed to receive output. + self.less_computer_session = session_id; + self.pending_approval = None; + if *fresh { + self.less_computer_output.clear(); + } else if !self.less_computer_output.is_empty() { + self.less_computer_output.push_str("\n\n"); + } + self.less_computer_turn_start = self.less_computer_output.len(); + self.less_computer_input = text.clone(); + } else if session_id != self.less_computer_session { + return; + } + match event.kind { + // Linux已有独立录音显示;新typed反馈供接手Host/UI团队继续接入。 + LessComputerEventKind::VoiceState { .. } => {} + LessComputerEventKind::User { .. } => {} + LessComputerEventKind::Started => { + self.status = tr_l10n(lang, "status.less_running").to_string(); + } + LessComputerEventKind::Delta { text } => { + self.less_computer_output.push_str(&text); + } + LessComputerEventKind::Tool { name } => { + self.status = fmt_l10n(lang, "status.less_tool", &[&name]); + } + LessComputerEventKind::Compaction => { + self.status = tr_l10n(lang, "status.less_compacted").to_string(); + } + LessComputerEventKind::Completed { text, .. } => { + // A terminal is authoritative even for final-only + // providers or after a missed partial event. + self.less_computer_output + .truncate(self.less_computer_turn_start); + self.less_computer_output.push_str(&text); + self.pending_approval = None; + self.status = tr_l10n(lang, "less_computer.done").to_string(); + } + LessComputerEventKind::Approval { token, command, .. } => { + self.pending_approval = Some((token, command)); + self.status = tr_l10n(lang, "status.less_waiting").to_string(); + } + LessComputerEventKind::Error { message } => { + self.pending_approval = None; + self.status = message; + } + LessComputerEventKind::Cancelled => { + self.pending_approval = None; + self.status = tr_l10n(lang, "less_computer.cancelled").to_string(); + } + } + } + BackendEventKind::PreferencesChanged(_) => { + if let Some(backend) = self.backend() { + let latest = backend.get_preferences(); + self.preferences = Some(match self.preferences.as_ref() { + Some(draft) if self.settings_dirty.any() => { + self.settings_dirty.merge(&latest, draft) + } + _ => latest, + }); + } + self.load_remote_status(); + self.load_library(); + } + BackendEventKind::VocabularyChanged(_) | BackendEventKind::StylePacksChanged(_) => { + self.load_library() + } + BackendEventKind::QaState(state) => { + if state.kind == QaStateKind::AnswerDelta { + if let Some(current) = self + .qa_state + .as_mut() + .filter(|current| current.session_id == state.session_id) + { + // Core deltas deliberately omit messages. Preserve + // the conversation and append only this turn's text; + // the following Answer replaces it with Core history. + current.kind = state.kind; + current + .chunk + .get_or_insert_with(String::new) + .push_str(state.chunk.as_deref().unwrap_or_default()); + } + } else if matches!( + state.kind, + QaStateKind::Idle + | QaStateKind::Loading + | QaStateKind::Thinking + | QaStateKind::Recording + ) || self + .qa_state + .as_ref() + .is_none_or(|current| current.session_id == state.session_id) + { + self.qa_state = Some(state); + } + if let Some(state) = self.qa_state.clone() { + let session_id = + state.session_id.clone().unwrap_or_else(|| "qa".to_string()); + self.send_popup( + PopupKind::Qa, + HostToPopup::QaSnapshot { + version: POPUP_PROTOCOL_VERSION, + session_id, + sequence: event_sequence.saturating_mul(2), + phase: format!("{:?}", state.kind), + messages: state + .messages + .unwrap_or_default() + .into_iter() + .map(|message| PopupChatMessage { + role: message.role, + content: message.content, + selection_text: message.selection_text, + }) + .collect(), + selection_preview: state.selection_preview, + streaming_answer: state.chunk.unwrap_or_default(), + error: state.error, + }, + ); + } + } + BackendEventKind::SelectionStateChanged(snapshot) => { + if snapshot.phase == SelectionPhase::Preview { + self.selection_draft = snapshot.preview_text.clone().unwrap_or_default(); + self.selection_preview_visible = true; + } + if let Some(session_id) = snapshot.session_id { + self.send_popup( + PopupKind::Preview, + HostToPopup::Preview { + version: POPUP_PROTOCOL_VERSION, + session_id: session_id.to_string(), + sequence: event_sequence.saturating_mul(2), + text: snapshot.preview_text.clone().unwrap_or_default(), + source: snapshot.source_text.clone().unwrap_or_default(), + }, + ); + } + self.selection = Some(snapshot); + } + BackendEventKind::RemoteInputStatusChanged(_) + | BackendEventKind::RemoteInputFailed(_) => self.load_remote_status(), + _ => {} + } + } + + fn poll(&mut self, ctx: &egui::Context) { + let lang = self.lang; + if let Some(native) = &self.native { + let (launch_intents, hotkey_events, errors) = native.drain_native_events(); + let host = native.host_arc(); + for intent in launch_intents { + let host = Arc::clone(&host); + self.spawn(async move { + host.dispatch_launch_intent(intent).await?; + Ok(tr_l10n(lang, "status.launch_handled").to_string()) + }); + } + for event in hotkey_events { + let host = Arc::clone(&host); + self.spawn(async move { + host.dispatch_hotkey_event(event).await?; + Ok(tr_l10n(lang, "status.hotkey_handled").to_string()) + }); + } + if let Some(error) = errors.last() { + self.status = error.to_string(); + } + + let mut actions = Vec::new(); + native.host_actions().drain(|action| actions.push(action)); + // HostAction controls only native visibility/focus/effects. + // QA and Selection contents and terminal ownership always come + // back through sequenced Core events handled above. + for action in actions { + match action { + HostAction::ShowMain | HostAction::ShowLessComputer => { + ctx.send_viewport_cmd(egui::ViewportCommand::Visible(true)); + } + HostAction::FocusMain => { + ctx.send_viewport_cmd(egui::ViewportCommand::Focus); + } + HostAction::Notify(message) => { + self.status = message.clone(); + std::thread::spawn(move || { + if let Err(error) = notify(Notification { + summary: "OpenLess", + body: &message, + icon: "openless", + timeout_ms: 0, + }) { + eprintln!("OpenLess desktop notification failed: {error}"); + } + }); + } + HostAction::OpenExternalUrl(url) | HostAction::OpenSystemSettings(url) => { + std::thread::spawn(move || { + if let Err(error) = open_external(&url) { + eprintln!("OpenLess external URL failed: {error}"); + } + }); + } + HostAction::RequestRestart => { + self.status = tr_l10n(lang, "status.request_restart").to_string(); + } + HostAction::ShowSelectionPreview => { + self.selection_preview_visible = true; + self.show_selection_popup(); + } + HostAction::HideSelectionPreview => { + self.selection_preview_visible = false; + let session_id = self + .selection + .as_ref() + .and_then(|selection| selection.session_id) + .map(|id| id.to_string()) + .unwrap_or_else(|| "selection".to_string()); + self.hide_popup( + PopupKind::Preview, + session_id, + self.last_event_sequence.saturating_mul(2).saturating_add(1), + ); + } + HostAction::ShowQa => { + self.qa_visible = true; + self.show_qa_popup(); + } + HostAction::HideQa => { + self.qa_visible = false; + let session_id = self + .qa_state + .as_ref() + .and_then(|state| state.session_id.clone()) + .unwrap_or_else(|| "qa".to_string()); + self.hide_popup( + PopupKind::Qa, + session_id, + self.last_event_sequence.saturating_mul(2).saturating_add(1), + ); + } + HostAction::ShowDictationFeedback => self.show_capsule_popup(), + HostAction::HideDictationFeedback => { + let session_id = self + .snapshot + .as_ref() + .and_then(|snapshot| snapshot.dictation.session_id) + .map(|id| id.to_string()) + .unwrap_or_else(|| "dictation".to_string()); + self.hide_popup( + PopupKind::Capsule, + session_id, + self.last_event_sequence.saturating_mul(2).saturating_add(1), + ); + } + } + } + } + self.poll_popup_supervisors(); + let mut events = Vec::new(); + let drain = self + .subscription + .as_mut() + .map(|subscription| drain_events(subscription, |event| events.push(event))); + for event in events { + self.apply_event(event); + } + if let Some(EventDrainOutcome::Lagged { dropped, .. }) = drain { + if let Some(backend) = self.backend() { + // Broadcast lag does not imply Core lost the events. Replay + // from the last applied sequence first; duplicate delivery + // from the live receiver is rejected by apply_event above. + let replay = backend.replay_events_after(self.last_event_sequence); + let snapshot = backend.snapshot(); + if replay.truncated { + // The bounded tail cannot reconstruct derived text/UI + // state. Reset it before applying the authoritative tail + // so no stale transcript, approval or preview survives. + self.transcript_state = TranscriptAccumulator::default(); + self.transcript.clear(); + self.transcript_session = snapshot.dictation.session_id; + self.less_computer_input.clear(); + self.less_computer_output.clear(); + self.less_computer_turn_start = 0; + self.less_computer_session = None; + self.pending_approval = None; + self.qa_state = None; + self.qa_visible = false; + self.selection = None; + self.selection_draft.clear(); + self.selection_preview_visible = false; + } + self.snapshot = Some(snapshot); + for event in replay.events { + self.apply_event(event); + } + self.status = if replay.truncated { + fmt_l10n(lang, "status.backlog_reset", &[&dropped]) + } else { + fmt_l10n(lang, "status.backlog_replay", &[&dropped]) + }; + } + } + while let Ok(result) = self.rx.try_recv() { + match result { + UiResult::Message(message) => self.status = message, + UiResult::Remote(Ok(remote)) => self.remote_access = Some(remote), + UiResult::Remote(Err(error)) => self.status = error, + UiResult::Providers(Ok(panel)) => { + if panel.kind != self.provider_kind { + continue; + } + if !panel.descriptors.iter().any(|descriptor| { + descriptor.provider_type.as_str() == self.new_provider_type + }) { + self.new_provider_type = panel + .descriptors + .first() + .map(|descriptor| descriptor.provider_type.as_str().to_string()) + .unwrap_or_default(); + } + let selected = self + .selected_channel_id + .as_ref() + .filter(|id| panel.channels.iter().any(|channel| &channel.id == *id)) + .cloned() + .or_else(|| { + panel + .channels + .iter() + .find(|channel| channel.id == panel.active_provider) + .map(|channel| channel.id.clone()) + }) + .or_else(|| panel.channels.first().map(|channel| channel.id.clone())); + self.selected_channel_id = selected.clone(); + if self.pending_channel_delete.as_ref().is_some_and(|id| { + !panel.channels.iter().any(|channel| &channel.id == id) + }) { + self.pending_channel_delete = None; + } + self.providers = ProvidersState::Loaded(panel.clone()); + self.provider_models.clear(); + if let Some(channel_id) = selected { + if let Some((channel, descriptor)) = + provider_channel_descriptor(&panel, &channel_id) + { + self.provider_editor = ProviderEditorState::Loading { + kind: panel.kind, + channel_id, + }; + self.load_provider_editor(panel.kind, channel, descriptor); + } + } else { + self.provider_editor = ProviderEditorState::Idle; + } + } + UiResult::Providers(Err(error)) => { + self.providers = ProvidersState::Failed(error.clone()); + self.status = error; + } + UiResult::ProviderEditor { + kind, + channel_id, + result, + } => { + if kind != self.provider_kind + || self.selected_channel_id.as_deref() != Some(channel_id.as_str()) + { + continue; + } + match *result { + Ok(editor) => { + // Reads race with channel switching and mutation + // refreshes. Only the still-selected channel may install + // its editor, otherwise late credential data is ignored. + self.provider_editor = + ProviderEditorState::Loaded(Box::new(editor)); + } + Err(error) => { + self.provider_editor = ProviderEditorState::Failed(error.clone()); + self.status = error; + } + } + } + UiResult::ProviderModels { + kind, + channel_id, + result, + } => { + if kind == self.provider_kind + && self.selected_channel_id.as_deref() == Some(channel_id.as_str()) + { + match result { + Ok(models) => { + self.status = fmt_l10n( + lang, + "status.provider_models_loaded", + &[&models.len()], + ); + self.provider_models = models; + } + Err(error) => self.status = error, + } + } + } + UiResult::ProviderMutation(result) => { + match result { + Ok(message) => self.status = message, + Err(error) => self.status = error, + } + self.providers = ProvidersState::Loading; + self.provider_editor = ProviderEditorState::Idle; + self.provider_models.clear(); + self.load_providers(self.provider_kind); + } + UiResult::Library(Ok(library)) => { + self.vocabulary = library.vocabulary; + self.correction_rules = library.correction_rules; + self.style_packs = library.style_packs; + self.vocab_preset_store = library.vocab_preset_store; + self.vocab_presets = library.vocab_presets; + } + UiResult::Library(Err(error)) => self.status = error, + UiResult::SettingsSaved(result) => match *result { + Ok(outcome) => { + self.preferences = Some(outcome.preferences.clone()); + if let Some(native) = &self.native { + self.snapshot = Some(native.host().snapshot()); + } + self.settings_dirty = SettingsDirty::default(); + self.status = tr_l10n(lang, "status.settings_saved").to_string(); + // Appearance (e.g. the Overview heatmap toggle) and any + // provider/credential edits may change Overview state. + self.load_overview(); + if let Some(backend) = self.backend() { + let config = openless_core::RemoteInputConfig { + enabled: outcome.preferences.remote_input_enabled, + port: outcome.preferences.remote_input_port, + }; + self.spawn(async move { + backend.services().remote_input.configure(config).await?; + Ok(tr_l10n(lang, "status.remote_updated").to_string()) + }); + } + } + Err(error) => self.status = error, + }, + UiResult::Marketplace(Ok(items)) => { + self.status = fmt_l10n(lang, "status.marketplace_loaded", &[&items.len()]); + self.marketplace_items = items; + } + UiResult::Marketplace(Err(error)) => self.status = error, + UiResult::MarketplaceFlow(Ok(flow)) => { + self.status = fmt_l10n(lang, "status.device_code", &[&flow.user_code]); + self.marketplace_flow = Some(flow); + } + UiResult::MarketplaceFlow(Err(error)) => self.status = error, + UiResult::MarketplaceAuthPoll(Ok(result)) => match result { + openless_core::OAuthPollResult::Authorized { login } => { + self.marketplace_flow = None; + self.status = fmt_l10n(lang, "status.logged_in", &[&login]); + } + openless_core::OAuthPollResult::Pending => { + self.status = tr_l10n(lang, "status.oauth_pending").to_string(); + } + openless_core::OAuthPollResult::SlowDown => { + self.status = tr_l10n(lang, "status.oauth_slowdown").to_string(); + } + openless_core::OAuthPollResult::Error { message } => { + self.status = message; + } + }, + UiResult::MarketplaceAuthPoll(Err(error)) => self.status = error, + UiResult::MarketplaceDetail(Ok(detail)) => { + self.status = + fmt_l10n(lang, "status.detail_loaded", &[&detail.summary.name]); + self.marketplace_detail = Some(detail); + } + UiResult::MarketplaceDetail(Err(error)) => self.status = error, + UiResult::MarketplaceMine(Ok((packs, likes))) => { + self.status = fmt_l10n( + lang, + "status.my_publish_likes", + &[&packs.len(), &likes.len()], + ); + self.marketplace_my_packs = packs; + self.marketplace_my_likes = likes; + } + UiResult::MarketplaceMine(Err(error)) => self.status = error, + UiResult::Microphones(Ok(devices)) => { + self.microphones = devices.clone(); + let selected = self + .preferences + .as_ref() + .map(|prefs| prefs.microphone_device_name.as_str()) + .unwrap_or_default(); + if let Some(tray) = &self.tray { + let microphones = devices + .into_iter() + .map(|device| openless_linux_egui::TrayMicrophone { + selected: !selected.is_empty() + && (selected == device.id || selected == device.name), + name: device.name, + is_default: device.is_default, + }) + .collect(); + if let Err(error) = tray.set_microphones(microphones) { + self.status = error.to_string(); + } + } + } + UiResult::Microphones(Err(error)) => self.status = error, + UiResult::Overview(Ok(data)) => self.overview = OverviewState::Loaded(data), + UiResult::Overview(Err(error)) => { + self.status = error.clone(); + self.overview = OverviewState::Failed(error); + } + UiResult::UpdateCheck(Ok(Some(manifest))) => { + self.update_busy = false; + self.status = fmt_l10n(lang, "update.discovered", &[&manifest.version]); + self.update_manifest = Some(manifest); + } + UiResult::UpdateCheck(Ok(None)) => { + self.update_busy = false; + self.status = tr_l10n(lang, "update.up_to_date").to_string(); + } + UiResult::UpdateCheck(Err(error)) => { + self.update_busy = false; + self.status = fmt_l10n(lang, "update.check_failed", &[&error]); + } + UiResult::UpdateProgress(progress) => self.update_progress = Some(progress), + UiResult::UpdateInstalled(Ok(installed)) => { + self.update_busy = false; + self.update_manifest = None; + self.status = + fmt_l10n(lang, "update.installed_restart", &[&installed.version]); + } + UiResult::UpdateInstalled(Err(error)) => { + self.update_busy = false; + self.status = fmt_l10n(lang, "update.install_failed", &[&error]); + } + } + } + if let Some(backend) = self.backend() { + self.snapshot = Some(backend.snapshot()); + } + } + + fn overview_summary_ui(&mut self, ui: &mut egui::Ui) { + let lang = self.lang; + let mut reload = false; + ui.horizontal(|ui| { + ui.heading(tr_l10n(lang, "heading.overview")); + ui.add_space(8.0); + if ui.button(tr_l10n(lang, "btn.refresh")).clicked() { + reload = true; + } + }); + if reload { + self.overview = OverviewState::Loading; + self.load_overview(); + return; + } + match &self.overview { + OverviewState::Loading => { + ui.horizontal(|ui| { + ui.spinner(); + ui.label(tr_l10n(lang, "loading.overview")); + }); + } + OverviewState::Failed(error) => { + ui.colored_label( + egui::Color32::from_rgb(220, 80, 80), + format!("{}: {error}", tr_l10n(lang, "overview.load_failed")), + ); + if ui.button(tr_l10n(lang, "btn.retry")).clicked() { + reload = true; + } + } + OverviewState::Loaded(_) => { + let show_heatmap = self + .preferences + .as_ref() + .map(|preferences| preferences.show_overview_activity_heatmap) + .unwrap_or(true); + let today = chrono::Local::now().date_naive(); + if let Some(summary) = self.overview.summary(today) { + overview_provider_cards(ui, &summary, lang); + ui.add_space(6.0); + overview_metric_row(ui, &summary, lang); + ui.add_space(6.0); + overview_recent(ui, &summary, lang); + if show_heatmap && summary.activity_days_total > 0 { + ui.add_space(6.0); + overview_heatmap(ui, &summary, lang); + } + } + } + } + if reload { + self.overview = OverviewState::Loading; + self.load_overview(); + } + } + + fn dictation_ui(&mut self, ui: &mut egui::Ui) { + let lang = self.lang; + ui.heading(tr_l10n(lang, "heading.dictation")); + let phase = self + .snapshot + .as_ref() + .map(|snapshot| snapshot.dictation.phase) + .unwrap_or(DictationPhase::Idle); + ui.horizontal(|ui| { + if ui + .add_enabled( + phase == DictationPhase::Idle, + egui::Button::new(tr_l10n(lang, "btn.start")), + ) + .clicked() + { + if let Some(backend) = self.backend() { + self.transcript.clear(); + self.spawn(async move { + backend.start_dictation().await?; + Ok(tr_l10n(lang, "dictation.recording").to_string()) + }); + } + } + if ui + .add_enabled( + phase == DictationPhase::Recording, + egui::Button::new(tr_l10n(lang, "btn.stop")), + ) + .clicked() + { + if let Some(backend) = self.backend() { + self.spawn(async move { + let result = backend.stop_dictation().await?; + Ok(fmt_l10n( + lang, + "status.done_chars", + &[&result.polished_text.chars().count()], + )) + }); + } + } + if ui + .add_enabled( + phase != DictationPhase::Idle, + egui::Button::new(tr_l10n(lang, "btn.cancel")), + ) + .clicked() + { + if let Some(backend) = self.backend() { + self.spawn(async move { + backend.cancel_dictation(None).await?; + Ok(tr_l10n(lang, "status.dictation_cancelled").to_string()) + }); + } + } + }); + ui.label(if self.transcript.is_empty() { + tr_l10n(lang, "dictation.no_transcript") + } else { + &self.transcript + }); + } + + fn less_computer_ui(&mut self, ui: &mut egui::Ui) { + let lang = self.lang; + ui.heading("Less Computer"); + ui.text_edit_multiline(&mut self.less_computer_input); + ui.horizontal(|ui| { + if ui.button(tr_l10n(lang, "btn.run")).clicked() + && !self.less_computer_input.trim().is_empty() + { + if let Some(backend) = self.backend() { + let prompt = self.less_computer_input.clone(); + self.less_computer_output.clear(); + self.spawn(async move { + backend.submit_less_computer(prompt).await?; + Ok(tr_l10n(lang, "less_computer.done").to_string()) + }); + } + } + if ui.button(tr_l10n(lang, "btn.cancel")).clicked() { + if let Some(backend) = self.backend() { + self.spawn(async move { + backend.cancel_less_computer(None).await?; + Ok(tr_l10n(lang, "less_computer.cancelled").to_string()) + }); + } + } + }); + if let Some((token, command)) = self.pending_approval.clone() { + ui.label(fmt_l10n(lang, "approval.request_run", &[&command])); + ui.horizontal(|ui| { + for (label, approved) in [ + (tr_l10n(lang, "btn.allow"), true), + (tr_l10n(lang, "btn.deny"), false), + ] { + if ui.button(label).clicked() { + if let Some(backend) = self.backend() { + let token = token.clone(); + self.pending_approval = None; + self.spawn(async move { + backend + .services() + .less_computer + .approve(token, approved) + .await?; + Ok(tr_l10n(lang, "approval.submitted").to_string()) + }); + } + } + } + }); + } + ui.label(if self.less_computer_output.is_empty() { + tr_l10n(lang, "less_computer.no_output") + } else { + &self.less_computer_output + }); + } + + fn qa_ui(&mut self, ui: &mut egui::Ui) { + if !self.qa_visible { + return; + } + let lang = self.lang; + ui.heading(tr_l10n(lang, "heading.qa")); + if let Some(state) = &self.qa_state { + if let Some(messages) = &state.messages { + for message in messages { + ui.label(format!("{}:{}", message.role, message.content)); + } + } + if let Some(chunk) = &state.chunk { + ui.label(chunk); + } + if let Some(error) = &state.error { + ui.colored_label(egui::Color32::RED, error); + } + } + ui.text_edit_multiline(&mut self.qa_input); + ui.horizontal(|ui| { + let recording = self + .qa_state + .as_ref() + .is_some_and(|state| state.kind == QaStateKind::Recording); + if ui + .button(if recording { + tr_l10n(lang, "btn.end_recording") + } else { + tr_l10n(lang, "btn.voice_ask") + }) + .clicked() + { + if let Some(backend) = self.backend() { + self.spawn(async move { + backend.services().qa.toggle_recording().await?; + Ok(tr_l10n(lang, "qa.recording_updated").to_string()) + }); + } + } + if ui.button(tr_l10n(lang, "btn.send")).clicked() + && !self.qa_input.trim().is_empty() + { + if let Some(backend) = self.backend() { + let text = std::mem::take(&mut self.qa_input); + self.spawn(async move { + backend.services().qa.submit_text(text).await?; + Ok(tr_l10n(lang, "qa.submitted").to_string()) + }); + } + } + if ui.button(tr_l10n(lang, "btn.close")).clicked() { + if let Some(backend) = self.backend() { + self.spawn(async move { + backend.services().qa.dismiss().await?; + Ok(tr_l10n(lang, "qa.closed").to_string()) + }); + } + } + }); + } + + fn selection_ui(&mut self, ui: &mut egui::Ui) { + let Some(selection) = self.selection.clone() else { + return; + }; + let lang = self.lang; + if self.selection_preview_visible && selection.phase == SelectionPhase::Preview { + ui.heading(tr_l10n(lang, "heading.selection_preview")); + ui.text_edit_multiline(&mut self.selection_draft); + ui.horizontal(|ui| { + if ui.button(tr_l10n(lang, "btn.confirm_replace")).clicked() { + if let (Some(backend), Some(session_id)) = + (self.backend(), selection.session_id) + { + let text = self.selection_draft.clone(); + self.spawn(async move { + backend + .services() + .selection + .confirm(session_id, Some(text)) + .await?; + Ok(tr_l10n(lang, "selection.replaced").to_string()) + }); + } + } + if ui.button(tr_l10n(lang, "btn.cancel")).clicked() { + if let (Some(backend), Some(session_id)) = + (self.backend(), selection.session_id) + { + self.spawn(async move { + backend + .services() + .selection + .cancel(Some(session_id)) + .await?; + Ok(tr_l10n(lang, "selection.cancelled").to_string()) + }); + } + } + }); + } else if selection.phase == SelectionPhase::Completed + && selection.revert_outcome.is_none() + { + ui.horizontal(|ui| { + ui.label(tr_l10n(lang, "selection.replace_completed")); + if ui.button(tr_l10n(lang, "btn.undo")).clicked() { + if let (Some(backend), Some(session_id)) = + (self.backend(), selection.session_id) + { + self.spawn(async move { + backend.services().selection.revert(session_id).await?; + Ok(tr_l10n(lang, "selection.reverted").to_string()) + }); + } + } + }); + } + } + + fn provider_management_ui(&mut self, ui: &mut egui::Ui) { + let lang = self.lang; + ui.horizontal(|ui| { + ui.strong(tr_l10n(lang, "providers.credentials")); + for (kind, label) in [ + (openless_core::ChannelKind::Asr, "ASR"), + (openless_core::ChannelKind::Llm, "LLM"), + ] { + if ui + .selectable_label(self.provider_kind == kind, label) + .clicked() + && self.provider_kind != kind + { + self.provider_kind = kind; + self.providers = ProvidersState::Loading; + self.selected_channel_id = None; + self.pending_channel_delete = None; + self.provider_editor = ProviderEditorState::Idle; + self.provider_models.clear(); + self.load_providers(kind); + } + } + if ui.button(tr_l10n(lang, "btn.refresh_channel")).clicked() { + self.providers = ProvidersState::Loading; + self.load_providers(self.provider_kind); + } + }); + + let panel = match self.providers.clone() { + ProvidersState::Loading => { + ui.label(tr_l10n(lang, "providers.loading_dir")); + return; + } + ProvidersState::Failed(error) => { + ui.colored_label(egui::Color32::RED, error); + return; + } + ProvidersState::Loaded(panel) => panel, + }; + + ui.group(|ui| { + ui.label(tr_l10n(lang, "btn.new_channel")); + ui.horizontal(|ui| { + egui::ComboBox::from_id_salt("new-provider-type") + .selected_text( + panel + .descriptors + .iter() + .find(|item| item.provider_type.as_str() == self.new_provider_type) + .map(provider_descriptor_label) + .unwrap_or_else(|| { + tr_l10n(lang, "lbl.choose_provider").to_string() + }), + ) + .show_ui(ui, |ui| { + for descriptor in &panel.descriptors { + ui.selectable_value( + &mut self.new_provider_type, + descriptor.provider_type.as_str().to_string(), + provider_descriptor_label(descriptor), + ); + } + }); + ui.text_edit_singleline(&mut self.new_channel_name); + if ui + .add_enabled( + !self.new_provider_type.is_empty(), + egui::Button::new(tr_l10n(lang, "btn.create")), + ) + .clicked() + { + if let (Some(backend), Some(descriptor)) = ( + self.backend(), + panel + .descriptors + .iter() + .find(|item| item.provider_type.as_str() == self.new_provider_type), + ) { + let kind = panel.kind; + let provider_type = descriptor.provider_type.as_str().to_string(); + let name = if self.new_channel_name.trim().is_empty() { + descriptor.label_key.clone() + } else { + self.new_channel_name.trim().to_string() + }; + self.new_channel_name.clear(); + self.spawn_provider_mutation(async move { + backend.create_channel(kind, provider_type, name).await?; + Ok(tr_l10n(lang, "status.channel_created").to_string()) + }); + } + } + }); + ui.small(tr_l10n(lang, "providers.core_note")); + }); + + if panel.channels.is_empty() { + ui.label(tr_l10n(lang, "providers.empty")); + return; + } + + for (index, channel) in panel.channels.iter().enumerate() { + let active = channel.id == panel.active_provider; + ui.horizontal(|ui| { + let selected = self.selected_channel_id.as_deref() == Some(channel.id.as_str()); + let active_suffix = if active { + tr_l10n(lang, "btn.status_active") + } else { + "" + }; + let disabled_suffix = if channel.enabled { + "" + } else { + tr_l10n(lang, "btn.status_disabled") + }; + if ui + .selectable_label( + selected, + format!( + "{} · {}{active_suffix}{disabled_suffix}", + channel.name, channel.provider_type, + ), + ) + .clicked() + { + self.selected_channel_id = Some(channel.id.clone()); + self.provider_models.clear(); + if let Some((channel, descriptor)) = + provider_channel_descriptor(&panel, &channel.id) + { + self.provider_editor = ProviderEditorState::Loading { + kind: panel.kind, + channel_id: channel.id.clone(), + }; + self.load_provider_editor(panel.kind, channel, descriptor); + } + } + if !active + && channel.enabled + && ui.button(tr_l10n(lang, "btn.set_active")).clicked() + { + if let Some(backend) = self.backend() { + let slot = provider_slot(panel.kind); + let channel_id = channel.id.clone(); + self.spawn_provider_mutation(async move { + backend.set_active_provider(slot, channel_id).await?; + Ok(tr_l10n(lang, "status.channel_active").to_string()) + }); + } + } + if ui + .button(if channel.enabled { + tr_l10n(lang, "btn.disable") + } else { + tr_l10n(lang, "btn.enable") + }) + .clicked() + { + if let Some(backend) = self.backend() { + let kind = panel.kind; + let channel_id = channel.id.clone(); + let enabled = !channel.enabled; + self.spawn_provider_mutation(async move { + backend + .set_channel_enabled(kind, channel_id, enabled) + .await?; + Ok(tr_l10n(lang, "status.channel_enabled").to_string()) + }); + } + } + if index > 0 && ui.button(tr_l10n(lang, "btn.move_up")).clicked() { + if let Some(backend) = self.backend() { + let kind = panel.kind; + let mut ids = panel + .channels + .iter() + .map(|item| item.id.clone()) + .collect::>(); + ids.swap(index, index - 1); + self.spawn_provider_mutation(async move { + backend.reorder_channels(kind, ids).await?; + Ok(tr_l10n(lang, "status.channel_reordered").to_string()) + }); + } + } + if index + 1 < panel.channels.len() + && ui.button(tr_l10n(lang, "btn.move_down")).clicked() + { + if let Some(backend) = self.backend() { + let kind = panel.kind; + let mut ids = panel + .channels + .iter() + .map(|item| item.id.clone()) + .collect::>(); + ids.swap(index, index + 1); + self.spawn_provider_mutation(async move { + backend.reorder_channels(kind, ids).await?; + Ok(tr_l10n(lang, "status.channel_reordered").to_string()) + }); + } + } + if self.pending_channel_delete.as_deref() == Some(channel.id.as_str()) { + if ui.button(tr_l10n(lang, "btn.confirm_delete")).clicked() { + self.pending_channel_delete = None; + if let Some(backend) = self.backend() { + let kind = panel.kind; + let channel_id = channel.id.clone(); + self.spawn_provider_mutation(async move { + backend.delete_channel(kind, channel_id).await?; + Ok(tr_l10n(lang, "status.channel_deleted").to_string()) + }); + } + } + if ui.button(tr_l10n(lang, "btn.cancel_delete")).clicked() { + self.pending_channel_delete = None; + } + } else if ui.button(tr_l10n(lang, "btn.delete")).clicked() { + // Channel deletion may remove the last usable provider + // and its persisted secrets, so require a deliberate + // second click even in this intentionally compact UI. + self.pending_channel_delete = Some(channel.id.clone()); + } + }); + } + + match self.provider_editor.clone() { + ProviderEditorState::Idle => {} + ProviderEditorState::Loading { kind, channel_id } => { + ui.label(fmt_l10n( + lang, + "providers.reading_channel", + &[&format!("{kind:?}"), &channel_id], + )); + } + ProviderEditorState::Failed(error) => { + ui.colored_label(egui::Color32::RED, error); + } + ProviderEditorState::Loaded(editor) => { + let mut editor = *editor; + ui.separator(); + ui.strong(fmt_l10n(lang, "providers.editing", &[&editor.channel.id])); + let mut provider_type = editor.descriptor.provider_type.as_str().to_string(); + egui::ComboBox::from_id_salt("edit-provider-type") + .selected_text(provider_descriptor_label(&editor.descriptor)) + .show_ui(ui, |ui| { + for descriptor in &panel.descriptors { + ui.selectable_value( + &mut provider_type, + descriptor.provider_type.as_str().to_string(), + provider_descriptor_label(descriptor), + ); + } + }); + if provider_type != editor.descriptor.provider_type.as_str() { + if let Some(backend) = self.backend() { + let kind = editor.kind; + let channel_id = editor.channel.id.clone(); + self.spawn_provider_mutation(async move { + backend + .set_channel_provider_type(kind, channel_id, provider_type) + .await?; + Ok(tr_l10n(lang, "status.provider_type_updated").to_string()) + }); + } + return; + } + + ui.label(fmt_l10n( + lang, + "providers.auth_probe", + &[ + &auth_requirement_label(lang, editor.descriptor.auth_requirement), + &format!("{:?}", editor.descriptor.validation_probe), + ], + )); + ui.horizontal(|ui| { + ui.label(tr_l10n(lang, "providers.name")); + ui.text_edit_singleline(&mut editor.name); + }); + provider_fields_ui(ui, lang, &mut editor); + + ui.horizontal(|ui| { + if ui.button(tr_l10n(lang, "btn.save_fields")).clicked() { + if let Some(backend) = self.backend() { + let saved = editor.clone(); + self.spawn_provider_mutation(async move { + save_provider_editor(backend, saved).await?; + Ok(tr_l10n(lang, "status.channel_saved").to_string()) + }); + } + } + if ui.button(tr_l10n(lang, "btn.clear_secret")).clicked() { + if let Some(backend) = self.backend() { + let cleared = editor.clone(); + self.spawn_provider_mutation(async move { + clear_provider_secrets(backend, &cleared).await?; + Ok(tr_l10n(lang, "status.secret_cleared").to_string()) + }); + } + } + if ui.button(tr_l10n(lang, "btn.validate")).clicked() { + if let Some(backend) = self.backend() { + let kind = editor.kind; + let channel_id = editor.channel.id.clone(); + self.spawn_provider_mutation(async move { + validate_provider_channel(lang, backend, kind, channel_id).await + }); + } + } + if ui.button(tr_l10n(lang, "btn.list_models")).clicked() { + self.provider_models.clear(); + self.request_provider_models(editor.kind, editor.channel.id.clone()); + } + }); + if !self.provider_models.is_empty() { + ui.label(tr_l10n(lang, "providers.model_list")); + for model in self.provider_models.clone() { + if ui.button(&model).clicked() { + editor.model = model; + } + } + } + self.provider_editor = ProviderEditorState::Loaded(Box::new(editor)); + } + } + } + + /// Apply a newly chosen UI locale immediately: persist it as Linux-UI + /// state (never Core business truth), resolve it to a concrete language + /// and let the next frame re-render every localized surface. Persistence + /// is offloaded off the egui frame so the write can never stall a repaint. + fn apply_locale_pref(&mut self, pref: LocalePref) { + if pref == self.locale_pref { + return; + } + self.locale_pref = pref; + self.lang = pref.resolve(); + if let Some(tray) = &self.tray { + let _ = tray.set_lang(self.lang); + } + let runtime = self.tokio.clone(); + runtime.spawn_blocking(move || { + let _ = save_locale_pref(pref); + }); + } + + /// The language selector row shown in Settings. Changing it re-renders + /// the whole window immediately (shell, headings, labels, popups later + /// pick it up from the persisted UI state on their next launch). + fn language_selector_ui(&mut self, ui: &mut egui::Ui) { + let mut chosen: Option = None; + ui.horizontal(|ui| { + ui.label(egui::RichText::new(tr_l10n(self.lang, "settings.language")).strong()); + let pref = self.locale_pref; + let lang = self.lang; + let selected = match pref { + LocalePref::System => { + tr_l10n(lang, "settings.language_follow_system").to_string() + } + LocalePref::Lang(explicit) => { + tr_l10n(explicit, locale_key(explicit)).to_string() + } + }; + egui::ComboBox::from_id_salt("openless-ui-language") + .width(240.0) + .selected_text(selected) + .show_ui(ui, |ui| { + if ui + .selectable_label( + pref == LocalePref::System, + tr_l10n(lang, "settings.language_follow_system"), + ) + .clicked() + { + chosen = Some(LocalePref::System); + } + for option in LANGS { + let native_label = tr_l10n(option, locale_key(option)); + if ui + .selectable_label(pref == LocalePref::Lang(option), native_label) + .clicked() + { + chosen = Some(LocalePref::Lang(option)); + } + } + }); + }); + if let Some(pref) = chosen { + self.apply_locale_pref(pref); + self.status = tr_l10n(self.lang, "settings.locale_saved").to_string(); + ui.ctx().request_repaint(); + } + } + + fn settings_ui(&mut self, ui: &mut egui::Ui) { + let lang = self.lang; + ui.heading(tr_l10n(lang, "nav.providers")); + self.language_selector_ui(ui); + ui.separator(); + if let Some(snapshot) = &self.snapshot { + let credentials = &snapshot.credentials; + let asr_state = if credentials.asr_configured { + tr_l10n(lang, "overview.configured") + } else { + tr_l10n(lang, "overview.unconfigured") + }; + let llm_state = if credentials.llm_configured { + tr_l10n(lang, "overview.configured") + } else { + tr_l10n(lang, "overview.unconfigured") + }; + ui.label(format!( + "ASR:{}({asr_state})", + credentials.active_asr_provider + )); + ui.label(format!( + "LLM:{}({llm_state})", + credentials.active_llm_provider + )); + } + self.provider_management_ui(ui); + ui.separator(); + let mut save_settings = false; + if let Some(preferences) = self.preferences.as_mut() { + self.settings_dirty.streaming_insert |= ui + .checkbox( + &mut preferences.streaming_insert, + tr_l10n(lang, "settings.streaming_insert"), + ) + .changed(); + self.settings_dirty.coding_agent_enabled |= ui + .checkbox( + &mut preferences.coding_agent_enabled, + tr_l10n(lang, "settings.enable_coding_agent"), + ) + .changed(); + ui.collapsing(tr_l10n(lang, "settings.recording_input"), |ui| { + let previous_mode = preferences.hotkey.mode; + egui::ComboBox::from_label(tr_l10n(lang, "settings.rec_mode")) + .selected_text(match preferences.hotkey.mode { + openless_core::shared_types::HotkeyMode::Toggle => { + tr_l10n(lang, "recmode.toggle") + } + openless_core::shared_types::HotkeyMode::Hold => { + tr_l10n(lang, "recmode.hold") + } + openless_core::shared_types::HotkeyMode::DoubleClick => { + tr_l10n(lang, "recmode.double_click") + } + openless_core::shared_types::HotkeyMode::Auto => { + tr_l10n(lang, "recmode.auto") + } + }) + .show_ui(ui, |ui| { + for (mode, key) in [ + ( + openless_core::shared_types::HotkeyMode::Toggle, + "recmode.toggle", + ), + ( + openless_core::shared_types::HotkeyMode::Hold, + "recmode.hold", + ), + ( + openless_core::shared_types::HotkeyMode::Auto, + "recmode.auto", + ), + ] { + ui.selectable_value( + &mut preferences.hotkey.mode, + mode, + tr_l10n(lang, key), + ); + } + }); + self.settings_dirty.recording |= preferences.hotkey.mode != previous_mode; + self.settings_dirty.recording |= ui + .checkbox( + &mut preferences.silence_auto_stop_enabled, + tr_l10n(lang, "settings.auto_stop"), + ) + .changed(); + if preferences.silence_auto_stop_enabled { + let previous = preferences.silence_auto_stop_seconds; + egui::ComboBox::from_label(tr_l10n(lang, "settings.silence_duration")) + .selected_text(fmt_l10n(lang, "settings.seconds", &[&previous])) + .show_ui(ui, |ui| { + for seconds in [1.0, 1.5, 2.0, 3.0, 4.0, 5.0] { + ui.selectable_value( + &mut preferences.silence_auto_stop_seconds, + seconds, + fmt_l10n(lang, "settings.seconds", &[&seconds]), + ); + } + }); + self.settings_dirty.recording |= + preferences.silence_auto_stop_seconds != previous; + } + let selected_microphone = if preferences.microphone_device_name.is_empty() { + tr_l10n(lang, "settings.system_default").to_string() + } else { + preferences.microphone_device_name.clone() + }; + let previous_microphone = preferences.microphone_device_name.clone(); + egui::ComboBox::from_label(tr_l10n(lang, "settings.microphone")) + .selected_text(selected_microphone) + .show_ui(ui, |ui| { + ui.selectable_value( + &mut preferences.microphone_device_name, + String::new(), + tr_l10n(lang, "settings.system_default"), + ); + for device in &self.microphones { + ui.selectable_value( + &mut preferences.microphone_device_name, + device.name.clone(), + &device.name, + ); + } + }); + self.settings_dirty.microphone |= + preferences.microphone_device_name != previous_microphone; + self.settings_dirty.recording |= ui + .checkbox( + &mut preferences.mute_during_recording, + tr_l10n(lang, "settings.mute_while"), + ) + .changed(); + self.settings_dirty.recording |= ui + .checkbox( + &mut preferences.audio_cue_on_record, + tr_l10n(lang, "settings.cue_audio"), + ) + .changed(); + }); + ui.collapsing(tr_l10n(lang, "settings.appearance"), |ui| { + let previous_theme = preferences.theme_mode; + egui::ComboBox::from_label(tr_l10n(lang, "settings.theme")) + .selected_text(match preferences.theme_mode { + openless_core::shared_types::ThemeMode::System => { + tr_l10n(lang, "theme.follow_system") + } + openless_core::shared_types::ThemeMode::Light => { + tr_l10n(lang, "theme.light") + } + openless_core::shared_types::ThemeMode::Dark => { + tr_l10n(lang, "theme.dark") + } + }) + .show_ui(ui, |ui| { + for (mode, key) in [ + ( + openless_core::shared_types::ThemeMode::System, + "theme.follow_system", + ), + (openless_core::shared_types::ThemeMode::Light, "theme.light"), + (openless_core::shared_types::ThemeMode::Dark, "theme.dark"), + ] { + ui.selectable_value( + &mut preferences.theme_mode, + mode, + tr_l10n(lang, key), + ); + } + }); + self.settings_dirty.appearance |= preferences.theme_mode != previous_theme; + self.settings_dirty.appearance |= ui + .checkbox( + &mut preferences.show_overview_activity_heatmap, + tr_l10n(lang, "settings.show_heatmap"), + ) + .changed(); + }); + ui.collapsing(tr_l10n(lang, "settings.hotkeys_group"), |ui| { + self.settings_dirty.hotkeys |= shortcut_editor( + ui, + tr_l10n(lang, "hotkey.dictation"), + &mut preferences.dictation_hotkey, + ); + self.settings_dirty.hotkeys |= + optional_shortcut_editor(ui, lang, "QA", &mut preferences.qa_hotkey, ";"); + self.settings_dirty.hotkeys |= shortcut_editor( + ui, + tr_l10n(lang, "hotkey.translation"), + &mut preferences.translation_hotkey, + ); + self.settings_dirty.hotkeys |= optional_shortcut_editor( + ui, + lang, + tr_l10n(lang, "hotkey.selection_polish"), + &mut preferences.selection_polish_hotkey, + "P", + ); + self.settings_dirty.hotkeys |= optional_shortcut_editor( + ui, + lang, + tr_l10n(lang, "hotkey.switch_style"), + &mut preferences.switch_style_hotkey, + "S", + ); + self.settings_dirty.hotkeys |= optional_shortcut_editor( + ui, + lang, + tr_l10n(lang, "hotkey.open_app"), + &mut preferences.open_app_hotkey, + "O", + ); + self.settings_dirty.hotkeys |= optional_shortcut_editor( + ui, + lang, + tr_l10n(lang, "hotkey.coding_agent"), + &mut preferences.coding_agent_voice_hotkey, + "L", + ); + }); + self.settings_dirty.start_minimized |= ui + .checkbox( + &mut preferences.start_minimized, + tr_l10n(lang, "settings.start_minimized"), + ) + .changed(); + self.settings_dirty.launch_at_login |= ui + .checkbox( + &mut preferences.launch_at_login, + tr_l10n(lang, "settings.launch_at_login"), + ) + .changed(); + self.settings_dirty.auto_update_check |= ui + .checkbox( + &mut preferences.auto_update_check, + tr_l10n(lang, "settings.auto_update"), + ) + .changed(); + let previous_channel = preferences.update_channel; + egui::ComboBox::from_label(tr_l10n(lang, "settings.update_channel")) + .selected_text(match preferences.update_channel { + openless_core::shared_types::UpdateChannel::Stable => { + tr_l10n(lang, "channel.stable") + } + openless_core::shared_types::UpdateChannel::Beta => "Beta", + }) + .show_ui(ui, |ui| { + ui.selectable_value( + &mut preferences.update_channel, + openless_core::shared_types::UpdateChannel::Stable, + tr_l10n(lang, "channel.stable"), + ); + ui.selectable_value( + &mut preferences.update_channel, + openless_core::shared_types::UpdateChannel::Beta, + "Beta", + ); + }); + self.settings_dirty.update_channel |= + preferences.update_channel != previous_channel; + self.settings_dirty.remote_input_enabled |= ui + .checkbox( + &mut preferences.remote_input_enabled, + tr_l10n(lang, "settings.enable_remote"), + ) + .changed(); + self.settings_dirty.remote_input_port |= ui + .add( + egui::DragValue::new(&mut preferences.remote_input_port) + .range(1..=u16::MAX) + .prefix(tr_l10n(lang, "settings.port")), + ) + .changed(); + if ui.button(tr_l10n(lang, "btn.save_settings")).clicked() { + save_settings = true; + } + } + if save_settings { + if let (Some(native), Some(draft), Some(snapshot)) = + (&self.native, self.preferences.clone(), &self.snapshot) + { + let host = native.host_arc(); + let revision = snapshot.preferences_revision; + let dirty = self.settings_dirty; + let tx = self.tx.clone(); + self.tokio.spawn(async move { + let outcome = tokio::task::spawn_blocking(move || { + let save = |preferences, revision| { + if dirty.hotkeys { + host.update_settings_strict(preferences, revision) + } else { + host.save_settings(preferences, revision) + } + }; + match save(draft.clone(), revision) { + Err(error) + if error.code == openless_core::BackendErrorCode::Busy => + { + let latest_snapshot = host.snapshot(); + let latest = host.backend().get_preferences(); + save( + dirty.merge(&latest, &draft), + latest_snapshot.preferences_revision, + ) + } + result => result, + } + }) + .await + .map_err(|error| error.to_string()) + .and_then(|result| result.map_err(|error| error.to_string())); + let _ = tx.send(UiResult::SettingsSaved(Box::new(outcome))); + }); + } + } + if let Some((remote, pin)) = &self.remote_access { + let remote_state = if remote.running { + tr_l10n(lang, "remote.running") + } else if remote.starting { + tr_l10n(lang, "remote.starting") + } else { + tr_l10n(lang, "remote.stopped") + }; + ui.label(remote_state); + if remote.enabled { + ui.label(fmt_l10n( + lang, + "remote.lang_conns", + &[&remote.locale, &remote.connection_count], + )); + ui.monospace(format!("PIN:{pin}")); + for url in &remote.urls { + ui.monospace(url); + } + if ui.button(tr_l10n(lang, "btn.reset_pairing")).clicked() { + if let Some(backend) = self.backend() { + self.spawn(async move { + backend + .services() + .remote_input + .regenerate_pairing_pin() + .await?; + Ok(tr_l10n(lang, "status.remote_pin_reset").to_string()) }); } - HostAction::RequestRestart => { - self.status = "请手动重启 OpenLess".to_string(); + } + } + } + if ui.button(tr_l10n(lang, "btn.export_error_log")).clicked() { + if let Some(backend) = self.backend() { + let source = openless_linux_egui::log_path(&backend.config().data_dir); + self.spawn(async move { + let destination = tokio::task::spawn_blocking(|| { + rfd::FileDialog::new() + .add_filter("Log", &["log"]) + .set_file_name("openless.log") + .save_file() + }) + .await + .map_err(|error| { + BackendError::new( + openless_core::BackendErrorCode::Internal, + error.to_string(), + ) + })? + .ok_or_else(|| { + BackendError::new( + openless_core::BackendErrorCode::Cancelled, + tr_l10n(lang, "dialog.export_log_cancelled"), + ) + })?; + tokio::task::spawn_blocking(move || { + openless_linux_egui::export_error_log(&source, &destination) + }) + .await + .map_err(|error| { + BackendError::new( + openless_core::BackendErrorCode::Internal, + error.to_string(), + ) + })? + .map_err(|error| { + BackendError::new( + openless_core::BackendErrorCode::Platform, + error.to_string(), + ) + })?; + Ok(tr_l10n(lang, "status.export_log_done").to_string()) + }); + } + } + ui.separator(); + ui.heading(tr_l10n(lang, "head.software_update")); + let channel = self + .preferences + .as_ref() + .map(|preferences| preferences.update_channel) + .unwrap_or_default(); + match &self.update_support { + LinuxUpdateSupport::AppImage(_) => { + ui.horizontal(|ui| { + if ui + .add_enabled( + !self.update_busy, + egui::Button::new(tr_l10n(lang, "btn.check_now")), + ) + .clicked() + { + self.request_update_check(channel); + } + if self.update_manifest.is_some() + && ui + .add_enabled( + !self.update_busy, + egui::Button::new(tr_l10n(lang, "btn.download_install")), + ) + .clicked() + { + self.install_update(); + } + }); + if let Some(manifest) = &self.update_manifest { + ui.label(fmt_l10n(lang, "update.available", &[&manifest.version])); + } + if let Some(progress) = self.update_progress { + let fraction = progress + .content_length + .filter(|total| *total > 0) + .map(|total| progress.downloaded as f32 / total as f32); + if let Some(fraction) = fraction { + ui.add(egui::ProgressBar::new(fraction.clamp(0.0, 1.0))); + } + ui.label(fmt_l10n(lang, "update.downloaded", &[&progress.downloaded])); + } + } + LinuxUpdateSupport::ManualOnly { releases_url } => { + ui.label(tr_l10n(lang, "update.manual_notice")); + if ui.button(tr_l10n(lang, "btn.open_releases")).clicked() { + let url = (*releases_url).to_string(); + std::thread::spawn(move || { + let _ = open_external(&url); + }); + } + } + } + } + + fn vocabulary_ui(&mut self, ui: &mut egui::Ui) { + let lang = self.lang; + if let Some(backend) = self.backend() { + let pending = backend.pending_corrections(); + if !pending.is_empty() { + ui.heading(tr_l10n(lang, "head.pending_corrections")); + let mut action: Option<(String, bool)> = None; + for suggestion in pending { + ui.horizontal(|ui| { + ui.label(format!( + "{} → {}", + suggestion.pattern, suggestion.replacement + )); + if ui.small_button(tr_l10n(lang, "btn.accept")).clicked() { + action = Some((suggestion.id.clone(), true)); + } + if ui.small_button(tr_l10n(lang, "btn.ignore")).clicked() { + action = Some((suggestion.id.clone(), false)); + } + }); + } + if ui.button(tr_l10n(lang, "btn.close_all")).clicked() { + backend.dismiss_pending_corrections(); + } + if let Some((id, accept)) = action { + self.spawn(async move { + if accept { + backend.accept_pending_correction(&id)?; + } else { + backend.reject_pending_correction(&id); + } + Ok(tr_l10n(lang, "status.suggestion_handled").to_string()) + }); + } + ui.separator(); + } + } + ui.heading(tr_l10n(lang, "head.vocab_presets")); + ui.label(tr_l10n(lang, "lbl.preset_note")); + let mut preset_action: Option<(String, String)> = None; + for preset in &self.vocab_presets { + ui.horizontal_wrapped(|ui| { + ui.strong(&preset.name); + ui.label(fmt_l10n(lang, "lbl.preset_count", &[&preset.phrases.len()])); + if ui.small_button(tr_l10n(lang, "btn.apply")).clicked() { + preset_action = Some((preset.id.clone(), "apply".into())); + } + if self + .vocab_preset_store + .custom + .iter() + .any(|custom| custom.id == preset.id) + && ui.small_button(tr_l10n(lang, "btn.delete")).clicked() + { + preset_action = Some((preset.id.clone(), "delete".into())); + } else if openless_core::builtin_vocab_presets() + .iter() + .any(|builtin| builtin.id == preset.id) + && ui.small_button(tr_l10n(lang, "btn.hide_builtin")).clicked() + { + preset_action = Some((preset.id.clone(), "disable".into())); + } + ui.weak(preset.phrases.join("、")); + }); + } + for id in self.vocab_preset_store.disabled_builtin_preset_ids.clone() { + if ui + .small_button(fmt_l10n(lang, "btn.restore_builtin", &[&id])) + .clicked() + { + preset_action = Some((id, "enable".into())); + } + } + ui.group(|ui| { + ui.label(tr_l10n(lang, "lbl.new_custom_preset")); + ui.text_edit_singleline(&mut self.vocab_preset_name); + ui.add( + egui::TextEdit::multiline(&mut self.vocab_preset_phrases) + .hint_text(tr_l10n(lang, "hint.preset_phrases")) + .desired_rows(3), + ); + if ui.button(tr_l10n(lang, "btn.save_preset")).clicked() + && !self.vocab_preset_name.trim().is_empty() + && !self.vocab_preset_phrases.trim().is_empty() + { + preset_action = Some((String::new(), "create".into())); + } + }); + if let (Some(backend), Some((id, operation))) = (self.backend(), preset_action) { + let name = std::mem::take(&mut self.vocab_preset_name); + let phrases_text = std::mem::take(&mut self.vocab_preset_phrases); + let selected = self + .vocab_presets + .iter() + .find(|preset| preset.id == id) + .cloned(); + self.spawn(async move { + match operation.as_str() { + "apply" => { + let preset = selected.ok_or_else(|| { + BackendError::new( + openless_core::BackendErrorCode::Cancelled, + tr_l10n(lang, "status.preset_gone"), + ) + })?; + for phrase in preset.phrases { + backend.add_vocabulary( + phrase, + Some(fmt_l10n(lang, "status.from_preset", &[&preset.name])), + )?; + } + } + "create" => { + let mut phrases = phrases_text + .split([',', ',', '\n']) + .map(str::trim) + .filter(|phrase| !phrase.is_empty()) + .map(ToOwned::to_owned) + .collect::>(); + phrases.sort(); + phrases.dedup(); + let mut store = backend.list_vocabulary_presets()?; + store.custom.push(openless_core::VocabPreset { + id: uuid::Uuid::new_v4().to_string(), + name: name.trim().to_string(), + phrases, + }); + backend.save_vocabulary_presets(&store)?; } - HostAction::ShowSelectionPreview => { - self.navigation.open(Page::Selection); - self.selection_preview_visible = true; - ctx.send_viewport_cmd(egui::ViewportCommand::Visible(true)); + "delete" => { + let mut store = backend.list_vocabulary_presets()?; + store.custom.retain(|preset| preset.id != id); + backend.save_vocabulary_presets(&store)?; } - HostAction::HideSelectionPreview => { - self.selection_preview_visible = false; + "disable" => { + let mut store = backend.list_vocabulary_presets()?; + if !store.disabled_builtin_preset_ids.contains(&id) { + store.disabled_builtin_preset_ids.push(id); + } + backend.save_vocabulary_presets(&store)?; } - HostAction::ShowQa => { - self.navigation.open(Page::Qa); - self.qa_visible = true; - ctx.send_viewport_cmd(egui::ViewportCommand::Visible(true)); + "enable" => { + let mut store = backend.list_vocabulary_presets()?; + store + .disabled_builtin_preset_ids + .retain(|preset_id| preset_id != &id); + backend.save_vocabulary_presets(&store)?; } - HostAction::HideQa => self.qa_visible = false, - HostAction::ShowDictationFeedback | HostAction::HideDictationFeedback => {} + _ => unreachable!(), + } + Ok(tr_l10n(lang, "status.preset_updated").to_string()) + }); + } + ui.separator(); + ui.heading(tr_l10n(lang, "head.custom_vocab")); + ui.horizontal(|ui| { + ui.label(tr_l10n(lang, "lbl.phrase")); + ui.text_edit_singleline(&mut self.vocabulary_phrase); + ui.label(tr_l10n(lang, "lbl.note")); + ui.text_edit_singleline(&mut self.vocabulary_note); + if ui.button(tr_l10n(lang, "btn.add")).clicked() + && !self.vocabulary_phrase.trim().is_empty() + { + if let Some(backend) = self.backend() { + let phrase = std::mem::take(&mut self.vocabulary_phrase); + let note = std::mem::take(&mut self.vocabulary_note); + self.spawn(async move { + backend.add_vocabulary( + phrase, + (!note.trim().is_empty()).then_some(note), + )?; + Ok(tr_l10n(lang, "status.vocab_saved").to_string()) + }); } } + }); + let mut vocabulary_action = None; + for entry in &self.vocabulary { + ui.horizontal(|ui| { + let mut enabled = entry.enabled; + if ui.checkbox(&mut enabled, "").changed() { + vocabulary_action = Some((entry.id.clone(), Some(enabled))); + } + ui.label(egui::RichText::new(&entry.phrase).strong()); + if let Some(note) = &entry.note { + ui.label(note); + } + ui.label(fmt_l10n(lang, "lbl.hits", &[&entry.hits])); + if ui.small_button(tr_l10n(lang, "btn.delete")).clicked() { + vocabulary_action = Some((entry.id.clone(), None)); + } + }); } - let mut events = Vec::new(); - let drain = self - .subscription - .as_mut() - .map(|subscription| drain_events(subscription, |event| events.push(event))); - for event in events { - self.apply_event(event); + if let (Some(backend), Some((id, enabled))) = (self.backend(), vocabulary_action) { + self.spawn(async move { + if let Some(enabled) = enabled { + backend.set_vocabulary_enabled(&id, enabled)?; + } else { + backend.remove_vocabulary(&id)?; + } + Ok(tr_l10n(lang, "status.vocab_updated").to_string()) + }); } - if let Some(EventDrainOutcome::Lagged { dropped, .. }) = drain { - if let Some(backend) = self.backend() { - // Broadcast lag does not imply Core lost the events. Replay - // from the last applied sequence first; duplicate delivery - // from the live receiver is rejected by apply_event above. - let replay = backend.replay_events_after(self.last_event_sequence); - let snapshot = backend.snapshot(); - if replay.truncated { - // The bounded tail cannot reconstruct derived text/UI - // state. Reset it before applying the authoritative tail - // so no stale transcript, approval or preview survives. - self.transcript_state = TranscriptAccumulator::default(); - self.transcript.clear(); - self.transcript_session = snapshot.dictation.session_id; - self.less_computer_input.clear(); - self.less_computer_output.clear(); - self.less_computer_turn_start = 0; - self.less_computer_session = None; - self.less_computer_running = false; - self.pending_approval = None; - self.qa_state = None; - self.qa_visible = false; - self.selection = None; - self.selection_draft.clear(); - self.selection_preview_visible = false; + + ui.separator(); + ui.heading(tr_l10n(lang, "head.correction_rules")); + ui.horizontal(|ui| { + ui.text_edit_singleline(&mut self.correction_pattern); + ui.label("→"); + ui.text_edit_singleline(&mut self.correction_replacement); + if ui.button(tr_l10n(lang, "btn.add_rule")).clicked() + && !self.correction_pattern.trim().is_empty() + && !self.correction_replacement.trim().is_empty() + { + if let Some(backend) = self.backend() { + let pattern = std::mem::take(&mut self.correction_pattern); + let replacement = std::mem::take(&mut self.correction_replacement); + self.spawn(async move { + backend.add_correction_rule(pattern, replacement)?; + Ok(tr_l10n(lang, "status.correction_saved").to_string()) + }); } - self.snapshot = Some(snapshot); - for event in replay.events { - self.apply_event(event); + } + }); + let mut correction_action = None; + for rule in &self.correction_rules { + ui.horizontal(|ui| { + let mut enabled = rule.enabled; + if ui.checkbox(&mut enabled, "").changed() { + correction_action = Some((rule.id.clone(), Some(enabled))); } - self.status = if replay.truncated { - format!("事件积压 {dropped} 条,已重置派生界面并重放可用事件") + ui.label(format!("{} → {}", rule.pattern, rule.replacement)); + ui.label(format!("{:?}", rule.source)); + if ui.small_button(tr_l10n(lang, "btn.delete")).clicked() { + correction_action = Some((rule.id.clone(), None)); + } + }); + } + if let (Some(backend), Some((id, enabled))) = (self.backend(), correction_action) { + self.spawn(async move { + if let Some(enabled) = enabled { + backend.set_correction_rule_enabled(&id, enabled)?; } else { - format!("事件积压 {dropped} 条,已从 Core 重放补齐") - }; - } + backend.remove_correction_rule(&id)?; + } + Ok(tr_l10n(lang, "status.correction_updated").to_string()) + }); } - while let Ok(result) = self.rx.try_recv() { - match result { - UiResult::Environment(environment) => { - self.environment = Some(environment); - self.environment_refreshing = false; + } + + fn styles_ui(&mut self, ui: &mut egui::Ui) { + let lang = self.lang; + ui.label(tr_l10n(lang, "lbl.style_note")); + ui.group(|ui| { + ui.strong(tr_l10n(lang, "lbl.direct_hotkey")); + let previous_id = self.style_hotkey_pack_id.clone(); + egui::ComboBox::from_id_salt("style-hotkey-pack") + .selected_text( + self.style_packs + .iter() + .find(|pack| pack.id == self.style_hotkey_pack_id) + .map(|pack| pack.name.as_str()) + .unwrap_or_else(|| tr_l10n(lang, "lbl.choose_style")), + ) + .show_ui(ui, |ui| { + for pack in &self.style_packs { + ui.selectable_value( + &mut self.style_hotkey_pack_id, + pack.id.clone(), + &pack.name, + ); + } + }); + if previous_id != self.style_hotkey_pack_id { + let binding = self.preferences.as_ref().and_then(|preferences| { + preferences + .style_pack_hotkeys + .iter() + .find(|hotkey| hotkey.pack_id == self.style_hotkey_pack_id) + .map(|hotkey| hotkey.binding.clone()) + }); + self.style_hotkey_primary = binding + .as_ref() + .map(|binding| binding.primary.clone()) + .unwrap_or_default(); + self.style_hotkey_modifiers = binding + .map(|binding| binding.modifiers.join("+")) + .unwrap_or_default(); + } + ui.horizontal(|ui| { + ui.label(tr_l10n(lang, "lbl.primary")); + ui.text_edit_singleline(&mut self.style_hotkey_primary); + ui.label(tr_l10n(lang, "lbl.modifiers")); + ui.text_edit_singleline(&mut self.style_hotkey_modifiers); + }); + let save = ui + .add_enabled( + !self.style_hotkey_pack_id.is_empty() + && !self.style_hotkey_primary.trim().is_empty(), + egui::Button::new(tr_l10n(lang, "btn.save_direct_hotkey")), + ) + .clicked(); + let remove = ui + .add_enabled( + !self.style_hotkey_pack_id.is_empty(), + egui::Button::new(tr_l10n(lang, "btn.remove_direct_hotkey")), + ) + .clicked(); + if save || remove { + if let (Some(native), Some(mut preferences), Some(snapshot)) = + (&self.native, self.preferences.clone(), &self.snapshot) + { + let pack_id = self.style_hotkey_pack_id.clone(); + let desired = save.then(|| openless_core::shared_types::ShortcutBinding { + primary: self.style_hotkey_primary.trim().to_string(), + modifiers: self + .style_hotkey_modifiers + .split('+') + .map(str::trim) + .filter(|modifier| !modifier.is_empty()) + .map(ToOwned::to_owned) + .collect(), + }); + set_style_pack_hotkey(&mut preferences, &pack_id, desired.clone()); + let host = native.host_arc(); + let revision = snapshot.preferences_revision; + self.spawn(async move { + tokio::task::spawn_blocking(move || { + match host.update_settings_strict(preferences, revision) { + Err(error) + if error.code == openless_core::BackendErrorCode::Busy => + { + let mut latest = host.backend().get_preferences(); + set_style_pack_hotkey(&mut latest, &pack_id, desired); + let revision = host.snapshot().preferences_revision; + host.update_settings_strict(latest, revision) + } + result => result, + } + }) + .await + .map_err(|error| { + BackendError::new( + openless_core::BackendErrorCode::Internal, + error.to_string(), + ) + })??; + Ok(tr_l10n(lang, "status.style_hotkey_saved").to_string()) + }); } - UiResult::Message(message) => self.status = message, - UiResult::Models(Ok(models)) => self.models = ModelsState::Loaded(models), - UiResult::Models(Err(error)) => { - self.models = ModelsState::Failed(error.clone()); - self.status = error; + } + }); + ui.horizontal(|ui| { + if ui.button(tr_l10n(lang, "btn.new_style")).clicked() { + self.style_editor = Some(openless_core::StylePack { + id: uuid::Uuid::new_v4().to_string(), + name: tr_l10n(lang, "lbl.new_style_default").to_string(), + ..Default::default() + }); + } + if ui.button(tr_l10n(lang, "btn.import_zip")).clicked() { + if let Some(backend) = self.backend() { + self.spawn(async move { + let path = tokio::task::spawn_blocking(|| { + rfd::FileDialog::new() + .add_filter("OpenLess style pack", &["zip"]) + .pick_file() + }) + .await + .map_err(|error| { + BackendError::new( + openless_core::BackendErrorCode::Internal, + error.to_string(), + ) + })? + .ok_or_else(|| { + BackendError::new( + openless_core::BackendErrorCode::Cancelled, + tr_l10n(lang, "dialog.style_import_cancelled"), + ) + })?; + let pack = tokio::task::spawn_blocking(move || { + backend.import_style_pack_path(&path) + }) + .await + .map_err(|error| { + BackendError::new( + openless_core::BackendErrorCode::Internal, + error.to_string(), + ) + })??; + Ok(fmt_l10n(lang, "status.style_imported", &[&pack.name])) + }); } - UiResult::Remote(Ok(remote)) => { - self.remote_error = None; - self.remote_access = Some(remote); + } + }); + if let Some(editor) = self.style_editor.as_mut() { + ui.group(|ui| { + ui.heading(tr_l10n(lang, "head.style_pack_editor")); + ui.horizontal(|ui| { + ui.label(tr_l10n(lang, "lbl.name")); + ui.text_edit_singleline(&mut editor.name); + ui.label(tr_l10n(lang, "lbl.version")); + ui.text_edit_singleline(&mut editor.version); + }); + ui.label(tr_l10n(lang, "lbl.description")); + ui.text_edit_multiline(&mut editor.description); + egui::ComboBox::from_label(tr_l10n(lang, "lbl.base_mode")) + .selected_text(editor.base_mode.display_name()) + .show_ui(ui, |ui| { + for mode in [ + openless_core::PolishMode::Raw, + openless_core::PolishMode::Light, + openless_core::PolishMode::Structured, + openless_core::PolishMode::Formal, + ] { + ui.selectable_value( + &mut editor.base_mode, + mode, + mode.display_name(), + ); + } + }); + ui.label(tr_l10n(lang, "lbl.dictation_prompt")); + ui.add(egui::TextEdit::multiline(&mut editor.prompt).desired_rows(6)); + ui.label(tr_l10n(lang, "lbl.selection_prompt")); + ui.add(egui::TextEdit::multiline(&mut editor.selection_prompt).desired_rows(4)); + }); + let mut save = false; + let mut cancel = false; + ui.horizontal(|ui| { + save = ui.button(tr_l10n(lang, "btn.save_style")).clicked(); + cancel = ui.button(tr_l10n(lang, "btn.cancel_edit")).clicked(); + }); + if cancel { + self.style_editor = None; + } else if save { + let pack = self.style_editor.take().expect("editor exists"); + if let Some(backend) = self.backend() { + let exists = self.style_packs.iter().any(|item| item.id == pack.id); + self.spawn(async move { + let saved = if exists { + backend.update_style_pack(pack)? + } else { + backend.create_style_pack(pack)? + }; + Ok(fmt_l10n(lang, "status.style_saved", &[&saved.name])) + }); } - UiResult::Remote(Err(error)) => { - self.remote_access = None; - self.remote_error = Some(error.clone()); - self.status = error; + } + ui.separator(); + } + let mut action: Option<(String, &'static str, bool)> = None; + for pack in self.style_packs.clone() { + egui::Frame::group(ui.style()).show(ui, |ui| { + ui.horizontal(|ui| { + ui.heading(&pack.name); + if pack.active { + ui.label( + egui::RichText::new(tr_l10n(lang, "lbl.current")) + .color(theme::BLUE), + ); + } + ui.label(format!("{:?} · {:?}", pack.kind, pack.base_mode)); + }); + ui.label(&pack.description); + if let Some(author) = &pack.author { + ui.label(fmt_l10n( + lang, + "lbl.author_version", + &[author, &pack.version], + )); } - UiResult::Providers(Ok(panel)) => { - if panel.kind != self.provider_kind { - continue; + ui.horizontal(|ui| { + if !pack.active && ui.button(tr_l10n(lang, "btn.set_current")).clicked() { + action = Some((pack.id.clone(), "activate", true)); } - if !panel.descriptors.iter().any(|descriptor| { - descriptor.provider_type.as_str() == self.new_provider_type - }) { - self.new_provider_type = panel - .descriptors - .first() - .map(|descriptor| descriptor.provider_type.as_str().to_string()) - .unwrap_or_default(); + let mut enabled = pack.enabled; + if ui + .checkbox(&mut enabled, tr_l10n(lang, "btn.enable_label")) + .changed() + { + action = Some((pack.id.clone(), "enabled", enabled)); } - let selected = self - .selected_channel_id - .as_ref() - .filter(|id| panel.channels.iter().any(|channel| &channel.id == *id)) - .cloned() - .or_else(|| { - panel - .channels - .iter() - .find(|channel| channel.id == panel.active_provider) - .map(|channel| channel.id.clone()) + if ui.button(tr_l10n(lang, "btn.preview_runtime")).clicked() { + if let Some(backend) = self.backend() { + let diagnostics = backend.preview_style_pack_runtime(&pack); + self.status = fmt_l10n( + lang, + "status.style_preview", + &[ + &diagnostics.pack_name, + &diagnostics.single_turn_prompt_chars, + &diagnostics.multi_turn_prompt_chars, + &diagnostics.hotwords.len(), + ], + ); + } + } + if pack.kind == openless_core::StylePackKind::Imported + && ui.button(tr_l10n(lang, "btn.edit")).clicked() + { + self.style_editor = Some(pack.clone()); + } + if pack.kind == openless_core::StylePackKind::Imported + && ui.button(tr_l10n(lang, "btn.delete")).clicked() + { + action = Some((pack.id.clone(), "delete", false)); + } + if pack.kind == openless_core::StylePackKind::Builtin + && ui.button(tr_l10n(lang, "btn.reset_builtin")).clicked() + { + action = Some((pack.id.clone(), "reset", false)); + } + if ui.button(tr_l10n(lang, "btn.export_zip")).clicked() { + action = Some((pack.id.clone(), "export", false)); + } + }); + }); + ui.add_space(8.0); + } + if let (Some(backend), Some((id, operation, value))) = (self.backend(), action) { + self.spawn(async move { + match operation { + "activate" => { + backend.activate_style_pack(&id)?; + } + "enabled" => { + backend.set_style_pack_enabled(&id, value)?; + } + "delete" => { + backend.remove_style_pack(&id)?; + } + "reset" => { + backend.reset_builtin_style_pack(&id)?; + } + "export" => { + let bytes = backend.export_style_pack_bytes(&id)?; + let destination = tokio::task::spawn_blocking(move || { + rfd::FileDialog::new() + .add_filter("OpenLess style pack", &["zip"]) + .set_file_name(format!("openless-style-{id}.zip")) + .save_file() }) - .or_else(|| panel.channels.first().map(|channel| channel.id.clone())); - self.selected_channel_id = selected.clone(); - if self.pending_channel_delete.as_ref().is_some_and(|id| { - !panel.channels.iter().any(|channel| &channel.id == id) - }) { - self.pending_channel_delete = None; + .await + .map_err(|error| { + BackendError::new( + openless_core::BackendErrorCode::Internal, + error.to_string(), + ) + })? + .ok_or_else(|| { + BackendError::new( + openless_core::BackendErrorCode::Cancelled, + tr_l10n(lang, "dialog.style_export_cancelled"), + ) + })?; + tokio::task::spawn_blocking(move || { + openless_linux_egui::atomic_save(&destination, &bytes) + }) + .await + .map_err(|error| { + BackendError::new( + openless_core::BackendErrorCode::Internal, + error.to_string(), + ) + })? + .map_err(|error| { + BackendError::new( + openless_core::BackendErrorCode::Platform, + error.to_string(), + ) + })?; } - self.providers = ProvidersState::Loaded(panel.clone()); - self.provider_models.clear(); - if let Some(channel_id) = selected { - if let Some((channel, descriptor)) = - provider_channel_descriptor(&panel, &channel_id) - { - self.provider_editor = ProviderEditorState::Loading { - kind: panel.kind, - channel_id, - }; - self.load_provider_editor(panel.kind, channel, descriptor); + _ => unreachable!(), + } + Ok(tr_l10n(lang, "status.style_updated").to_string()) + }); + } + } + + fn marketplace_ui(&mut self, ui: &mut egui::Ui) { + let lang = self.lang; + ui.horizontal(|ui| { + ui.text_edit_singleline(&mut self.marketplace_query); + if ui.button(tr_l10n(lang, "btn.search_refresh")).clicked() { + self.load_marketplace(); + } + if ui.button(tr_l10n(lang, "btn.github_login")).clicked() { + if let Some(backend) = self.backend() { + let tx = self.tx.clone(); + self.tokio.spawn(async move { + let result = backend + .services() + .marketplace + .start_device_flow() + .await + .map_err(|error| error.to_string()); + let _ = tx.send(UiResult::MarketplaceFlow(result)); + }); + } + } + if ui.button(tr_l10n(lang, "btn.logout")).clicked() { + if let Some(backend) = self.backend() { + self.spawn(async move { + backend.services().marketplace.logout().await?; + Ok(tr_l10n(lang, "status.logout_done").to_string()) + }); + } + } + if ui.button(tr_l10n(lang, "btn.my_publish_like")).clicked() { + self.load_marketplace_mine(); + } + }); + if let Some(flow) = self.marketplace_flow.clone() { + ui.horizontal(|ui| { + ui.label(fmt_l10n(lang, "lbl.device_code", &[&flow.user_code])); + if ui.button(tr_l10n(lang, "btn.open_github")).clicked() { + let url = flow.verification_uri.clone(); + std::thread::spawn(move || { + if let Err(error) = open_external(&url) { + eprintln!("OpenLess GitHub login URL failed: {error}"); } - } else { - self.provider_editor = ProviderEditorState::Idle; + }); + } + if ui.button(tr_l10n(lang, "btn.check_auth")).clicked() { + if let Some(backend) = self.backend() { + let tx = self.tx.clone(); + let flow_id = flow.flow_id.clone(); + self.tokio.spawn(async move { + let result = backend + .services() + .marketplace + .poll_device_flow(flow_id) + .await + .map_err(|error| error.to_string()); + let _ = tx.send(UiResult::MarketplaceAuthPoll(result)); + }); } } - UiResult::Providers(Err(error)) => { - self.providers = ProvidersState::Failed(error.clone()); - self.status = error; + }); + } + if self.marketplace_items.is_empty() { + ui.label(tr_l10n(lang, "marketplace.not_loaded")); + return; + } + if let Some(detail) = &self.marketplace_detail { + ui.group(|ui| { + ui.heading(fmt_l10n( + lang, + "head.marketplace_detail", + &[&detail.summary.name], + )); + ui.label(fmt_l10n(lang, "lbl.status_colon", &[&detail.state])); + ui.label(&detail.prompt); + }); + } + if !self.marketplace_my_packs.is_empty() || !self.marketplace_my_likes.is_empty() { + ui.group(|ui| { + ui.heading(tr_l10n(lang, "head.marketplace_mine")); + ui.label(fmt_l10n( + lang, + "lbl.liked", + &[&self.marketplace_my_likes.len()], + )); + for pack in &self.marketplace_my_packs { + ui.label(format!("{} · {}", pack.summary.name, pack.state)); } - UiResult::ProviderEditor { - kind, - channel_id, - result, - } => { - if kind != self.provider_kind - || self.selected_channel_id.as_deref() != Some(channel_id.as_str()) - { - continue; + }); + } + let mut action: Option<(String, &'static str)> = None; + for pack in &self.marketplace_items { + egui::Frame::group(ui.style()).show(ui, |ui| { + ui.horizontal(|ui| { + ui.heading(&pack.name); + ui.label(format!("@{} · {}", pack.author_login, pack.version)); + }); + ui.label(&pack.description); + ui.label(fmt_l10n( + lang, + "lbl.like_dl", + &[&pack.like_count, &pack.download_count, &pack.base_mode], + )); + ui.horizontal(|ui| { + if ui.button(tr_l10n(lang, "btn.install")).clicked() { + action = Some((pack.id.clone(), "install")); } - match *result { - Ok(editor) => { - // Reads race with channel switching and mutation - // refreshes. Only the still-selected channel may install - // its editor, otherwise late credential data is ignored. - self.provider_editor = - ProviderEditorState::Loaded(Box::new(editor)); - } - Err(error) => { - self.provider_editor = ProviderEditorState::Failed(error.clone()); - self.status = error; - } + if ui.button(tr_l10n(lang, "btn.toggle_like")).clicked() { + action = Some((pack.id.clone(), "like")); } - } - UiResult::ProviderModels { - kind, - channel_id, - result, - } => { - if kind == self.provider_kind - && self.selected_channel_id.as_deref() == Some(channel_id.as_str()) - { - match result { - Ok(models) => { - self.status = format!("已读取 {} 个模型", models.len()); - self.provider_models = models; - } - Err(error) => self.status = error, + if ui.button(tr_l10n(lang, "btn.detail")).clicked() { + action = Some((pack.id.clone(), "detail")); + } + if ui.button(tr_l10n(lang, "btn.download_zip")).clicked() { + action = Some((pack.id.clone(), "download")); + } + }); + }); + ui.add_space(8.0); + } + if let (Some(backend), Some((id, operation))) = (self.backend(), action) { + let tx = self.tx.clone(); + self.tokio.spawn(async move { + match operation { + "install" => { + let result = backend + .services() + .marketplace + .install(id) + .await + .map(|pack| { + fmt_l10n(lang, "status.marketplace_installed", &[&pack.name]) + }) + .map_err(|error| error.to_string()); + let _ = + tx.send(UiResult::Message(result.unwrap_or_else(|error| error))); + } + "like" => { + let result = backend.services().marketplace.toggle_like(id).await; + let message = result + .map(|result| { + fmt_l10n(lang, "status.marketplace_like", &[&result.like_count]) + }) + .unwrap_or_else(|error| error.to_string()); + let _ = tx.send(UiResult::Message(message)); + } + "detail" => { + let result = backend + .services() + .marketplace + .detail(id) + .await + .map_err(|error| error.to_string()); + let _ = tx.send(UiResult::MarketplaceDetail(result)); + } + "download" => { + let result = async { + let bytes = backend + .services() + .marketplace + .download_archive(id.clone()) + .await?; + let destination = tokio::task::spawn_blocking(move || { + rfd::FileDialog::new() + .add_filter("OpenLess style pack", &["zip"]) + .set_file_name(format!("openless-marketplace-{id}.zip")) + .save_file() + }) + .await + .map_err(|error| { + BackendError::new( + openless_core::BackendErrorCode::Internal, + error.to_string(), + ) + })? + .ok_or_else(|| { + BackendError::new( + openless_core::BackendErrorCode::Cancelled, + tr_l10n(lang, "dialog.marketplace_zip_cancelled"), + ) + })?; + tokio::task::spawn_blocking(move || { + openless_linux_egui::atomic_save(&destination, &bytes) + }) + .await + .map_err(|error| { + BackendError::new( + openless_core::BackendErrorCode::Internal, + error.to_string(), + ) + })? + .map_err(|error| { + BackendError::new( + openless_core::BackendErrorCode::Platform, + error.to_string(), + ) + })?; + Ok::<_, BackendError>( + tr_l10n(lang, "status.marketplace_zip_saved").to_string(), + ) } + .await + .unwrap_or_else(|error| error.to_string()); + let _ = tx.send(UiResult::Message(result)); } + _ => unreachable!(), } - UiResult::ProviderMutation(result) => { - match result { - Ok(message) => self.status = message, - Err(error) => self.status = error, - } - self.providers = ProvidersState::Loading; - self.provider_editor = ProviderEditorState::Idle; - self.provider_models.clear(); - self.load_providers(self.provider_kind); + }); + } + ui.separator(); + ui.heading(tr_l10n(lang, "head.publish_local")); + let mut local_action: Option<(String, Option, &'static str)> = None; + for pack in self + .style_packs + .iter() + .filter(|pack| pack.kind == openless_core::StylePackKind::Imported) + { + ui.horizontal(|ui| { + ui.label(&pack.name); + if ui.button(tr_l10n(lang, "btn.upload_update")).clicked() { + local_action = + Some((pack.id.clone(), pack.origin_pack_id.clone(), "upload")); } - } + }); } - if let Some(backend) = self.backend() { - self.snapshot = Some(backend.snapshot()); + for pack in &self.marketplace_my_packs { + ui.horizontal(|ui| { + ui.label(fmt_l10n(lang, "lbl.published", &[&pack.summary.name])); + if ui.button(tr_l10n(lang, "btn.delete_publish")).clicked() { + local_action = Some((pack.summary.id.clone(), None, "delete")); + } + }); + } + if let (Some(backend), Some((id, origin, operation))) = (self.backend(), local_action) { + self.spawn(async move { + match operation { + "upload" => { + let result = backend.services().marketplace.upload(id, origin).await?; + Ok(fmt_l10n( + lang, + "status.marketplace_published", + &[&result.state, &result.message], + )) + } + "delete" => { + backend.services().marketplace.delete(id).await?; + Ok(tr_l10n(lang, "status.marketplace_deleted").to_string()) + } + _ => unreachable!(), + } + }); } } - fn dictation_ui(&mut self, ui: &mut egui::Ui) { - ui.heading("听写"); - let phase = self - .snapshot - .as_ref() - .map(|snapshot| snapshot.dictation.phase) - .unwrap_or(DictationPhase::Idle); + fn history_ui(&mut self, ui: &mut egui::Ui) { + let lang = self.lang; + ui.heading(tr_l10n(lang, "nav.history")); ui.horizontal(|ui| { - if ui - .add_enabled(phase == DictationPhase::Idle, egui::Button::new("开始")) - .clicked() - { + ui.label(tr_l10n(lang, "lbl.search")); + ui.text_edit_singleline(&mut self.history_search); + if ui.button(tr_l10n(lang, "btn.clear_all")).clicked() { if let Some(backend) = self.backend() { - self.transcript.clear(); self.spawn(async move { - backend.start_dictation().await?; - Ok("正在录音".to_string()) + backend.clear_history()?; + Ok(tr_l10n(lang, "status.history_cleared").to_string()) }); } } - if ui - .add_enabled( - phase == DictationPhase::Recording, - egui::Button::new("停止"), - ) - .clicked() - { - if let Some(backend) = self.backend() { - self.spawn(async move { - let result = backend.stop_dictation().await?; - Ok(format!("完成:{} 字", result.polished_text.chars().count())) - }); - } + }); + let Some(backend) = self.backend() else { + return; + }; + match backend.list_history() { + Ok(history) if history.is_empty() => { + ui.label(tr_l10n(lang, "history.empty")); } - if ui - .add_enabled(phase != DictationPhase::Idle, egui::Button::new("取消")) - .clicked() - { - if let Some(backend) = self.backend() { - self.spawn(async move { - backend.cancel_dictation(None).await?; - Ok("听写已取消".to_string()) + Ok(history) => { + let query = self.history_search.trim().to_lowercase(); + let mut action: Option<(String, &'static str, String)> = None; + for item in history + .into_iter() + .rev() + .filter(|item| { + query.is_empty() + || item.final_text.to_lowercase().contains(&query) + || item.raw_transcript.to_lowercase().contains(&query) + }) + .take(100) + { + let delivery = match item.insert_status { + HistoryInsertStatus::Inserted => tr_l10n(lang, "history.inserted"), + HistoryInsertStatus::CopiedFallback => { + tr_l10n(lang, "history.copied_fallback") + } + HistoryInsertStatus::PasteSent => tr_l10n(lang, "history.paste_sent"), + HistoryInsertStatus::Failed => tr_l10n(lang, "history.failed"), + HistoryInsertStatus::NotRequested => { + tr_l10n(lang, "history.not_requested") + } + }; + egui::Frame::group(ui.style()).show(ui, |ui| { + ui.label(format!("{} · {}", item.created_at, delivery)); + ui.label(&item.final_text); + ui.horizontal(|ui| { + if ui.small_button(tr_l10n(lang, "btn.copy")).clicked() { + action = + Some((item.id.clone(), "copy", item.final_text.clone())); + } + if ui.small_button(tr_l10n(lang, "btn.repolish")).clicked() { + action = Some(( + item.id.clone(), + "repolish", + item.raw_transcript.clone(), + )); + } + if item.has_audio_recording == Some(true) { + if ui + .small_button(tr_l10n(lang, "btn.play_recording")) + .clicked() + { + action = Some((item.id.clone(), "play", String::new())); + } + if ui + .small_button(tr_l10n(lang, "btn.export_recording")) + .clicked() + { + action = Some((item.id.clone(), "export", String::new())); + } + if ui.small_button(tr_l10n(lang, "btn.retranscribe")).clicked() + { + action = + Some((item.id.clone(), "retranscribe", String::new())); + } + } + if ui.small_button(tr_l10n(lang, "btn.delete")).clicked() { + action = Some((item.id.clone(), "delete", String::new())); + } + }); }); + ui.add_space(6.0); + } + if let Some((id, operation, text)) = action { + match operation { + "copy" => match fcitx5_copy_to_clipboard(&text) { + Ok(()) => { + self.status = tr_l10n(lang, "status.history_copied").to_string() + } + Err(error) => { + self.status = fmt_l10n(lang, "status.copy_failed", &[&error]) + } + }, + "repolish" => { + let service = Arc::clone(&backend.services().auxiliary); + self.spawn(async move { + let polished = service + .repolish(openless_core::RepolishRequest { + raw_text: text, + style_pack_id: None, + front_app: None, + }) + .await?; + Ok(fmt_l10n(lang, "status.repolish_done", &[&polished])) + }); + } + "delete" => self.spawn(async move { + backend.delete_history(&id)?; + Ok(tr_l10n(lang, "status.history_deleted").to_string()) + }), + "play" => { + let data_dir = backend.config().data_dir.clone(); + self.spawn(async move { + let path = openless_linux_egui::recording_path(&data_dir, &id) + .map_err(|error| { + BackendError::new( + openless_core::BackendErrorCode::Persistence, + error.to_string(), + ) + })?; + tokio::task::spawn_blocking(move || { + openless_linux_egui::open_local_file(&path) + }) + .await + .map_err(|error| { + BackendError::new( + openless_core::BackendErrorCode::Internal, + error.to_string(), + ) + })? + .map_err(|error| { + BackendError::new( + openless_core::BackendErrorCode::Platform, + error.to_string(), + ) + })?; + Ok(tr_l10n(lang, "status.opened_player").to_string()) + }); + } + "export" => { + let data_dir = backend.config().data_dir.clone(); + self.spawn(async move { + let file_name = format!("openless-recording-{id}.wav"); + let destination = tokio::task::spawn_blocking(move || { + rfd::FileDialog::new() + .add_filter("WAV audio", &["wav"]) + .set_file_name(file_name) + .save_file() + }) + .await + .map_err(|error| { + BackendError::new( + openless_core::BackendErrorCode::Internal, + error.to_string(), + ) + })? + .ok_or_else(|| { + BackendError::new( + openless_core::BackendErrorCode::Cancelled, + tr_l10n(lang, "dialog.recording_export_cancelled"), + ) + })?; + let wav = tokio::task::spawn_blocking(move || { + openless_linux_egui::read_recording_wav(&data_dir, &id) + }) + .await + .map_err(|error| { + BackendError::new( + openless_core::BackendErrorCode::Internal, + error.to_string(), + ) + })? + .map_err(|error| { + BackendError::new( + openless_core::BackendErrorCode::Persistence, + error.to_string(), + ) + })?; + let saved = tokio::task::spawn_blocking(move || { + openless_linux_egui::atomic_save(&destination, &wav) + }) + .await + .map_err(|error| { + BackendError::new( + openless_core::BackendErrorCode::Internal, + error.to_string(), + ) + })? + .map_err(|error| { + BackendError::new( + openless_core::BackendErrorCode::Platform, + error.to_string(), + ) + })?; + Ok(fmt_l10n( + lang, + "status.recording_exported", + &[&saved.display()], + )) + }); + } + "retranscribe" => { + let data_dir = backend.config().data_dir.clone(); + self.spawn(async move { + let recording_id = id.clone(); + let wav = tokio::task::spawn_blocking(move || { + openless_linux_egui::read_recording_wav( + &data_dir, + &recording_id, + ) + }) + .await + .map_err(|error| { + BackendError::new( + openless_core::BackendErrorCode::Internal, + error.to_string(), + ) + })? + .map_err(|error| { + BackendError::new( + openless_core::BackendErrorCode::Persistence, + error.to_string(), + ) + })?; + let pcm = openless_linux_egui::recording_pcm(&wav) + .map_err(|error| { + BackendError::new( + openless_core::BackendErrorCode::Persistence, + error.to_string(), + ) + })? + .to_vec(); + let started = std::time::Instant::now(); + let result = backend + .services() + .auxiliary + .retranscribe_pcm(pcm) + .await + .map_err(|failure| failure.error)?; + let entry = backend.apply_history_retranscription( + &id, + result.text, + &result.asr, + started.elapsed().as_millis() as u64, + )?; + Ok(fmt_l10n(lang, "status.retranscribed", &[&entry.final_text])) + }); + } + _ => unreachable!(), + } } } - }); - ui.label(if self.transcript.is_empty() { - "尚无转写结果" - } else { - &self.transcript - }); - } - - fn less_computer_ui(&mut self, ui: &mut egui::Ui) { - ui.heading("Less Computer"); - ui.text_edit_multiline(&mut self.less_computer_input); - ui.horizontal(|ui| { - if ui.button("运行").clicked() && !self.less_computer_input.trim().is_empty() { - if let Some(backend) = self.backend() { - let prompt = self.less_computer_input.clone(); - self.less_computer_output.clear(); - self.spawn(async move { - backend.submit_less_computer(prompt).await?; - Ok("Less Computer 已完成".to_string()) - }); - } + Err(error) => { + ui.label(error.to_string()); + } + } + } + + // ── Frontend bridge ───────────────────────────────────────────────── + + /// Sync backend state into the frontend view model each frame before + /// rendering. Only fields that have real data sources are populated; + /// unwired fields remain in their default empty / loading state. + fn sync_view_model(&mut self) { + // Capture overview error before taking a mutable borrow on frontend_vm. + let overview_err = self.overview_error(); + let backend = self.backend(); + let lang = self.lang; + + let vm = &mut self.frontend_vm; + + // Map shell::Page to frontend::Page. + vm.active_page = match self.active_page { + shell::Page::Overview => frontend::view_model::Page::Overview, + shell::Page::History => frontend::view_model::Page::History, + shell::Page::Vocabulary => frontend::view_model::Page::Vocab, + shell::Page::Styles => frontend::view_model::Page::Style, + shell::Page::Marketplace => frontend::view_model::Page::Marketplace, + shell::Page::Providers => frontend::view_model::Page::Settings, + shell::Page::Assistant => frontend::view_model::Page::SelectionAsk, + }; + + vm.status = self.status.clone(); + vm.version = env!("CARGO_PKG_VERSION").to_string(); + + // Overview: wire real data when available. + if let Some(summary) = self.overview.summary(chrono::Local::now().date_naive()) { + vm.overview_loading = false; + vm.overview_error = None; + vm.overview = Some(frontend::view_model::OverviewSummary { + asr_provider: summary.asr_provider, + llm_provider: summary.llm_provider, + asr_configured: summary.asr_configured, + llm_configured: summary.llm_configured, + chars_today: summary.chars_today, + segments_today: summary.segments_today, + duration_ms_today: summary.duration_ms_today, + avg_latency_ms: summary.avg_latency_ms, + history_total: summary.history_total, + recent: summary + .recent + .into_iter() + .map(|entry| frontend::view_model::OverviewRecentEntry { + created_at: entry.created_at, + final_text: entry.final_text, + duration_ms: entry.duration_ms, + }) + .collect(), + last_7_segments: summary.last_7.segments, + last_30_segments: summary.last_30.segments, + heatmap_weeks: summary.heatmap_weeks, + heatmap_days: summary.heatmap_days, + activity_days_total: summary.activity_days_total, + }); + } else if let Some(error) = overview_err { + vm.overview_loading = false; + vm.overview_error = Some(error); + vm.overview = None; + } else { + vm.overview_loading = true; + vm.overview_error = None; + vm.overview = None; + } + + // Settings: populate from preferences. + if let Some(prefs) = &self.preferences { + let s = &mut vm.settings; + s.streaming_insert = prefs.streaming_insert; + s.start_minimized = prefs.start_minimized; + s.auto_update = prefs.auto_update_check; + s.remote_input = prefs.remote_input_enabled; + s.remote_port = prefs.remote_input_port.to_string(); + s.activity_heatmap = prefs.show_overview_activity_heatmap; + s.theme = match prefs.theme_mode { + openless_core::shared_types::ThemeMode::System => 0, + openless_core::shared_types::ThemeMode::Light => 1, + openless_core::shared_types::ThemeMode::Dark => 2, + }; + s.recording_enabled = true; + s.realtime_mode = matches!( + prefs.hotkey.mode, + openless_core::shared_types::HotkeyMode::Hold + ); + s.restore_clipboard = true; + s.remember_history = true; + // Linux: no local model support. + s.local_model = false; + s.selection_voice = false; + } + + // History: wire from Core when backend is available. + if let Some(backend) = backend { + if let Ok(history) = backend.list_history() { + vm.history_entries = history + .into_iter() + .rev() + .map(|item| frontend::view_model::HistoryEntry { + time: item.id.clone(), + text: item.final_text, + duration: item + .duration_ms + .map(|d| format_duration(d, lang)) + .unwrap_or_default(), + tag: match item.insert_status { + HistoryInsertStatus::Inserted => "已插入", + HistoryInsertStatus::CopiedFallback => "已复制", + HistoryInsertStatus::PasteSent => "已发送", + HistoryInsertStatus::Failed => "失败", + HistoryInsertStatus::NotRequested => "未请求", + } + .to_string(), + }) + .collect(); + } + } + + // Vocabulary: wire from existing data. + if !self.vocabulary.is_empty() { + vm.vocab_unsupported = false; + vm.vocab_entries = self + .vocabulary + .iter() + .map(|entry| frontend::view_model::VocabEntry { + phrase: entry.phrase.clone(), + hits: entry.hits as usize, + enabled: entry.enabled, + learned: false, + }) + .collect(); + } + + // Correction rules: wire from existing data. + if !self.correction_rules.is_empty() { + vm.vocab_unsupported = false; + vm.vocab_rules = self + .correction_rules + .iter() + .map(|rule| frontend::view_model::CorrectionRule { + pattern: rule.pattern.clone(), + replacement: rule.replacement.clone(), + enabled: rule.enabled, + learned: false, + }) + .collect(); + } + vm.vocab_saved_presets = self + .vocab_presets + .iter() + .map(|preset| frontend::view_model::SavedVocabPreset { + name: preset.name.clone(), + phrases: preset.phrases.join("、"), + }) + .collect(); + + // Style packs: wire from existing data. + if !self.style_packs.is_empty() { + vm.style_unsupported = false; + vm.style_packs = self + .style_packs + .iter() + .map(|pack| frontend::view_model::StylePack { + name: pack.name.clone(), + description: pack.description.clone(), + tags: vec![pack.base_mode.display_name().to_string()], + accent: theme::BLUE, + is_builtin: pack.kind == openless_core::StylePackKind::Builtin, + is_active: pack.active, + }) + .collect(); + } + + // Marketplace: wire from Core data when available. + if !self.marketplace_items.is_empty() { + vm.marketplace_loading = false; + vm.marketplace_unsupported = false; + vm.marketplace_packs = self + .marketplace_items + .iter() + .map(|item| frontend::view_model::MarketplacePack { + name: item.name.clone(), + version: item.version.clone(), + description: item.description.clone(), + mode: item.base_mode.clone(), + author: item.author_login.clone(), + tags: vec![item.base_mode.clone()], + likes: item.like_count as u32, + downloads: item.download_count as u32, + is_new: false, + }) + .collect(); + } + + // Startup error. + if let Some(error) = &self.startup_error { + vm.status = format!("启动失败: {error}"); + } + } + + /// Returns the overview error string if the overview is in a failed state. + fn overview_error(&self) -> Option { + match &self.overview { + crate::linux_app::OverviewState::Failed(error) => Some(error.clone()), + _ => None, + } + } + + /// Apply a settings toggle from the frontend to the live preferences. + fn apply_settings_toggle(&mut self, field: frontend::view_model::SettingsField) { + let Some(preferences) = self.preferences.as_mut() else { + return; + }; + match field { + frontend::view_model::SettingsField::StreamingInsert => { + preferences.streaming_insert = !preferences.streaming_insert; + self.settings_dirty.streaming_insert = true; + } + frontend::view_model::SettingsField::StartMinimized => { + preferences.start_minimized = !preferences.start_minimized; + self.settings_dirty.start_minimized = true; + } + frontend::view_model::SettingsField::AutoUpdate => { + preferences.auto_update_check = !preferences.auto_update_check; + self.settings_dirty.auto_update_check = true; + } + frontend::view_model::SettingsField::RemoteInput => { + preferences.remote_input_enabled = !preferences.remote_input_enabled; + self.settings_dirty.remote_input_enabled = true; + } + frontend::view_model::SettingsField::ActivityHeatmap => { + preferences.show_overview_activity_heatmap = + !preferences.show_overview_activity_heatmap; + self.settings_dirty.appearance = true; + } + frontend::view_model::SettingsField::RealtimeMode => { + preferences.hotkey.mode = match preferences.hotkey.mode { + openless_core::shared_types::HotkeyMode::Hold => { + openless_core::shared_types::HotkeyMode::Toggle + } + _ => openless_core::shared_types::HotkeyMode::Hold, + }; + self.settings_dirty.recording = true; + } + frontend::view_model::SettingsField::RecordingEnabled => { + self.settings_dirty.recording = true; + // Toggle recording enabled state — no-op on preferences directly, + // but marks dirty so save will apply. + } + frontend::view_model::SettingsField::RestoreClipboard + | frontend::view_model::SettingsField::StackedLayout + | frontend::view_model::SettingsField::ConservativeLayout + | frontend::view_model::SettingsField::SystemProxy + | frontend::view_model::SettingsField::RememberHistory + | frontend::view_model::SettingsField::RecordAudio + | frontend::view_model::SettingsField::LessComputer + | frontend::view_model::SettingsField::Multimodal + | frontend::view_model::SettingsField::BetaChannel => { + self.frontend_vm.settings_notice = + Some(tr_l10n(self.lang, "settings.unsupported_linux").to_string()); + } + frontend::view_model::SettingsField::SelectionAssistant => { + self.frontend_vm.settings_notice = + Some(tr_l10n(self.lang, "settings.unsupported_linux").to_string()); } - if ui.button("取消").clicked() { - if let Some(backend) = self.backend() { - self.spawn(async move { - backend.cancel_less_computer(None).await?; - Ok("Less Computer 已取消".to_string()) - }); - } + frontend::view_model::SettingsField::SelectionVoice => { + self.frontend_vm.settings_notice = + Some(tr_l10n(self.lang, "settings.unsupported_linux").to_string()); } - }); - ui.label(if self.less_computer_output.is_empty() { - "尚无 Agent 输出" - } else { - &self.less_computer_output - }); + frontend::view_model::SettingsField::LocalModel => { + // Linux does not support local model inference. + self.frontend_vm.settings_notice = + Some(tr_l10n(self.lang, "settings.unsupported_linux").to_string()); + } + frontend::view_model::SettingsField::MarketplaceEnabled => { + self.frontend_vm.marketplace_unsupported = + !self.frontend_vm.marketplace_unsupported; + } + } + self.save_settings_if_dirty(); } - fn agent_approval_ui(&mut self, ui: &mut egui::Ui) { - if let Some((token, command)) = self.pending_approval.clone() { - egui::ScrollArea::vertical() - .id_salt("approval_command") - .max_height(72.0) - .show(ui, |ui| { - ui.label(format!("请求执行:{command}")); - }); - ui.horizontal(|ui| { - for (label, approved) in [("允许", true), ("拒绝", false)] { - if ui.button(label).clicked() { - if let Some(backend) = self.backend() { - let token = token.clone(); - self.pending_approval = None; - self.spawn(async move { - backend - .services() - .less_computer - .approve(token, approved) - .await?; - Ok("审批已提交".to_string()) - }); - } - } - } - }); + /// Apply a settings combo change from the frontend. + fn apply_settings_combo( + &mut self, + field: frontend::view_model::SettingsComboField, + index: usize, + ) { + let Some(preferences) = self.preferences.as_mut() else { + return; + }; + match field { + frontend::view_model::SettingsComboField::Theme => { + preferences.theme_mode = match index { + 0 => openless_core::shared_types::ThemeMode::System, + 1 => openless_core::shared_types::ThemeMode::Light, + 2 => openless_core::shared_types::ThemeMode::Dark, + _ => return, + }; + self.settings_dirty.appearance = true; + self.frontend_vm.settings.theme = index; + } + frontend::view_model::SettingsComboField::Language => { + let pref = match index { + 0 => LocalePref::System, + 1 => LocalePref::Lang(Lang::ZhCn), + 2 => LocalePref::Lang(Lang::ZhTw), + 3 => LocalePref::Lang(Lang::En), + 4 => LocalePref::Lang(Lang::Ja), + 5 => LocalePref::Lang(Lang::Ko), + _ => return, + }; + self.apply_locale_pref(pref); + self.frontend_vm.settings.language = index; + } + frontend::view_model::SettingsComboField::Provider + | frontend::view_model::SettingsComboField::Retention + | frontend::view_model::SettingsComboField::Microphone + | frontend::view_model::SettingsComboField::RecordingMode => { + self.frontend_vm.settings_notice = + Some(tr_l10n(self.lang, "settings.unsupported_linux").to_string()); + } } + self.save_settings_if_dirty(); } - fn qa_ui(&mut self, ui: &mut egui::Ui) { - ui.heading("问答"); - if !self.qa_visible { - ui.label("打开问答后可文字提问或语音提问。切换页面会保留当前会话;关闭会话使用下方的关闭操作。"); - if ui.button("打开问答").clicked() { - if let Some(backend) = self.backend() { - self.spawn(async move { - backend.services().qa.show().await?; - Ok("问答已打开".to_string()) - }); - } - } + /// Apply a settings text field change from the frontend. + fn apply_settings_text( + &mut self, + field: frontend::view_model::SettingsTextField, + text: String, + ) { + let Some(preferences) = self.preferences.as_mut() else { return; - } - if let Some(state) = &self.qa_state { - if let Some(messages) = &state.messages { - for message in messages { - ui.label(format!("{}:{}", message.role, message.content)); + }; + match field { + frontend::view_model::SettingsTextField::RemotePort => { + if let Ok(port) = text.parse::() { + preferences.remote_input_port = port; + self.settings_dirty.remote_input_port = true; + self.frontend_vm.settings.remote_port = text; } } - if let Some(chunk) = &state.chunk { - ui.label(chunk); + frontend::view_model::SettingsTextField::ApiKey => { + self.frontend_vm.settings.api_key = text; } - if let Some(error) = &state.error { - ui.colored_label(egui::Color32::RED, error); + frontend::view_model::SettingsTextField::Endpoint => { + self.frontend_vm.settings.endpoint = text; + } + frontend::view_model::SettingsTextField::Model => { + self.frontend_vm.settings.model = text; + } + frontend::view_model::SettingsTextField::ClaudePrompt => { + self.frontend_vm.settings.claude_prompt = text; } } - ui.text_edit_multiline(&mut self.qa_input); - ui.horizontal(|ui| { - let recording = self - .qa_state - .as_ref() - .is_some_and(|state| state.kind == QaStateKind::Recording); - if ui - .button(if recording { - "结束录音" - } else { - "语音提问" - }) - .clicked() - { - if let Some(backend) = self.backend() { - self.spawn(async move { - backend.services().qa.toggle_recording().await?; - Ok("问答录音状态已更新".to_string()) - }); - } + self.save_settings_if_dirty(); + } + + /// Apply a settings action button from the frontend. + fn apply_settings_action(&mut self, field: frontend::view_model::SettingsActionField) { + match field { + frontend::view_model::SettingsActionField::ConnectionTest => { + self.frontend_vm.settings_notice = + Some(tr_l10n(self.lang, "settings.unsupported_linux").to_string()); } - if ui.button("发送").clicked() && !self.qa_input.trim().is_empty() { + frontend::view_model::SettingsActionField::ClearHistory => { if let Some(backend) = self.backend() { - let text = std::mem::take(&mut self.qa_input); + let lang = self.lang; self.spawn(async move { - backend.services().qa.submit_text(text).await?; - Ok("问答已提交".to_string()) + backend.clear_history()?; + Ok(tr_l10n(lang, "status.history_cleared").to_string()) }); } } - if ui.button("关闭").clicked() { + frontend::view_model::SettingsActionField::ExportDiagnostics => { if let Some(backend) = self.backend() { + let source = openless_linux_egui::log_path(&backend.config().data_dir); + let lang = self.lang; self.spawn(async move { - backend.services().qa.dismiss().await?; - Ok("问答已关闭".to_string()) + let destination = tokio::task::spawn_blocking(|| { + rfd::FileDialog::new() + .add_filter("Log", &["log"]) + .set_file_name("openless.log") + .save_file() + }) + .await + .map_err(|error| { + BackendError::new( + openless_core::BackendErrorCode::Internal, + error.to_string(), + ) + })? + .ok_or_else(|| { + BackendError::new( + openless_core::BackendErrorCode::Cancelled, + tr_l10n(lang, "dialog.export_log_cancelled"), + ) + })?; + tokio::task::spawn_blocking(move || { + openless_linux_egui::export_error_log(&source, &destination) + }) + .await + .map_err(|error| { + BackendError::new( + openless_core::BackendErrorCode::Internal, + error.to_string(), + ) + })? + .map_err(|error| { + BackendError::new( + openless_core::BackendErrorCode::Platform, + error.to_string(), + ) + })?; + Ok(tr_l10n(lang, "status.export_log_done").to_string()) }); } } - if ui.button("取消本轮").clicked() { - if let Some(backend) = self.backend() { - let session_id = self - .qa_state - .as_ref() - .and_then(|state| state.session_id.as_deref()) - .and_then(|id| uuid::Uuid::parse_str(id).ok()) - .map(openless_core::SessionId::from_uuid); - self.spawn(async move { - backend.services().qa.cancel(session_id).await?; - Ok("问答本轮已取消".to_string()) - }); + frontend::view_model::SettingsActionField::CheckUpdate => { + let channel = self + .preferences + .as_ref() + .map(|prefs| prefs.update_channel) + .unwrap_or_default(); + self.request_update_check(channel); + } + frontend::view_model::SettingsActionField::OpenGitHub => { + let _ = open_external("https://github.com/earendil-works/openless"); + } + frontend::view_model::SettingsActionField::OpenHelp => { + let _ = open_external("https://github.com/earendil-works/openless"); + } + frontend::view_model::SettingsActionField::OpenReleaseNotes => { + let _ = open_external("https://github.com/earendil-works/openless/releases"); + } + frontend::view_model::SettingsActionField::OpenFeedback => { + let _ = open_external("https://github.com/earendil-works/openless/issues"); + } + frontend::view_model::SettingsActionField::CopyQQ => { + match fcitx5_copy_to_clipboard("1078960553") { + Ok(()) => { + self.frontend_vm.settings_notice = + Some(tr_l10n(self.lang, "status.copied").to_string()); + } + Err(error) => { + self.frontend_vm.settings_notice = Some(format!("复制失败: {error}")); + } } } - }); + frontend::view_model::SettingsActionField::ModelManagement + | frontend::view_model::SettingsActionField::ExtensionManagement + | frontend::view_model::SettingsActionField::Permissions + | frontend::view_model::SettingsActionField::ClaudeDetect + | frontend::view_model::SettingsActionField::ClaudeConsole + | frontend::view_model::SettingsActionField::ClaudeRunTest => { + self.frontend_vm.settings_notice = + Some(tr_l10n(self.lang, "settings.unsupported_linux").to_string()); + } + } } - fn selection_ui(&mut self, ui: &mut egui::Ui) { - ui.heading("选区润色"); - let Some(selection) = self.selection.clone() else { - ui.label("先在目标应用中选中文字,再使用已配置的选区润色快捷键。预览会在此显示,确认前可以编辑或取消。"); - ui.small("此入口是现有 Selection polish;Selection Voice 的完整意图路由尚未接入。"); + /// Persist dirty settings if any fields have been changed. + fn save_settings_if_dirty(&mut self) { + if !self.settings_dirty.any() { return; - }; - ui.label(format!("当前状态:{:?}", selection.phase)); - if self.selection_preview_visible && selection.phase == SelectionPhase::Preview { - ui.strong("选区预览"); - ui.text_edit_multiline(&mut self.selection_draft); - ui.horizontal(|ui| { - if ui.button("确认替换").clicked() { - if let (Some(backend), Some(session_id)) = - (self.backend(), selection.session_id) - { - let text = self.selection_draft.clone(); - self.spawn(async move { - backend - .services() - .selection - .confirm(session_id, Some(text)) - .await?; - Ok("选区替换已确认".to_string()) - }); - } - } - if ui.button("取消").clicked() { - if let (Some(backend), Some(session_id)) = - (self.backend(), selection.session_id) - { - self.spawn(async move { - backend - .services() - .selection - .cancel(Some(session_id)) - .await?; - Ok("选区替换已取消".to_string()) - }); - } - } - }); - } else if selection.phase == SelectionPhase::Completed - && selection.revert_outcome.is_none() + } + if let (Some(native), Some(draft), Some(snapshot)) = + (&self.native, self.preferences.clone(), &self.snapshot) { - ui.horizontal(|ui| { - ui.label("最近一次选区替换已完成"); - if ui.button("撤销").clicked() { - if let (Some(backend), Some(session_id)) = - (self.backend(), selection.session_id) - { - self.spawn(async move { - backend.services().selection.revert(session_id).await?; - Ok("选区替换已撤销".to_string()) - }); + let host = native.host_arc(); + let revision = snapshot.preferences_revision; + let dirty = self.settings_dirty; + let tx = self.tx.clone(); + self.tokio.spawn(async move { + let outcome = tokio::task::spawn_blocking(move || { + let save = |preferences, revision| { + if dirty.hotkeys { + host.update_settings_strict(preferences, revision) + } else { + host.save_settings(preferences, revision) + } + }; + match save(draft.clone(), revision) { + Err(error) if error.code == openless_core::BackendErrorCode::Busy => { + let latest_snapshot = host.snapshot(); + let latest = host.backend().get_preferences(); + save( + dirty.merge(&latest, &draft), + latest_snapshot.preferences_revision, + ) + } + result => result, } - } + }) + .await + .map_err(|error| error.to_string()) + .and_then(|result| result.map_err(|error| error.to_string())); + let _ = tx.send(UiResult::SettingsSaved(Box::new(outcome))); }); } } - fn models_ui(&mut self, ui: &mut egui::Ui) { - ui.horizontal(|ui| { - ui.heading("本地模型"); - if ui.button("刷新").clicked() { - self.models = ModelsState::Loading; - self.load_models(); - } - }); - let models = match self.models.clone() { - ModelsState::Loading => { - ui.label("正在加载模型目录…"); - return; - } - ModelsState::Failed(error) => { - ui.colored_label(egui::Color32::RED, error); - return; - } - ModelsState::Loaded(models) if models.is_empty() => { - ui.label("模型目录未返回任何可用模型"); - return; - } - ModelsState::Loaded(models) => models, - }; - for model in models { - ui.horizontal(|ui| { - ui.label(format!( - "{} · {} · {}", - model.display_name, - model.family, - if model.installed { - "已安装" - } else { - "未安装" + /// Dispatch frontend actions to existing Core / backend methods. + fn apply_frontend_actions( + &mut self, + actions: Vec, + ctx: &egui::Context, + ) { + for action in actions { + match action { + frontend::view_model::FrontendAction::Navigate(page) => { + self.active_page = match page { + frontend::view_model::Page::Overview => shell::Page::Overview, + frontend::view_model::Page::History => shell::Page::History, + frontend::view_model::Page::Vocab => shell::Page::Vocabulary, + frontend::view_model::Page::Style => shell::Page::Styles, + frontend::view_model::Page::Marketplace => shell::Page::Marketplace, + frontend::view_model::Page::SelectionAsk => shell::Page::Assistant, + frontend::view_model::Page::Translation => shell::Page::Assistant, + frontend::view_model::Page::Settings => shell::Page::Providers, + }; + } + frontend::view_model::FrontendAction::ToggleSettings => { + self.frontend_vm.settings_open = !self.frontend_vm.settings_open; + if self.frontend_vm.settings_open { + self.frontend_vm.active_page = frontend::view_model::Page::Settings; } - )); - if !model.installed && ui.button("下载").clicked() { + } + frontend::view_model::FrontendAction::CloseSettings => { + self.frontend_vm.settings_open = false; + self.frontend_vm.active_page = frontend::view_model::Page::Overview; + } + frontend::view_model::FrontendAction::SidebarToggleStyle => { + self.frontend_vm.style_open = !self.frontend_vm.style_open; + } + frontend::view_model::FrontendAction::SidebarToggleTools => { + self.frontend_vm.tools_open = !self.frontend_vm.tools_open; + } + frontend::view_model::FrontendAction::WindowClose => { + ctx.send_viewport_cmd(egui::ViewportCommand::Close); + } + frontend::view_model::FrontendAction::WindowMaximize => { + let maximized = + ctx.input(|input| input.viewport().maximized.unwrap_or(false)); + ctx.send_viewport_cmd(egui::ViewportCommand::Maximized(!maximized)); + } + frontend::view_model::FrontendAction::WindowMinimize => { + ctx.send_viewport_cmd(egui::ViewportCommand::Minimized(true)); + } + frontend::view_model::FrontendAction::MarketplaceRefresh => { + self.load_marketplace(); + } + frontend::view_model::FrontendAction::MarketplaceMyPacks => { + self.load_marketplace_mine(); + } + frontend::view_model::FrontendAction::MarketplaceSearch(query) => { + self.marketplace_query = query; + } + frontend::view_model::FrontendAction::MarketplaceCloseDetail => { + self.frontend_vm.marketplace_selected = None; + } + frontend::view_model::FrontendAction::MarketplaceDetail(index) => { + self.frontend_vm.marketplace_selected = Some(index); + } + frontend::view_model::FrontendAction::MarketplaceInstall(index) => { + if let Some(item) = self.marketplace_items.get(index) { + if let Some(backend) = self.backend() { + let id = item.id.clone(); + let lang = self.lang; + self.spawn(async move { + let pack = backend.services().marketplace.install(id).await?; + Ok(fmt_l10n( + lang, + "status.marketplace_installed", + &[&pack.name], + )) + }); + } + } + } + frontend::view_model::FrontendAction::MarketplaceDownload(index) => { + if let Some(item) = self.marketplace_items.get(index) { + if let Some(backend) = self.backend() { + let id = item.id.clone(); + let lang = self.lang; + let tx = self.tx.clone(); + self.tokio.spawn(async move { + let result = async { + let bytes = backend + .services() + .marketplace + .download_archive(id.clone()) + .await?; + let destination = tokio::task::spawn_blocking(move || { + rfd::FileDialog::new() + .add_filter("OpenLess style pack", &["zip"]) + .set_file_name(format!( + "openless-marketplace-{id}.zip" + )) + .save_file() + }) + .await + .map_err(|error| { + BackendError::new( + openless_core::BackendErrorCode::Internal, + error.to_string(), + ) + })? + .ok_or_else(|| { + BackendError::new( + openless_core::BackendErrorCode::Cancelled, + tr_l10n(lang, "dialog.marketplace_zip_cancelled"), + ) + })?; + tokio::task::spawn_blocking(move || { + openless_linux_egui::atomic_save(&destination, &bytes) + .map_err(|error| { + BackendError::new( + openless_core::BackendErrorCode::Internal, + error.to_string(), + ) + }) + }) + .await + .map_err( + |error| { + BackendError::new( + openless_core::BackendErrorCode::Internal, + error.to_string(), + ) + }, + )??; + Ok::<_, BackendError>( + tr_l10n(lang, "status.marketplace_zip_saved") + .to_string(), + ) + } + .await + .unwrap_or_else(|error| error.to_string()); + let _ = tx.send(UiResult::Message(result)); + }); + } + } + } + frontend::view_model::FrontendAction::MarketplaceToggleLike(index) => { + if let Some(item) = self.marketplace_items.get(index) { + if let Some(backend) = self.backend() { + let id = item.id.clone(); + let lang = self.lang; + self.spawn(async move { + let result = + backend.services().marketplace.toggle_like(id).await?; + Ok(fmt_l10n( + lang, + "status.marketplace_like", + &[&result.like_count], + )) + }); + } + } + } + frontend::view_model::FrontendAction::MarketplaceSort(_sort) => { + // Sort is handled locally in the frontend for now. + } + frontend::view_model::FrontendAction::HistoryClear => { if let Some(backend) = self.backend() { - let target = model.target.clone(); + let lang = self.lang; self.spawn(async move { - backend - .services() - .local_asr - .start_download(target, None) - .await?; - Ok("模型下载完成".to_string()) + backend.clear_history()?; + Ok(tr_l10n(lang, "status.history_cleared").to_string()) }); } } - if model.installed && ui.button("激活").clicked() { + frontend::view_model::FrontendAction::HistoryRefresh => { + self.frontend_vm.history_cleared = false; + } + frontend::view_model::FrontendAction::HistorySearch(query) => { + self.frontend_vm.history_query = query; + } + frontend::view_model::FrontendAction::HistoryFilter(index) => { + self.frontend_vm.history_filter = index; + } + frontend::view_model::FrontendAction::HistorySelect(index) => { + self.frontend_vm.history_selected = index; + } + frontend::view_model::FrontendAction::HistoryTogglePlay => { + self.frontend_vm.history_audio_playing = + !self.frontend_vm.history_audio_playing; + } + frontend::view_model::FrontendAction::HistoryRepolish => { + self.frontend_vm.history_repolished = true; + } + frontend::view_model::FrontendAction::HistoryDelete(index) => { + if let Some(backend) = self.backend() { + if let Some(entry) = self.frontend_vm.history_entries.get(index) { + let id = entry.time.clone(); + let lang = self.lang; + self.spawn(async move { + backend.delete_history(&id)?; + Ok(tr_l10n(lang, "status.history_deleted").to_string()) + }); + } + } + } + frontend::view_model::FrontendAction::HistoryExport(index) => { + if let Some(backend) = self.backend() { + if let Some(entry) = self.frontend_vm.history_entries.get(index) { + let id = entry.time.clone(); + let data_dir = backend.config().data_dir.clone(); + let lang = self.lang; + self.spawn(async move { + let file_name = format!("openless-recording-{id}.wav"); + let destination = tokio::task::spawn_blocking(move || { + rfd::FileDialog::new() + .add_filter("WAV audio", &["wav"]) + .set_file_name(file_name) + .save_file() + }) + .await + .map_err(|error| { + BackendError::new( + openless_core::BackendErrorCode::Internal, + error.to_string(), + ) + })? + .ok_or_else(|| { + BackendError::new( + openless_core::BackendErrorCode::Cancelled, + tr_l10n(lang, "dialog.recording_export_cancelled"), + ) + })?; + let wav = tokio::task::spawn_blocking(move || { + openless_linux_egui::read_recording_wav(&data_dir, &id) + }) + .await + .map_err(|error| { + BackendError::new( + openless_core::BackendErrorCode::Internal, + error.to_string(), + ) + })? + .map_err(|error| { + BackendError::new( + openless_core::BackendErrorCode::Persistence, + error.to_string(), + ) + })?; + let saved = tokio::task::spawn_blocking(move || { + openless_linux_egui::atomic_save(&destination, &wav) + }) + .await + .map_err(|error| { + BackendError::new( + openless_core::BackendErrorCode::Internal, + error.to_string(), + ) + })? + .map_err(|error| { + BackendError::new( + openless_core::BackendErrorCode::Platform, + error.to_string(), + ) + })?; + Ok(fmt_l10n( + lang, + "status.recording_exported", + &[&saved.display()], + )) + }); + } + } + } + frontend::view_model::FrontendAction::HistoryRepolish => { + if let Some(backend) = self.backend() { + if let Some(entry) = self + .frontend_vm + .history_entries + .get(self.frontend_vm.history_selected) + { + let text = entry.text.clone(); + let service = Arc::clone(&backend.services().auxiliary); + let lang = self.lang; + self.spawn(async move { + let polished = service + .repolish(openless_core::RepolishRequest { + raw_text: text, + style_pack_id: None, + front_app: None, + }) + .await?; + Ok(fmt_l10n(lang, "status.repolish_done", &[&polished])) + }); + } + } + } + frontend::view_model::FrontendAction::HistoryTogglePlay => { if let Some(backend) = self.backend() { - let target = model.target.clone(); - self.spawn(async move { - let descriptor = - openless_core::provider_rules::provider_descriptor( - openless_core::ProviderKind::Asr, - "local-qwen3-c", - ) - .ok_or_else(|| { - openless_core::BackendError::new( - openless_core::BackendErrorCode::Unsupported, - "local Qwen provider is unavailable", - ) - })?; - let provider_type = descriptor.provider_type.as_str().to_string(); - let existing = backend - .list_channels(openless_core::ChannelKind::Asr) - .await? - .into_iter() - .find(|channel| channel.provider_type == provider_type) - .map(|channel| channel.id); - let provider_id = match existing { - Some(provider_id) => provider_id, - None => { - backend - .create_channel( - openless_core::ChannelKind::Asr, - provider_type, - descriptor.label_key, + if let Some(entry) = self + .frontend_vm + .history_entries + .get(self.frontend_vm.history_selected) + { + let id = entry.time.clone(); + let data_dir = backend.config().data_dir.clone(); + let lang = self.lang; + self.spawn(async move { + let path = openless_linux_egui::recording_path(&data_dir, &id) + .map_err(|error| { + BackendError::new( + openless_core::BackendErrorCode::Persistence, + error.to_string(), ) - .await? - } - }; - backend - .activate_local_asr(openless_core::LocalAsrActivationRequest { - target, - provider_id, + })?; + tokio::task::spawn_blocking(move || { + openless_linux_egui::open_local_file(&path) }) - .await?; - Ok("本地模型已激活并预加载".to_string()) - }); + .await + .map_err(|error| { + BackendError::new( + openless_core::BackendErrorCode::Internal, + error.to_string(), + ) + })? + .map_err(|error| { + BackendError::new( + openless_core::BackendErrorCode::Platform, + error.to_string(), + ) + })?; + Ok(tr_l10n(lang, "status.opened_player").to_string()) + }); + } } } - if ui.button("取消").clicked() { + frontend::view_model::FrontendAction::VocabAddPhrase(phrase) => { if let Some(backend) = self.backend() { - let target = model.target.clone(); + let lang = self.lang; self.spawn(async move { - backend.services().local_asr.cancel_download(target).await?; - Ok("模型下载已取消".to_string()) + backend.add_vocabulary(phrase, None)?; + Ok(tr_l10n(lang, "status.vocab_saved").to_string()) }); } } - }); - } - } - - fn provider_management_ui(&mut self, ui: &mut egui::Ui) { - ui.horizontal(|ui| { - ui.strong("凭据渠道"); - for (kind, label) in [ - (openless_core::ChannelKind::Asr, "ASR"), - (openless_core::ChannelKind::Llm, "LLM"), - ] { - if ui - .selectable_label(self.provider_kind == kind, label) - .clicked() - && self.provider_kind != kind - { - self.provider_kind = kind; - self.providers = ProvidersState::Loading; - self.selected_channel_id = None; - self.pending_channel_delete = None; - self.provider_editor = ProviderEditorState::Idle; - self.provider_models.clear(); - self.load_providers(kind); - } - } - if ui.button("刷新渠道").clicked() { - self.providers = ProvidersState::Loading; - self.load_providers(self.provider_kind); - } - }); - - let panel = match self.providers.clone() { - ProvidersState::Loading => { - ui.label("正在读取 Core 渠道目录…"); - return; - } - ProvidersState::Failed(error) => { - ui.colored_label(egui::Color32::RED, error); - return; - } - ProvidersState::Loaded(panel) => panel, - }; - - ui.group(|ui| { - ui.label("新增渠道"); - ui.horizontal(|ui| { - egui::ComboBox::from_id_salt("new-provider-type") - .selected_text( - panel - .descriptors - .iter() - .find(|item| item.provider_type.as_str() == self.new_provider_type) - .map(provider_descriptor_label) - .unwrap_or_else(|| "选择 Provider".to_string()), - ) - .show_ui(ui, |ui| { - for descriptor in &panel.descriptors { - ui.selectable_value( - &mut self.new_provider_type, - descriptor.provider_type.as_str().to_string(), - provider_descriptor_label(descriptor), - ); + frontend::view_model::FrontendAction::VocabRemovePhrase(index) => { + if let Some(backend) = self.backend() { + if let Some(entry) = self.vocabulary.get(index) { + let id = entry.id.clone(); + let lang = self.lang; + self.spawn(async move { + backend.remove_vocabulary(&id)?; + Ok(tr_l10n(lang, "status.vocab_updated").to_string()) + }); } - }); - ui.text_edit_singleline(&mut self.new_channel_name); - if ui - .add_enabled( - !self.new_provider_type.is_empty(), - egui::Button::new("创建"), - ) - .clicked() - { - if let (Some(backend), Some(descriptor)) = ( - self.backend(), - panel - .descriptors - .iter() - .find(|item| item.provider_type.as_str() == self.new_provider_type), - ) { - let kind = panel.kind; - let provider_type = descriptor.provider_type.as_str().to_string(); - let name = if self.new_channel_name.trim().is_empty() { - descriptor.label_key.clone() - } else { - self.new_channel_name.trim().to_string() - }; - self.new_channel_name.clear(); - self.spawn_provider_mutation(async move { - backend.create_channel(kind, provider_type, name).await?; - Ok("渠道已创建".to_string()) - }); - } - } - }); - ui.small("Provider 类型、默认 Endpoint/Model 与鉴权要求均来自 Core descriptor。"); - }); - - if panel.channels.is_empty() { - ui.label("尚无渠道;先从上方 Core Provider 列表创建一个。"); - return; - } - - for (index, channel) in panel.channels.iter().enumerate() { - let active = channel.id == panel.active_provider; - ui.horizontal(|ui| { - let selected = self.selected_channel_id.as_deref() == Some(channel.id.as_str()); - if ui - .selectable_label( - selected, - format!( - "{} · {}{}{}", - channel.name, - channel.provider_type, - if active { " · active" } else { "" }, - if channel.enabled { "" } else { " · 已禁用" }, - ), - ) - .clicked() - { - self.selected_channel_id = Some(channel.id.clone()); - self.provider_models.clear(); - if let Some((channel, descriptor)) = - provider_channel_descriptor(&panel, &channel.id) - { - self.provider_editor = ProviderEditorState::Loading { - kind: panel.kind, - channel_id: channel.id.clone(), - }; - self.load_provider_editor(panel.kind, channel, descriptor); } } - if !active && channel.enabled && ui.button("设为 active").clicked() { + frontend::view_model::FrontendAction::VocabTogglePhrase(index) => { if let Some(backend) = self.backend() { - let slot = provider_slot(panel.kind); - let channel_id = channel.id.clone(); - self.spawn_provider_mutation(async move { - backend.set_active_provider(slot, channel_id).await?; - Ok("active 渠道已更新".to_string()) - }); + if let Some(entry) = self.vocabulary.get(index) { + let id = entry.id.clone(); + let enabled = !entry.enabled; + let lang = self.lang; + self.spawn(async move { + backend.set_vocabulary_enabled(&id, enabled)?; + Ok(tr_l10n(lang, "status.vocab_updated").to_string()) + }); + } } } - if ui - .button(if channel.enabled { "禁用" } else { "启用" }) - .clicked() - { + frontend::view_model::FrontendAction::VocabAddRule { + pattern, + replacement, + } => { if let Some(backend) = self.backend() { - let kind = panel.kind; - let channel_id = channel.id.clone(); - let enabled = !channel.enabled; - self.spawn_provider_mutation(async move { - backend - .set_channel_enabled(kind, channel_id, enabled) - .await?; - Ok("渠道启用状态已更新".to_string()) + let lang = self.lang; + self.spawn(async move { + backend.add_correction_rule(pattern, replacement)?; + Ok(tr_l10n(lang, "status.correction_saved").to_string()) }); } } - if index > 0 && ui.button("上移").clicked() { + frontend::view_model::FrontendAction::VocabRemoveRule(index) => { if let Some(backend) = self.backend() { - let kind = panel.kind; - let mut ids = panel - .channels - .iter() - .map(|item| item.id.clone()) - .collect::>(); - ids.swap(index, index - 1); - self.spawn_provider_mutation(async move { - backend.reorder_channels(kind, ids).await?; - Ok("渠道顺序已更新".to_string()) - }); + if let Some(rule) = self.correction_rules.get(index) { + let id = rule.id.clone(); + let lang = self.lang; + self.spawn(async move { + backend.remove_correction_rule(&id)?; + Ok(tr_l10n(lang, "status.correction_updated").to_string()) + }); + } } } - if index + 1 < panel.channels.len() && ui.button("下移").clicked() { + frontend::view_model::FrontendAction::VocabToggleRule(index) => { if let Some(backend) = self.backend() { - let kind = panel.kind; - let mut ids = panel - .channels - .iter() - .map(|item| item.id.clone()) - .collect::>(); - ids.swap(index, index + 1); - self.spawn_provider_mutation(async move { - backend.reorder_channels(kind, ids).await?; - Ok("渠道顺序已更新".to_string()) - }); + if let Some(rule) = self.correction_rules.get(index) { + let id = rule.id.clone(); + let enabled = !rule.enabled; + let lang = self.lang; + self.spawn(async move { + backend.set_correction_rule_enabled(&id, enabled)?; + Ok(tr_l10n(lang, "status.correction_updated").to_string()) + }); + } } } - if self.pending_channel_delete.as_deref() == Some(channel.id.as_str()) { - if ui.button("确认删除").clicked() { - self.pending_channel_delete = None; - if let Some(backend) = self.backend() { - let kind = panel.kind; - let channel_id = channel.id.clone(); - self.spawn_provider_mutation(async move { - backend.delete_channel(kind, channel_id).await?; - Ok("渠道已删除".to_string()) + frontend::view_model::FrontendAction::VocabApplyPreset(index) => { + if let Some(backend) = self.backend() { + if let Some(preset) = self.vocab_presets.get(index) { + let phrases = preset.phrases.clone(); + let name = preset.name.clone(); + let lang = self.lang; + self.spawn(async move { + for phrase in phrases { + backend.add_vocabulary( + phrase, + Some(fmt_l10n(lang, "status.from_preset", &[&name])), + )?; + } + Ok(tr_l10n(lang, "status.preset_updated").to_string()) }); } } - if ui.button("取消删除").clicked() { - self.pending_channel_delete = None; - } - } else if ui.button("删除").clicked() { - // Channel deletion may remove the last usable provider - // and its persisted secrets, so require a deliberate - // second click even in this intentionally compact UI. - self.pending_channel_delete = Some(channel.id.clone()); } - }); - } - - match self.provider_editor.clone() { - ProviderEditorState::Idle => {} - ProviderEditorState::Loading { kind, channel_id } => { - ui.label(format!("正在读取 {:?} 渠道 {channel_id}…", kind)); - } - ProviderEditorState::Failed(error) => { - ui.colored_label(egui::Color32::RED, error); - } - ProviderEditorState::Loaded(editor) => { - let mut editor = *editor; - ui.separator(); - ui.strong(format!("编辑渠道 {}", editor.channel.id)); - let mut provider_type = editor.descriptor.provider_type.as_str().to_string(); - egui::ComboBox::from_id_salt("edit-provider-type") - .selected_text(provider_descriptor_label(&editor.descriptor)) - .show_ui(ui, |ui| { - for descriptor in &panel.descriptors { - ui.selectable_value( - &mut provider_type, - descriptor.provider_type.as_str().to_string(), - provider_descriptor_label(descriptor), - ); - } - }); - if provider_type != editor.descriptor.provider_type.as_str() { + frontend::view_model::FrontendAction::VocabCreatePreset { name, phrases } => { if let Some(backend) = self.backend() { - let kind = editor.kind; - let channel_id = editor.channel.id.clone(); - self.spawn_provider_mutation(async move { - backend - .set_channel_provider_type(kind, channel_id, provider_type) - .await?; - Ok("Provider 类型已更新".to_string()) + let lang = self.lang; + self.spawn(async move { + let mut phrase_list: Vec = phrases + .split([',', ',', '\n']) + .map(str::trim) + .filter(|p| !p.is_empty()) + .map(ToOwned::to_owned) + .collect(); + phrase_list.sort(); + phrase_list.dedup(); + let mut store = backend.list_vocabulary_presets()?; + store.custom.push(openless_core::VocabPreset { + id: uuid::Uuid::new_v4().to_string(), + name: name.trim().to_string(), + phrases: phrase_list, + }); + backend.save_vocabulary_presets(&store)?; + Ok(tr_l10n(lang, "status.preset_updated").to_string()) }); } - return; } - - ui.label(format!( - "鉴权:{} · 探针:{:?}", - auth_requirement_label(editor.descriptor.auth_requirement), - editor.descriptor.validation_probe - )); - ui.horizontal(|ui| { - ui.label("名称"); - ui.text_edit_singleline(&mut editor.name); - }); - provider_fields_ui(ui, &mut editor); - - ui.horizontal(|ui| { - if ui.button("保存字段/Secret").clicked() { - if let Some(backend) = self.backend() { - let saved = editor.clone(); - self.spawn_provider_mutation(async move { - save_provider_editor(backend, saved).await?; - Ok("渠道配置已保存".to_string()) - }); - } - } - if ui.button("清除 Secret").clicked() { - if let Some(backend) = self.backend() { - let cleared = editor.clone(); - self.spawn_provider_mutation(async move { - clear_provider_secrets(backend, &cleared).await?; - Ok("渠道 Secret 已清除".to_string()) + frontend::view_model::FrontendAction::StyleActivate(index) => { + if let Some(backend) = self.backend() { + if let Some(pack) = self.style_packs.get(index) { + let id = pack.id.clone(); + let lang = self.lang; + self.spawn(async move { + backend.activate_style_pack(&id)?; + Ok(tr_l10n(lang, "status.style_updated").to_string()) }); } } - if ui.button("验证连接").clicked() { - if let Some(backend) = self.backend() { - let kind = editor.kind; - let channel_id = editor.channel.id.clone(); - self.spawn_provider_mutation(async move { - validate_provider_channel(backend, kind, channel_id).await + } + frontend::view_model::FrontendAction::StyleExport(index) => { + if let Some(backend) = self.backend() { + if let Some(pack) = self.style_packs.get(index) { + let id = pack.id.clone(); + let lang = self.lang; + self.spawn(async move { + let bytes = backend.export_style_pack_bytes(&id)?; + let destination = tokio::task::spawn_blocking(move || { + rfd::FileDialog::new() + .add_filter("OpenLess style pack", &["zip"]) + .set_file_name(format!("openless-style-{id}.zip")) + .save_file() + }) + .await + .map_err(|error| { + BackendError::new( + openless_core::BackendErrorCode::Internal, + error.to_string(), + ) + })? + .ok_or_else(|| { + BackendError::new( + openless_core::BackendErrorCode::Cancelled, + tr_l10n(lang, "dialog.style_export_cancelled"), + ) + })?; + tokio::task::spawn_blocking(move || { + openless_linux_egui::atomic_save(&destination, &bytes) + }) + .await + .map_err(|error| { + BackendError::new( + openless_core::BackendErrorCode::Internal, + error.to_string(), + ) + })? + .map_err(|error| { + BackendError::new( + openless_core::BackendErrorCode::Platform, + error.to_string(), + ) + })?; + Ok(tr_l10n(lang, "status.style_updated").to_string()) }); } } - if ui.button("列出模型").clicked() { - self.provider_models.clear(); - self.request_provider_models(editor.kind, editor.channel.id.clone()); + } + frontend::view_model::FrontendAction::StyleEdit(index) => { + if let Some(pack) = self.style_packs.get(index).cloned() { + self.style_editor = Some(pack); + self.frontend_vm.style_editor_open = true; + self.frontend_vm.style_prompt = self + .style_editor + .as_ref() + .map(|e| e.prompt.clone()) + .unwrap_or_default(); } - }); - if !self.provider_models.is_empty() { - ui.label("模型列表(点击填入):"); - for model in self.provider_models.clone() { - if ui.button(&model).clicked() { - editor.model = model; + } + frontend::view_model::FrontendAction::StyleSaveEditor(prompt) => { + if let Some(mut pack) = self.style_editor.take() { + pack.prompt = prompt; + if let Some(backend) = self.backend() { + let exists = self.style_packs.iter().any(|p| p.id == pack.id); + let lang = self.lang; + self.spawn(async move { + let saved = if exists { + backend.update_style_pack(pack)? + } else { + backend.create_style_pack(pack)? + }; + Ok(fmt_l10n(lang, "status.style_saved", &[&saved.name])) + }); } } + self.frontend_vm.style_editor_open = false; } - self.provider_editor = ProviderEditorState::Loaded(Box::new(editor)); - } - } - } - - fn services_ui(&mut self, ui: &mut egui::Ui) { - ui.heading("AI 服务"); - ui.label("选择 ASR 语音识别、LLM 文本处理或 Omni 服务,再编辑并校验渠道。已配置不代表网络请求已通过。"); - if let Some(snapshot) = &self.snapshot { - let credentials = &snapshot.credentials; - ui.label(format!( - "ASR:{}({})", - credentials.active_asr_provider, - if credentials.asr_configured { - "已配置" - } else { - "未配置" - } - )); - ui.label(format!( - "LLM:{}({})", - credentials.active_llm_provider, - if credentials.llm_configured { - "已配置" - } else { - "未配置" + frontend::view_model::FrontendAction::StyleCloseEditor => { + self.style_editor = None; + self.frontend_vm.style_editor_open = false; } - )); - } - self.provider_management_ui(ui); - } - - fn save_preferences(&mut self) { - let (Some(native), Some(snapshot), Some(preferences)) = - (&self.native, &self.snapshot, &self.preferences) - else { - return; - }; - match native - .host() - .save_settings(preferences.clone(), snapshot.preferences_revision) - { - Ok(_) => { - self.status = "设置已保存".to_string(); - let config = openless_core::RemoteInputConfig { - enabled: preferences.remote_input_enabled, - port: preferences.remote_input_port, - }; - if let Some(backend) = self.backend() { - self.spawn(async move { - backend.services().remote_input.configure(config).await?; - Ok("远程输入状态已更新".to_string()) + frontend::view_model::FrontendAction::StyleNewPack => { + self.style_editor = Some(openless_core::StylePack { + id: uuid::Uuid::new_v4().to_string(), + name: tr_l10n(self.lang, "lbl.new_style_default").to_string(), + ..Default::default() }); + self.frontend_vm.style_editor_open = true; } - } - Err(error) => self.status = error.to_string(), - } - } - - fn settings_actions_ui(&mut self, ui: &mut egui::Ui) { - ui.horizontal_wrapped(|ui| { - if ui.button("保存设置").clicked() { - self.save_preferences(); - } - if ui.button("放弃修改并重新读取").clicked() { - if let Some(backend) = self.backend() { - self.preferences = Some(backend.get_preferences()); - self.snapshot = Some(backend.snapshot()); - self.status = "已重新读取设置".to_string(); - } - } - }); - ui.small( - "环境与设置、手机输入共用设置草稿;保存会一起应用。保存冲突时可重新读取后再修改。", - ); - } - - fn settings_ui(&mut self, ui: &mut egui::Ui) { - ui.heading("环境与设置"); - ui.strong("现有功能设置"); - if let Some(preferences) = self.preferences.as_mut() { - ui.checkbox(&mut preferences.streaming_insert, "流式插入"); - ui.small("将转写逐步发送到原输入目标,实际结果以听写与历史反馈为准。"); - ui.checkbox(&mut preferences.coding_agent_enabled, "启用 Less Computer"); - ui.small("使用已有 Agent 配置与 CLI;进程执行仍遵循 Core 的审批规则。"); - self.settings_actions_ui(ui); - } - ui.horizontal_wrapped(|ui| { - if ui.button("配置 AI 服务").clicked() { - self.navigation.open(Page::Services); - } - if ui.button("设置手机输入").clicked() { - self.navigation.open(Page::Remote); - } - }); - ui.small( - "托盘、自启、自动更新、系统静音与额外全局热键尚未完整接入,此页没有对应开关。", - ); - ui.separator(); - self.environment_ui(ui); - } - - fn remote_ui(&mut self, ui: &mut egui::Ui) { - ui.heading("手机输入"); - ui.label( - "先启用并保存,再让手机连接同一局域网,打开本机提供的 HTTPS 地址并输入配对码。", - ); - ui.label("首次连接需要确认并信任本服务的证书;服务运行不代表手机已连接。"); - if let Some(preferences) = self.preferences.as_mut() { - ui.checkbox(&mut preferences.remote_input_enabled, "启用远程输入"); - ui.add( - egui::DragValue::new(&mut preferences.remote_input_port) - .range(1..=u16::MAX) - .prefix("端口 "), - ); - self.settings_actions_ui(ui); - } - ui.separator(); - if ui.button("刷新连接状态").clicked() { - self.load_remote_status(); - } - if let Some(error) = &self.remote_error { - ui.colored_label( - egui::Color32::YELLOW, - format!("暂时无法读取连接状态:{error}"), - ); - ui.label("检查桌面密钥环、网络和端口后刷新;旧地址与配对码已隐藏。"); - } else if self.remote_access.is_none() { - ui.label("尚未取得手机输入状态。"); - } - if let Some((remote, pin)) = &self.remote_access { - ui.label(if remote.running { - "远程输入服务:运行中" - } else if remote.starting { - "远程输入服务:启动中" - } else { - "远程输入服务:已停止" - }); - ui.label(format!("当前连接数:{}", remote.connection_count)); - if remote.active_session_id.is_some() { - ui.label("手机语音会话进行中,可使用顶部的语音取消。"); - } - if remote.urls_stale { - ui.colored_label( - egui::Color32::YELLOW, - "网络地址已过期,请检查网络后刷新状态。", - ); - } - if remote.enabled && remote.running && !remote.urls_stale { - // 首次信任前必须核对根证书指纹:网页与描述文件名称不能证明身份。 - ui.label("本机根证书 SHA-256"); - match remote.ca_fingerprint_sha256.as_ref().filter(|value| { - value.len() == 64 && value.bytes().all(|byte| byte.is_ascii_hexdigit()) - }) { - Some(fingerprint) => { - let display = fingerprint - .as_bytes() - .chunks(2) - .map(|pair| std::str::from_utf8(pair).unwrap().to_ascii_uppercase()) - .collect::>() - .join(" "); - ui.add(egui::Label::new(egui::RichText::new(&display).monospace()).wrap()); - if ui.button("复制完整指纹").clicked() { - ui.ctx().copy_text(display); - } - } - None => ui.label("完整指纹不可用。请勿安装或信任下载的证书。"), - } - ui.label("安装或开启完全信任前,在手机系统的证书详情中核对全部 SHA-256 字符,必须与此处一致。网页、描述文件名称和标识不能证明证书身份。若不一致或无法查看,请停止并移除已下载或安装的描述文件。"); - ui.label("描述文件应只包含一张根证书。若有其他证书、VPN 或设备管理配置,请勿安装。首次下载仍可能被局域网攻击者替换;核验后再信任。根证书可签发其他证书,不再使用时请移除。"); - ui.monospace(format!("PIN:{pin}")); - for url in &remote.urls { - ui.monospace(url); + frontend::view_model::FrontendAction::StyleImport => { + if let Some(backend) = self.backend() { + let lang = self.lang; + self.spawn(async move { + let path = tokio::task::spawn_blocking(|| { + rfd::FileDialog::new() + .add_filter("OpenLess style pack", &["zip"]) + .pick_file() + }) + .await + .map_err(|error| { + BackendError::new( + openless_core::BackendErrorCode::Internal, + error.to_string(), + ) + })? + .ok_or_else(|| { + BackendError::new( + openless_core::BackendErrorCode::Cancelled, + tr_l10n(lang, "dialog.style_import_cancelled"), + ) + })?; + let pack = tokio::task::spawn_blocking(move || { + backend.import_style_pack_path(&path) + }) + .await + .map_err(|error| { + BackendError::new( + openless_core::BackendErrorCode::Internal, + error.to_string(), + ) + })??; + Ok(fmt_l10n(lang, "status.style_imported", &[&pack.name])) + }); + } } - if remote.urls.is_empty() { - ui.label("服务已启动,但尚未提供可用地址;请检查本机局域网连接。"); + frontend::view_model::FrontendAction::SelectionAskToggleHistory => { + self.frontend_vm.qa_save_history = !self.frontend_vm.qa_save_history; } - } - if remote.enabled && ui.button("重置配对码").clicked() { - if let Some(backend) = self.backend() { - self.spawn(async move { - backend - .services() - .remote_input - .regenerate_pairing_pin() - .await?; - Ok("远程输入配对码已重置".to_string()) - }); + frontend::view_model::FrontendAction::TranslationToggleLanguage(_) => { + self.frontend_vm.settings_notice = + Some(tr_l10n(self.lang, "settings.unsupported_linux").to_string()); } - } - } - } - - fn environment_ui(&mut self, ui: &mut egui::Ui) { - ui.strong("Linux 环境准备"); - ui.label(if self.native.is_some() { - "Core 已连接。下面的环境检查不代表录音、落字或服务调用已经实测成功。" - } else { - "Core 未连接。可查看准备步骤;修复启动问题后,请退出并重新启动 OpenLess。" - }); - if let Some(environment) = &self.environment { - ui.label(match environment.session { - LinuxDesktopSession::X11 => "桌面会话:检测到 X11 环境", - LinuxDesktopSession::Wayland => "桌面会话:检测到 Wayland 环境", - LinuxDesktopSession::Headless => "桌面会话:未检测到 DISPLAY / WAYLAND_DISPLAY", - }); - ui.label(if environment.fcitx5_ready { - "fcitx5:D-Bus 探测有响应,插件加载、快捷键与目标应用落字仍需实际操作确认。" - } else { - "fcitx5:D-Bus 探测未通过,可能是会话总线、服务或插件未就绪。" - }); - ui.label(match environment.permissions.microphone { - openless_core::PermissionState::Unsupported => { - "麦克风:当前探测环境不支持;请进入图形桌面会话。" + frontend::view_model::FrontendAction::TranslationSetTarget(_) => { + self.frontend_vm.settings_notice = + Some(tr_l10n(self.lang, "settings.unsupported_linux").to_string()); } - _ => "麦克风:尚未验证录音。请在系统声音设置选择输入设备,再进行一次短听写。", - }); - } else { - ui.label("尚未取得桌面环境探测结果。"); - } - ui.label(match &self.plugin_check { - Some(Ok(FcitxPluginStatus::Ready)) => { - "本次启动插件检查:找到插件文件;文件存在不代表 fcitx5 已加载它。" - } - Some(Ok(FcitxPluginStatus::Updated)) => { - "本次启动插件检查:插件文件已安装或更新,需要重载配置并重新启动 fcitx5。" - } - Some(Ok(FcitxPluginStatus::Missing)) => { - "本次启动插件检查:未找到插件文件,请重新安装含 OpenLess 插件的软件包。" - } - Some(Err(_)) => "本次启动插件检查:检查失败,请查看下方具体原因。", - None => "本次启动插件检查:未执行。", - }); - if let Some(Err(error)) = &self.plugin_check { - ui.colored_label(egui::Color32::YELLOW, error); - } - if ui - .add_enabled( - !self.environment_refreshing, - egui::Button::new(if self.environment_refreshing { - "正在检测…" - } else { - "重新检测会话与 D-Bus" - }), - ) - .clicked() - { - self.environment_refreshing = true; - let tx = self.tx.clone(); - self.tokio.spawn_blocking(move || { - let environment = LinuxCapabilitySnapshot::detect(false, package_kind()); - let _ = tx.send(UiResult::Environment(environment)); - }); - } - ui.small("重新检测只更新上面的会话与 D-Bus 信息,不安装插件,也不重新连接 Core。本次启动检查结果保留到退出。"); - egui::CollapsingHeader::new("准备步骤与官方指南") - .default_open(self.native.is_none()) - .show(ui, |ui| { - ui.separator(); - ui.strong("1 · 准备输入法与桌面会话"); - ui.label("在当前图形桌面安装并启用 fcitx5,再安装含 OpenLess 插件的当前软件包。先在普通编辑器中确认输入法可以输入。"); - ui.label("在终端运行以下诊断,查看输入法环境与插件加载信息:"); - command_ui(ui, "fcitx5-diagnose"); - ui.label("插件安装或更新后可先重载配置;若插件仍未加载,退出并重新登录桌面,再启动 OpenLess:"); - command_ui(ui, "fcitx5-remote -r"); - ui.horizontal_wrapped(|ui| { - ui.hyperlink_to( - "Fcitx 5 官方设置指南", - "https://fcitx-im.org/wiki/Setup_Fcitx_5", - ); - ui.hyperlink_to( - "Wayland 桌面配置差异", - "https://fcitx-im.org/wiki/Using_Fcitx_5_on_Wayland", - ); - }); - ui.small("Wayland 的输入法配置取决于桌面和应用工具包,请按官方对应章节配置;检测到 Wayland 不代表所有目标应用都支持替换。X11 的 overlay 能力标记也不代表本应用已接入录音浮层。"); - ui.separator(); - ui.strong("2 · 准备密钥环与识别服务"); - ui.label("Secret Service:当前没有独立的服务连接或解锁状态检测;渠道显示“已配置”也不能证明密钥环现在可读写。"); - ui.label("打开桌面的密码/密钥环管理器,确认当前登录会话的密钥环已解锁。然后到 AI 服务选择渠道,填写所需凭据、保存并校验;若返回锁定或访问失败,解锁后重试。"); - ui.hyperlink_to( - "Secret Service 官方规范", - "https://specifications.freedesktop.org/secret-service/latest/", - ); - ui.small("API 密钥输入只用于写入,不回显已有密钥。本地识别可在本地模型页下载并激活 Generic Qwen。"); - ui.separator(); - ui.strong("3 · 做一次短听写"); - ui.label("在系统声音设置确认输入设备有电平。配置识别服务后,在目标编辑器聚焦输入框,用已有听写快捷键录制一句话并结束,检查转写和落字结果。问答、选区润色与 Agent 分别从导航进入。"); - ui.small("请分别验证你使用的 X11/Wayland、GTK/Qt/浏览器/终端。托盘、自启和应用内自动更新仍未完整接入。"); - }); - } - - fn start_ui(&mut self, ui: &mut egui::Ui) { - ui.heading("从一次听写开始"); - ui.label("先准备 Linux 输入环境,再选择识别服务。切换页面不会停止正在进行的任务。"); - if let Some(error) = &self.startup_error { - ui.colored_label(egui::Color32::YELLOW, format!("启动未完成:{error}")); - } - if let Some(snapshot) = &self.snapshot { - ui.label(if snapshot.running { - "Core:运行中" - } else { - "Core:未运行" - }); - let credentials = &snapshot.credentials; - match credentials.pipeline_mode { - openless_core::shared_types::PipelineMode::Multimodal => { - ui.label("当前管线:多模态(Omni)"); - ui.label(if credentials.omni_configured { - "Omni:已配置。" - } else { - "Omni:尚未配置,请到 AI 服务配置 Omni。" - }); + frontend::view_model::FrontendAction::SettingsToggle(field) => { + self.apply_settings_toggle(field); } - openless_core::shared_types::PipelineMode::Traditional => { - ui.label("当前管线:传统(ASR + LLM)"); - ui.label(if credentials.asr_configured { - "ASR 语音识别:已配置。" - } else { - "ASR 语音识别:尚未配置,请配置 AI 服务或激活本地模型。" - }); - ui.label(if credentials.llm_configured { - "LLM 润色:已配置。" - } else { - "LLM 润色:尚未配置。" - }); + frontend::view_model::FrontendAction::SettingsCombo(field, index) => { + self.apply_settings_combo(field, index); } - } - ui.small("已配置不代表校验通过;请到 AI 服务验证连接。"); - } - ui.horizontal_wrapped(|ui| { - for (page, label) in [ - (Page::Settings, "1. 准备环境"), - (Page::Services, "2. 配置 AI 服务"), - (Page::Models, "使用本地模型"), - (Page::Dictation, "3. 打开听写"), - ] { - if ui - .add_enabled( - self.native.is_some() || page == Page::Settings, - egui::Button::new(label), - ) - .clicked() - { - self.navigation.open(page); + frontend::view_model::FrontendAction::SettingsText(field, text) => { + self.apply_settings_text(field, text); } - } - }); - ui.separator(); - if self.native.is_none() { - self.environment_ui(ui); - } else { - ui.strong("继续其他任务"); - ui.horizontal_wrapped(|ui| { - for page in [ - Page::Qa, - Page::Selection, - Page::Agent, - Page::Remote, - Page::History, - ] { - if ui.button(page.label()).clicked() { - self.navigation.open(page); - } + frontend::view_model::FrontendAction::SettingsAction(field) => { + self.apply_settings_action(field); } - }); - ui.label("问答支持文字与语音;选区润色保留确认、取消与撤销;Less Computer 的工具执行继续使用原有审批。"); - ui.small( - "Linux 当前提供已有 Core / Host 能力的入口,完整原生支持与发布验收仍在继续。", - ); - } - } - - fn page_activity(&self, page: Page) -> Option<&'static str> { - match page { - Page::Dictation - if self.snapshot.as_ref().is_some_and(|snapshot| { - matches!( - snapshot.dictation.phase, - DictationPhase::Starting - | DictationPhase::Recording - | DictationPhase::Transcribing - | DictationPhase::Polishing - | DictationPhase::Inserting - ) - }) => - { - Some("进行中") - } - Page::Qa if self.qa_visible => Some("会话"), - Page::Selection - if self.selection_preview_visible - && self.selection.as_ref().is_some_and(|selection| { - selection.phase == SelectionPhase::Preview - }) => - { - Some("待确认") - } - Page::Agent if self.pending_approval.is_some() => Some("待审批"), - Page::Agent if self.less_computer_running => Some("进行中"), - _ if self.navigation.has_update(page) => Some("有更新"), - _ => None, - } - } - - fn navigation_button(&mut self, ui: &mut egui::Ui, page: Page) { - let label = match self.page_activity(page) { - Some(activity) => format!("{} · {activity}", page.label()), - None => page.label().to_string(), - }; - if ui - .selectable_label(self.navigation.page == page, label) - .clicked() - { - self.navigation.open(page); - } - } - - fn activity_ui(&mut self, ui: &mut egui::Ui) { - ui.horizontal_wrapped(|ui| { - for page in Page::ALL { - if let Some(activity) = self.page_activity(page) { - if ui - .link(format!("{} · {activity} →", page.label())) - .clicked() - { - self.navigation.open(page); - } + frontend::view_model::FrontendAction::SettingsSection(section) => { + self.frontend_vm.settings_section = section; } - } - if self.native.is_some() && ui.button("取消当前语音 · Esc").clicked() { - self.cancel_voice(); - } - if self.qa_visible && ui.button("取消问答").clicked() { - if let Some(backend) = self.backend() { - let session_id = self - .qa_state - .as_ref() - .and_then(|state| state.session_id.as_deref()) - .and_then(|id| uuid::Uuid::parse_str(id).ok()) - .map(openless_core::SessionId::from_uuid); - self.spawn(async move { - backend.services().qa.cancel(session_id).await?; - Ok("问答本轮已取消".to_string()) - }); + frontend::view_model::FrontendAction::SettingsNotice(msg) => { + self.frontend_vm.settings_notice = Some(msg); } - } - if let Some(session_id) = self - .selection - .as_ref() - .filter(|selection| selection.phase == SelectionPhase::Preview) - .and_then(|selection| selection.session_id) - { - if ui.button("取消选区预览").clicked() { - if let Some(backend) = self.backend() { - self.spawn(async move { - backend - .services() - .selection - .cancel(Some(session_id)) - .await?; - Ok("选区替换已取消".to_string()) - }); + frontend::view_model::FrontendAction::MarketplaceDetail(index) => { + self.frontend_vm.marketplace_selected = Some(index); + // Load real detail from backend, not just index. + if let Some(item) = self.marketplace_items.get(index) { + if let Some(backend) = self.backend() { + let id = item.id.clone(); + let tx = self.tx.clone(); + self.tokio.spawn(async move { + let result = backend + .services() + .marketplace + .detail(id) + .await + .map_err(|error| error.to_string()); + let _ = tx.send(UiResult::MarketplaceDetail(result)); + }); + } } } } - if (self.less_computer_running || self.pending_approval.is_some()) - && ui.button("取消 Agent").clicked() - { - if let Some(backend) = self.backend() { - self.spawn(async move { - backend.cancel_less_computer(None).await?; - Ok("Less Computer 已取消".to_string()) - }); - } - } - }); + } } + } - fn cancel_voice(&self) { - if let Some(backend) = self.backend() { - self.spawn(async move { - backend.cancel_active_voice_session(None).await?; - Ok("语音会话已取消".to_string()) - }); - } + impl eframe::App for OpenLessEguiApp { + fn clear_color(&self, _visuals: &egui::Visuals) -> [f32; 4] { + egui::Color32::TRANSPARENT.to_normalized_gamma_f32() } - fn history_ui(&mut self, ui: &mut egui::Ui) { - ui.heading("历史"); - ui.label("最近 20 条,只读。插入、复制回退与已发送粘贴分别显示实际结果。"); - let Some(backend) = self.backend() else { - return; - }; - match backend.list_history() { - Ok(history) if history.is_empty() => { - ui.label("暂无历史记录"); - } - Ok(history) => { - for item in history.into_iter().rev().take(20) { - let delivery = match item.insert_status { - HistoryInsertStatus::Inserted => "已插入", - HistoryInsertStatus::CopiedFallback => "已复制", - HistoryInsertStatus::PasteSent => "已发送粘贴", - HistoryInsertStatus::Failed => "失败", - HistoryInsertStatus::NotRequested => "未请求插入", - }; - ui.label(format!( - "{} · {} · {}", - item.created_at, delivery, item.final_text - )); - } - } - Err(error) => { - ui.label(error.to_string()); - } + fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { + self.poll(ctx); + self.drain_tray(ctx); + theme::apply_visuals( + ctx, + self.preferences + .as_ref() + .map(|preferences| preferences.theme_mode) + .unwrap_or_default(), + ); + let auto_check = self + .preferences + .as_ref() + .is_some_and(|preferences| preferences.auto_update_check); + if auto_check + && !self.update_busy + && self.update_manifest.is_none() + && self + .update_schedule + .poll(self.update_started.elapsed(), false) + .is_some() + { + let channel = self + .preferences + .as_ref() + .map(|preferences| preferences.update_channel) + .unwrap_or_default(); + self.request_update_check(channel); } - } - } - - impl eframe::App for OpenLessEguiApp { - fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { - // Poll every frame before routing pages. Hidden pages retain their - // drafts, session ownership, event replay and async completion paths. - self.poll(ctx); - if ctx.input(|input| input.key_pressed(egui::Key::Escape)) { - self.cancel_voice(); + if ctx.input(|input| input.viewport().close_requested()) + && !self.exit_requested + && self.tray.is_some() + { + ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose); + ctx.send_viewport_cmd(egui::ViewportCommand::Visible(false)); } - egui::TopBottomPanel::top("status").show(ctx, |ui| { - ui.horizontal(|ui| { - ui.strong("OpenLess 2.0"); - ui.separator(); - ui.add(egui::Label::new(&self.status).truncate()) - .on_hover_text(&self.status); - }); - self.activity_ui(ui); - if self.pending_approval.is_some() { - ui.strong("Less Computer 等待审批"); - self.agent_approval_ui(ui); - } - }); - if ctx.screen_rect().width() < 760.0 { - egui::TopBottomPanel::top("compact_navigation").show(ctx, |ui| { - ui.horizontal_wrapped(|ui| { - for page in Page::ALL { - self.navigation_button(ui, page); - } - }); - }); - } else { - egui::SidePanel::left("navigation") - .resizable(false) - .default_width(176.0) - .show(ctx, |ui| { - egui::ScrollArea::vertical() - .id_salt("navigation_scroll") - .show(ui, |ui| { - ui.strong("工作空间"); - ui.add_space(8.0); - for page in Page::ALL { - if page == Page::Services { - ui.separator(); - ui.strong("准备与管理"); - } - self.navigation_button(ui, page); - } - }); + if ctx.input(|input| input.key_pressed(egui::Key::Escape)) { + let lang = self.lang; + if let Some(backend) = self.backend() { + self.spawn(async move { + backend.cancel_active_voice_session(None).await?; + Ok(tr_l10n(lang, "voice.cancelled").to_string()) }); + } } - egui::CentralPanel::default().show(ctx, |ui| { - let page = self.navigation.page; - egui::ScrollArea::vertical() - .id_salt(("page", page)) - .show(ui, |ui| { - if self.native.is_none() && !matches!(page, Page::Start | Page::Settings) { - ui.heading(page.label()); - ui.label("Core 尚未连接,请先完成 Linux 环境准备并重新启动应用。"); - if let Some(error) = &self.startup_error { - ui.colored_label(egui::Color32::YELLOW, error); - } - if ui.button("查看环境准备步骤").clicked() { - self.navigation.open(Page::Settings); - } - return; - } - match page { - Page::Start => self.start_ui(ui), - Page::Dictation => self.dictation_ui(ui), - Page::Qa => self.qa_ui(ui), - Page::Selection => self.selection_ui(ui), - Page::Agent => self.less_computer_ui(ui), - Page::Services => self.services_ui(ui), - Page::Models => self.models_ui(ui), - Page::Remote => self.remote_ui(ui), - Page::History => self.history_ui(ui), - Page::Settings => self.settings_ui(ui), - } - }); - }); + + // Build the view model from current backend state, then render the + // production frontend. Actions are collected and dispatched to + // existing Core / backend methods. + self.sync_view_model(); + let mut actions = Vec::new(); + frontend::render(ctx, &mut self.frontend_vm, &mut actions); + self.apply_frontend_actions(actions, ctx); + ctx.request_repaint_after(Duration::from_millis(50)); } } - fn command_ui(ui: &mut egui::Ui, command: &str) { - ui.horizontal_wrapped(|ui| { - ui.monospace(command); - if ui.button("复制命令").clicked() { - ui.ctx().copy_text(command.to_string()); - } - }); - } - impl Drop for OpenLessEguiApp { fn drop(&mut self) { if let Some(native) = self.native.take() { @@ -2099,6 +5613,18 @@ mod linux_app { } } + /// Map a concrete UI language to its display-name catalog key, shown in + /// that language's own native script regardless of the current UI language. + fn locale_key(lang: Lang) -> &'static str { + match lang { + Lang::ZhCn => "lang.zh-CN", + Lang::ZhTw => "lang.zh-TW", + Lang::En => "lang.en", + Lang::Ja => "lang.ja", + Lang::Ko => "lang.ko", + } + } + fn provider_kind(kind: openless_core::ChannelKind) -> openless_core::ProviderKind { match kind { openless_core::ChannelKind::Asr => openless_core::ProviderKind::Asr, @@ -2161,21 +5687,25 @@ mod linux_app { ) } - fn auth_requirement_label(requirement: openless_core::AuthRequirement) -> &'static str { - match requirement { - openless_core::AuthRequirement::None => "无需 Secret", - openless_core::AuthRequirement::ApiKey => "API Key", + fn auth_requirement_label( + lang: Lang, + requirement: openless_core::AuthRequirement, + ) -> &'static str { + let key = match requirement { + openless_core::AuthRequirement::None => "auth.none", + openless_core::AuthRequirement::ApiKey => "auth.api_key", openless_core::AuthRequirement::EndpointModelOptionalApiKey => { - "Endpoint + Model,API Key 可选" + "auth.endpoint_model_optional" } openless_core::AuthRequirement::ApiKeyUnlessCustomEndpoint => { - "公共 Endpoint 需要 API Key;自建 Endpoint 可无 Key" + "auth.api_key_unless_custom" } - openless_core::AuthRequirement::Volcengine => "火山引擎凭据", - openless_core::AuthRequirement::Xfyun => "讯飞 AppID + API Key", - openless_core::AuthRequirement::TencentCloud => "腾讯云 AppID + SecretID + SecretKey", - openless_core::AuthRequirement::OAuth => "OAuth", - } + openless_core::AuthRequirement::Volcengine => "auth.volcengine", + openless_core::AuthRequirement::Xfyun => "auth.xfyun", + openless_core::AuthRequirement::OAuth => "auth.oauth", + openless_core::AuthRequirement::TencentCloud => "auth.api_key", + }; + tr_l10n(lang, key) } fn provider_channel_descriptor( @@ -2253,19 +5783,6 @@ mod linux_app { } else { (String::new(), String::new()) }; - let app_id = if descriptor.auth_requirement == openless_core::AuthRequirement::TencentCloud - { - read_provider_value( - &backend, - kind, - &channel.id, - openless_core::credentials::TENCENT_CLOUD_APP_ID_ACCOUNT, - ) - .await? - .unwrap_or_default() - } else { - String::new() - }; Ok(ProviderEditor { kind, name: channel.name.clone(), @@ -2275,7 +5792,6 @@ mod linux_app { model, auth_mode, resource_id, - app_id, primary_secret: String::new(), secondary_secret: String::new(), }) @@ -2288,16 +5804,16 @@ mod linux_app { }); } - fn provider_fields_ui(ui: &mut egui::Ui, editor: &mut ProviderEditor) { + fn provider_fields_ui(ui: &mut egui::Ui, lang: Lang, editor: &mut ProviderEditor) { // This match chooses which input controls to render; it does not decide // whether credentials are sufficient. ProviderService validates the // descriptor's AuthRequirement again before any protocol request. match editor.descriptor.auth_requirement { openless_core::AuthRequirement::None => { - ui.label("此 Provider 不使用云凭据;模型由本地模型面板管理。"); + ui.label(tr_l10n(lang, "providers.no_cloud_note")); } openless_core::AuthRequirement::OAuth => { - ui.label("此 Provider 使用 OAuth;Linux egui 不读取或显示 OAuth token。"); + ui.label(tr_l10n(lang, "providers.oauth_note")); } openless_core::AuthRequirement::Volcengine => { egui::ComboBox::from_id_salt("volcengine-auth-mode") @@ -2333,20 +5849,12 @@ mod linux_app { secret_edit(ui, "AppID", &mut editor.primary_secret); secret_edit(ui, "API Key", &mut editor.secondary_secret); } - openless_core::AuthRequirement::TencentCloud => { - ui.horizontal(|ui| { - ui.label("腾讯云 AppID"); - ui.text_edit_singleline(&mut editor.app_id); - }); - secret_edit(ui, "SecretID", &mut editor.primary_secret); - secret_edit(ui, "SecretKey", &mut editor.secondary_secret); - ui.horizontal(|ui| { - ui.label("Model"); - ui.text_edit_singleline(&mut editor.model); - }); - } _ => { - secret_edit(ui, "API Key(留空表示不修改)", &mut editor.primary_secret); + secret_edit( + ui, + tr_l10n(lang, "providers.api_key_hint"), + &mut editor.primary_secret, + ); ui.horizontal(|ui| { ui.label("Endpoint"); ui.text_edit_singleline(&mut editor.endpoint); @@ -2481,40 +5989,6 @@ mod linux_app { ) .await?; } - openless_core::AuthRequirement::TencentCloud => { - write_or_remove_provider_value( - &backend, - editor.kind, - channel_id, - openless_core::credentials::TENCENT_CLOUD_APP_ID_ACCOUNT, - &editor.app_id, - ) - .await?; - write_secret_if_entered( - &backend, - editor.kind, - channel_id, - openless_core::credentials::TENCENT_CLOUD_SECRET_ID_ACCOUNT, - &editor.primary_secret, - ) - .await?; - write_secret_if_entered( - &backend, - editor.kind, - channel_id, - openless_core::credentials::TENCENT_CLOUD_SECRET_KEY_ACCOUNT, - &editor.secondary_secret, - ) - .await?; - write_or_remove_provider_value( - &backend, - editor.kind, - channel_id, - model_account(editor.kind), - &editor.model, - ) - .await?; - } _ => { write_or_remove_provider_value( &backend, @@ -2560,11 +6034,6 @@ mod linux_app { openless_core::credentials::XFYUN_APP_ID_ACCOUNT, openless_core::credentials::XFYUN_API_KEY_ACCOUNT, ], - openless_core::AuthRequirement::TencentCloud => &[ - openless_core::credentials::TENCENT_CLOUD_APP_ID_ACCOUNT, - openless_core::credentials::TENCENT_CLOUD_SECRET_ID_ACCOUNT, - openless_core::credentials::TENCENT_CLOUD_SECRET_KEY_ACCOUNT, - ], _ => &[api_key_account(editor.kind)], }; for account in accounts { @@ -2580,6 +6049,7 @@ mod linux_app { } async fn validate_provider_channel( + lang: Lang, backend: Arc, kind: openless_core::ChannelKind, channel_id: String, @@ -2589,8 +6059,8 @@ mod linux_app { .services() .provider .validate(openless_core::ProviderRequest { - thinking_enabled: backend.get_preferences().llm_thinking_enabled, kind: provider_kind(kind), + thinking_enabled: false, channel_id: Some(channel_id.clone()), }) .await; @@ -2600,7 +6070,7 @@ mod linux_app { backend .record_channel_test(kind, channel_id, true, Some(latency_ms), None) .await?; - Ok(format!("Provider 验证通过({latency_ms} ms)")) + Ok(fmt_l10n(lang, "status.provider_validated", &[&latency_ms])) } Err(error) => { let _ = backend @@ -2617,62 +6087,637 @@ mod linux_app { } } - fn package_kind() -> LinuxPackageKind { - if std::env::var_os("APPDIR").is_some() { - LinuxPackageKind::AppImage - } else if cfg!(debug_assertions) { - LinuxPackageKind::Development + fn shortcut_editor( + ui: &mut egui::Ui, + label: &str, + binding: &mut openless_core::shared_types::ShortcutBinding, + ) -> bool { + let mut changed = false; + ui.horizontal(|ui| { + ui.label(label); + changed |= ui.text_edit_singleline(&mut binding.primary).changed(); + for (modifier, caption) in [ + ("ctrl", "Ctrl"), + ("alt", "Alt"), + ("shift", "Shift"), + ("super", "Super"), + ] { + let mut enabled = binding + .modifiers + .iter() + .any(|value| value.eq_ignore_ascii_case(modifier)); + if ui.checkbox(&mut enabled, caption).changed() { + changed = true; + binding + .modifiers + .retain(|value| !value.eq_ignore_ascii_case(modifier)); + if enabled { + binding.modifiers.push(modifier.to_string()); + } + } + } + }); + changed + } + + fn optional_shortcut_editor( + ui: &mut egui::Ui, + lang: Lang, + label: &str, + binding: &mut Option, + default_primary: &str, + ) -> bool { + let mut enabled = binding.is_some(); + let mut changed = ui + .checkbox(&mut enabled, fmt_l10n(lang, "hotkey.enable", &[&label])) + .changed(); + if enabled && binding.is_none() { + *binding = Some(openless_core::shared_types::ShortcutBinding { + primary: default_primary.to_string(), + modifiers: vec!["ctrl".into(), "shift".into()], + }); + } else if !enabled && binding.is_some() { + *binding = None; + } + if let Some(binding) = binding { + changed |= shortcut_editor(ui, label, binding); + } + changed + } + + fn set_style_pack_hotkey( + preferences: &mut UserPreferences, + pack_id: &str, + binding: Option, + ) { + preferences + .style_pack_hotkeys + .retain(|hotkey| hotkey.pack_id != pack_id); + if let Some(binding) = binding { + preferences + .style_pack_hotkeys + .push(openless_core::shared_types::StylePackHotkey { + pack_id: pack_id.to_string(), + binding, + }); + } + } + + fn package_kind() -> LinuxPackageKind { + if std::env::var_os("APPDIR").is_some() { + LinuxPackageKind::AppImage + } else if cfg!(debug_assertions) { + LinuxPackageKind::Development + } else { + LinuxPackageKind::SystemPackage + } + } + + fn backend_config( + tray_available: bool, + updater_available: bool, + ) -> Result { + let home = std::env::var_os("HOME").map(std::path::PathBuf::from); + let data_dir = std::env::var_os("XDG_DATA_HOME") + .map(std::path::PathBuf::from) + .or_else(|| home.as_ref().map(|home| home.join(".local/share"))) + .ok_or_else(|| "HOME/XDG_DATA_HOME is unavailable".to_string())? + .join("OpenLess"); + let cache_dir = std::env::var_os("XDG_CACHE_HOME") + .map(std::path::PathBuf::from) + .or_else(|| home.as_ref().map(|home| home.join(".cache"))) + .ok_or_else(|| "HOME/XDG_CACHE_HOME is unavailable".to_string())? + .join("OpenLess"); + std::fs::create_dir_all(&data_dir).map_err(|error| error.to_string())?; + std::fs::create_dir_all(&cache_dir).map_err(|error| error.to_string())?; + let kind = package_kind(); + let capabilities = + LinuxCapabilitySnapshot::detect(tray_available, kind, updater_available).capabilities; + Ok(BackendConfig { + data_dir, + cache_dir, + home_dir: home, + resource_dir: std::env::current_exe() + .ok() + .and_then(|path| path.parent().map(std::path::Path::to_path_buf)), + platform: capabilities, + locale: std::env::var("LANG").unwrap_or_else(|_| "en-US".to_string()), + }) + } + + fn ensure_fcitx5_ready(config: &BackendConfig) -> Result<(), String> { + let home = config + .home_dir + .as_deref() + .ok_or_else(|| "HOME is unavailable for the fcitx5 plugin".to_string())?; + let layout = LinuxResourceLayout::detect(None).map_err(|error| error.to_string())?; + let plan = + FcitxPluginInstallPlan::for_layout(&layout, home).map_err(|error| error.to_string())?; + let status = ensure_fcitx5_plugin_installed(&plan).map_err(|error| error.to_string())?; + reconcile_fcitx5_install(status) + } + + /// Map an fcitx5 addon install result onto startup. + /// + /// A ready addon lets startup continue down the normal fcitx5 DBus path — + /// never a global-hotkey fallback — and only a genuinely missing plugin + /// aborts startup. + fn reconcile_fcitx5_install(status: FcitxPluginStatus) -> Result<(), String> { + match status { + FcitxPluginStatus::Ready => Ok(()), + FcitxPluginStatus::Missing => { + Err("未找到 OpenLess fcitx5 插件;请重新安装当前软件包".to_string()) + } + } + } + + struct NativePopupApp { + kind: PopupKind, + state: PopupState, + incoming: mpsc::Receiver, + outgoing: mpsc::Sender, + qa_input: String, + outgoing_sequence: u64, + ready_sent: bool, + preview_focus_requested: bool, + lang: Lang, + } + + impl NativePopupApp { + fn send(&mut self, message: PopupToHost) { + if self.outgoing.send(message).is_err() { + eprintln!("OpenLess popup output channel closed"); + } + } + + fn next_sequence(&mut self) -> u64 { + self.outgoing_sequence = self.outgoing_sequence.saturating_add(1); + self.outgoing_sequence + } + + fn session_id(&self) -> Option { + self.state.session_id.clone() + } + + fn dismiss(&mut self, ctx: &egui::Context) { + let Some(session_id) = self.session_id() else { + ctx.send_viewport_cmd(egui::ViewportCommand::Close); + return; + }; + let version = POPUP_PROTOCOL_VERSION; + let sequence = self.next_sequence(); + let message = match self.kind { + PopupKind::Qa => PopupToHost::DismissQa { + version, + session_id, + sequence, + }, + PopupKind::Preview => PopupToHost::CancelPreview { + version, + session_id, + sequence, + }, + PopupKind::Capsule => PopupToHost::DismissCapsule { + version, + session_id, + sequence, + }, + }; + self.send(message); + ctx.send_viewport_cmd(egui::ViewportCommand::Close); + } + } + + fn popup_heading(ui: &mut egui::Ui, title: &str) { + let response = ui + .horizontal(|ui| ui.heading(title)) + .response + .interact(egui::Sense::drag()); + if response.drag_started() { + ui.ctx().send_viewport_cmd(egui::ViewportCommand::StartDrag); + } + } + + /// Lightweight Markdown renderer ported from #997. It intentionally covers + /// the structures emitted by QA without introducing a WebView dependency. + fn render_popup_markdown(ui: &mut egui::Ui, markdown: &str) { + let mut code = String::new(); + let mut in_code = false; + for line in markdown.lines() { + let trimmed = line.trim(); + if trimmed.starts_with("```") { + if in_code { + render_popup_code(ui, code.trim_end()); + code.clear(); + } + in_code = !in_code; + continue; + } + if in_code { + code.push_str(line); + code.push('\n'); + continue; + } + if trimmed.is_empty() { + ui.add_space(4.0); + continue; + } + let (text, size, strong, italics, bullet) = + if let Some(value) = trimmed.strip_prefix("### ") { + (value, 14.0, true, false, false) + } else if let Some(value) = trimmed.strip_prefix("## ") { + (value, 15.0, true, false, false) + } else if let Some(value) = trimmed.strip_prefix("# ") { + (value, 16.0, true, false, false) + } else if let Some(value) = trimmed.strip_prefix("> ") { + (value, 13.0, false, true, false) + } else if let Some(value) = trimmed + .strip_prefix("- ") + .or_else(|| trimmed.strip_prefix("* ")) + { + (value, 13.0, false, false, true) + } else { + (trimmed, 13.0, false, false, false) + }; + let display = if bullet { + format!("• {text}") + } else { + text.to_string() + }; + render_popup_inline(ui, &display, size, strong, italics); + } + if in_code && !code.is_empty() { + render_popup_code(ui, code.trim_end()); + } + } + + fn render_popup_code(ui: &mut egui::Ui, code: &str) { + egui::Frame::new() + .fill(theme::SURFACE_2) + .corner_radius(egui::CornerRadius::same(6)) + .inner_margin(egui::Margin::symmetric(8, 6)) + .show(ui, |ui| { + ui.add(egui::Label::new(egui::RichText::new(code).monospace().size(12.0)).wrap()); + }); + } + + fn render_popup_inline( + ui: &mut egui::Ui, + text: &str, + size: f32, + base_strong: bool, + base_italics: bool, + ) { + let mut job = egui::text::LayoutJob::default(); + job.wrap.max_width = ui.available_width(); + let mut rest = text; + while !rest.is_empty() { + let mut matched = false; + for (open, close, strong, italics, monospace) in [ + ("**", "**", true, false, false), + ("__", "__", true, false, false), + ("`", "`", false, false, true), + ("*", "*", false, true, false), + ("_", "_", false, true, false), + ] { + if let Some(after_open) = rest.strip_prefix(open) { + if let Some(end) = after_open.find(close) { + append_popup_text( + &mut job, + &after_open[..end], + size, + base_strong || strong, + base_italics || italics, + monospace, + ui, + ); + rest = &after_open[end + close.len()..]; + matched = true; + break; + } + } + } + if matched { + continue; + } + let next = ["**", "__", "`", "*", "_"] + .iter() + .filter_map(|marker| rest.find(marker)) + .min() + .unwrap_or(rest.len()); + let length = if next == 0 { + rest.chars().next().map(char::len_utf8).unwrap_or(0) + } else { + next + }; + append_popup_text( + &mut job, + &rest[..length], + size, + base_strong, + base_italics, + false, + ui, + ); + rest = &rest[length..]; + } + ui.add(egui::Label::new(job).wrap()); + } + + fn append_popup_text( + job: &mut egui::text::LayoutJob, + text: &str, + size: f32, + strong: bool, + italics: bool, + monospace: bool, + ui: &egui::Ui, + ) { + job.append( + text, + 0.0, + egui::TextFormat { + font_id: egui::FontId::new( + size, + if monospace { + egui::FontFamily::Monospace + } else { + egui::FontFamily::Proportional + }, + ), + color: if strong { + ui.visuals().strong_text_color() + } else { + ui.visuals().text_color() + }, + background: if monospace { + theme::SURFACE_2 + } else { + egui::Color32::TRANSPARENT + }, + italics, + ..Default::default() + }, + ); + } + + impl eframe::App for NativePopupApp { + fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { + while let Ok(message) = self.incoming.try_recv() { + if message + .content_kind() + .is_some_and(|message_kind| message_kind != self.kind) + { + continue; + } + if matches!(message, HostToPopup::Preview { .. }) { + self.preview_focus_requested = false; + } + let shutdown = matches!(message, HostToPopup::Shutdown { .. }); + let outcome = self.state.apply(message); + if outcome == openless_linux_egui::PopupApplyOutcome::Applied { + ctx.send_viewport_cmd(egui::ViewportCommand::Visible(self.state.visible)); + } + if shutdown || self.state.shutdown_requested { + ctx.send_viewport_cmd(egui::ViewportCommand::Close); + return; + } + } + if !self.ready_sent { + if let Some(session_id) = self.session_id() { + let sequence = self.next_sequence(); + self.send(PopupToHost::Ready { + version: POPUP_PROTOCOL_VERSION, + session_id, + sequence, + kind: self.kind, + }); + self.ready_sent = true; + } + } + if ctx.input(|input| input.key_pressed(egui::Key::Escape)) { + self.dismiss(ctx); + return; + } + let lang = self.lang; + egui::CentralPanel::default() + .frame( + egui::Frame::NONE + .fill(theme::SURFACE) + .corner_radius(egui::CornerRadius::same(12)) + .inner_margin(egui::Margin::same(18)), + ) + .show(ctx, |ui| match self.kind { + PopupKind::Preview => { + popup_heading(ui, tr_l10n(lang, "heading.insert_preview")); + ui.label(&self.state.preview.source); + let editor = ui.add( + egui::TextEdit::multiline(&mut self.state.preview.text) + .desired_rows(6) + .desired_width(f32::INFINITY), + ); + if !self.preview_focus_requested { + editor.request_focus(); + self.preview_focus_requested = true; + } + ui.horizontal(|ui| { + if ui.button(tr_l10n(lang, "btn.cancel")).clicked() { + self.dismiss(ctx); + } + if ui.button(tr_l10n(lang, "btn.insert")).clicked() { + if let Some(session_id) = self.session_id() { + let sequence = self.next_sequence(); + self.send(PopupToHost::ConfirmPreview { + version: POPUP_PROTOCOL_VERSION, + session_id, + sequence, + text: self.state.preview.text.clone(), + }); + ctx.send_viewport_cmd(egui::ViewportCommand::Close); + } + } + }); + } + PopupKind::Qa => { + popup_heading(ui, tr_l10n(lang, "heading.qa_preview")); + if let Some(selection) = &self.state.qa.selection_preview { + ui.label(egui::RichText::new(selection).italics().color(theme::INK_3)); + } + egui::ScrollArea::vertical() + .max_height(300.0) + .show(ui, |ui| { + for message in &self.state.qa.messages { + ui.label(egui::RichText::new(&message.role).strong()); + render_popup_markdown(ui, &message.content); + } + if !self.state.qa.streaming_answer.is_empty() { + render_popup_markdown(ui, &self.state.qa.streaming_answer); + } + if let Some(error) = &self.state.qa.error { + ui.colored_label(egui::Color32::RED, error); + } + }); + let input = ui.text_edit_singleline(&mut self.qa_input); + ui.horizontal(|ui| { + if ui.button(tr_l10n(lang, "btn.close")).clicked() { + self.dismiss(ctx); + } + if ui + .button(if self.state.qa.phase == "Recording" { + tr_l10n(lang, "btn.stop_recording") + } else { + tr_l10n(lang, "btn.voice_ask") + }) + .clicked() + { + if let Some(session_id) = self.session_id() { + let sequence = self.next_sequence(); + self.send(PopupToHost::ToggleQaRecording { + version: POPUP_PROTOCOL_VERSION, + session_id, + sequence, + }); + } + } + let submit = ui.button(tr_l10n(lang, "btn.send")).clicked() + || (input.lost_focus() + && ui.input(|state| state.key_pressed(egui::Key::Enter))); + if submit && !self.qa_input.trim().is_empty() { + if let Some(session_id) = self.session_id() { + let sequence = self.next_sequence(); + let text = std::mem::take(&mut self.qa_input); + self.send(PopupToHost::SubmitQa { + version: POPUP_PROTOCOL_VERSION, + session_id, + sequence, + text, + }); + } + } + }); + } + PopupKind::Capsule => { + let response = ui + .horizontal(|ui| { + ui.spinner(); + ui.strong(&self.state.capsule.phase); + }) + .response + .interact(egui::Sense::drag()); + if response.drag_started() { + ui.ctx().send_viewport_cmd(egui::ViewportCommand::StartDrag); + } + if !self.state.capsule.text.is_empty() { + ui.label(&self.state.capsule.text); + } + if let Some(level) = self.state.capsule.audio_level { + ui.add(egui::ProgressBar::new(level.clamp(0.0, 1.0))); + } + } + }); + ctx.request_repaint_after(Duration::from_millis(33)); + } + } + + fn popup_kind(args: &[String]) -> Option { + if !args.iter().any(|arg| arg == "--openless-egui-popup") { + return None; + } + if args.iter().any(|arg| arg == "--qa") { + Some(PopupKind::Qa) + } else if args.iter().any(|arg| arg == "--preview") { + Some(PopupKind::Preview) + } else if args.iter().any(|arg| arg == "--capsule") { + Some(PopupKind::Capsule) } else { - LinuxPackageKind::SystemPackage + None } } - fn backend_config() -> Result { - let home = std::env::var_os("HOME").map(std::path::PathBuf::from); - let data_dir = std::env::var_os("XDG_DATA_HOME") - .map(std::path::PathBuf::from) - .or_else(|| home.as_ref().map(|home| home.join(".local/share"))) - .ok_or_else(|| "HOME/XDG_DATA_HOME is unavailable".to_string())? - .join("OpenLess"); - let cache_dir = std::env::var_os("XDG_CACHE_HOME") - .map(std::path::PathBuf::from) - .or_else(|| home.as_ref().map(|home| home.join(".cache"))) - .ok_or_else(|| "HOME/XDG_CACHE_HOME is unavailable".to_string())? - .join("OpenLess"); - std::fs::create_dir_all(&data_dir).map_err(|error| error.to_string())?; - std::fs::create_dir_all(&cache_dir).map_err(|error| error.to_string())?; - let kind = package_kind(); - let capabilities = LinuxCapabilitySnapshot::detect(false, kind).capabilities; - Ok(BackendConfig { - data_dir, - cache_dir, - home_dir: home, - resource_dir: std::env::current_exe() - .ok() - .and_then(|path| path.parent().map(std::path::Path::to_path_buf)), - platform: capabilities, - locale: std::env::var("LANG").unwrap_or_else(|_| "en-US".to_string()), - }) - } - - fn ensure_fcitx5_ready(config: &BackendConfig) -> Result { - let home = config - .home_dir - .as_deref() - .ok_or_else(|| "HOME is unavailable for the fcitx5 plugin".to_string())?; - let layout = LinuxResourceLayout::detect(None).map_err(|error| error.to_string())?; - let plan = - FcitxPluginInstallPlan::for_layout(&layout, home).map_err(|error| error.to_string())?; - ensure_fcitx5_plugin_installed(&plan).map_err(|error| error.to_string()) + fn run_popup_process(kind: PopupKind) -> Result<(), String> { + let (tx, rx) = mpsc::sync_channel(256); + std::thread::Builder::new() + .name("openless-popup-input".into()) + .spawn(move || { + let stdin = std::io::stdin(); + let mut reader = std::io::BufReader::new(stdin.lock()); + if let Err(error) = openless_linux_egui::run_popup(&mut reader, |message| { + let _ = tx.send(message); + }) { + eprintln!("OpenLess popup input failed: {error}"); + } + }) + .map_err(|error| error.to_string())?; + let (outgoing_tx, outgoing_rx) = mpsc::channel::(); + std::thread::Builder::new() + .name("openless-popup-output".into()) + .spawn(move || { + let stdout = std::io::stdout(); + let mut writer = stdout.lock(); + while let Ok(message) = outgoing_rx.recv() { + if let Err(error) = write_jsonl(&mut writer, &message) { + eprintln!("OpenLess popup output failed: {error}"); + break; + } + } + }) + .map_err(|error| error.to_string())?; + let size = match kind { + PopupKind::Qa => [520.0, 520.0], + PopupKind::Preview => [480.0, 300.0], + PopupKind::Capsule => [340.0, 112.0], + }; + let options = eframe::NativeOptions { + viewport: egui::ViewportBuilder::default() + .with_title("OpenLess") + .with_inner_size(size) + .with_decorations(false) + .with_always_on_top() + .with_visible(false), + ..Default::default() + }; + eframe::run_native( + "OpenLess Popup", + options, + Box::new(move |cc| { + theme::install(&cc.egui_ctx); + Ok(Box::new(NativePopupApp { + kind, + state: PopupState::default(), + incoming: rx, + outgoing: outgoing_tx, + qa_input: String::new(), + outgoing_sequence: 0, + ready_sent: false, + preview_focus_requested: false, + // The popup is a separate process, so it re-reads the + // persisted UI-locale preference rather than sharing state. + lang: load_locale_pref().resolve(), + })) + }), + ) + .map_err(|error| error.to_string()) } pub fn run() -> Result<(), String> { + let args = std::env::args().collect::>(); + if let Some(kind) = popup_kind(&args) { + return run_popup_process(kind); + } + let start_minimized = args.iter().any(|arg| arg == "--minimized"); let tokio = Arc::new(tokio::runtime::Runtime::new().map_err(|error| error.to_string())?); - let config = backend_config()?; + let tray = openless_linux_egui::LinuxTray::start().ok(); + let tray_available = tray.is_some(); + let kind = package_kind(); + let update_support = LinuxUpdateSupport::initialize(kind); + let updater_available = update_support.supports_auto_update(); + let config = backend_config(tray_available, updater_available)?; + if let Err(error) = openless_linux_egui::init_file_logger(&config.data_dir) { + eprintln!("OpenLess file logger unavailable: {error}"); + } let runtime_dir = std::env::var_os("XDG_RUNTIME_DIR") .map(std::path::PathBuf::from) .unwrap_or_else(|| config.cache_dir.join("runtime")); - let args = std::env::args().collect::>(); let broker = match SingleInstanceBroker::acquire_or_forward( &runtime_dir.join("openless.lock"), &runtime_dir.join("openless.sock"), @@ -2683,23 +6728,12 @@ mod linux_app { SingleInstanceRole::Primary(broker) => broker, SingleInstanceRole::Forwarded => return Ok(()), }; - let plugin_check = ensure_fcitx5_ready(&config); - let environment = LinuxCapabilitySnapshot::detect(false, package_kind()); let native = (|| { // AppImage may need to materialize its bundled plugin into the // per-user fcitx5 search path. Do that before opening the DBus // listener: otherwise the first run can wait forever for signals // from a plugin fcitx5 has never loaded. - match &plugin_check { - Ok(FcitxPluginStatus::Ready) => {} - Ok(FcitxPluginStatus::Updated) => return Err( - "fcitx5 插件已安装或更新;请重载配置(fcitx5-remote -r),重新启动 fcitx5 或重新登录桌面,再启动 OpenLess".to_string() - ), - Ok(FcitxPluginStatus::Missing) => return Err( - "未找到 OpenLess fcitx5 插件;请重新安装当前软件包".to_string() - ), - Err(error) => return Err(error.clone()), - } + ensure_fcitx5_ready(&config)?; let hotkeys = Fcitx5HotkeyListener::start().map_err(|error| error.to_string())?; let backend = { // Construction captures the existing executor for cpal/native @@ -2721,18 +6755,26 @@ mod linux_app { })(); let options = eframe::NativeOptions { viewport: egui::ViewportBuilder::default() - .with_inner_size([1040.0, 760.0]) - .with_min_inner_size([420.0, 400.0]), + .with_title("OpenLess") + .with_inner_size([1240.0, 800.0]) + .with_min_inner_size([960.0, 640.0]) + .with_decorations(false) + .with_transparent(true) + .with_resizable(true) + .with_visible(!start_minimized || !tray_available), ..Default::default() }; eframe::run_native( "OpenLess", options, - Box::new(move |_| { - let mut app = OpenLessEguiApp::new(tokio, native); - app.environment = Some(environment); - app.plugin_check = Some(plugin_check); - Ok(Box::new(app)) + Box::new(move |cc| { + theme::install(&cc.egui_ctx); + Ok(Box::new(OpenLessEguiApp::new( + tokio, + native, + tray, + update_support, + ))) }), ) .map_err(|error| error.to_string()) @@ -2742,284 +6784,15 @@ mod linux_app { mod tests { use super::*; - fn disconnected_app() -> OpenLessEguiApp { - OpenLessEguiApp::new( - Arc::new(tokio::runtime::Runtime::new().unwrap()), - Err("fixture: plugin unavailable".into()), - ) - } - - fn rendered_text(mut draw: impl FnMut(&mut egui::Ui)) -> String { - let ctx = egui::Context::default(); - let output = ctx.run( - egui::RawInput { - screen_rect: Some(egui::Rect::from_min_size( - egui::Pos2::ZERO, - egui::vec2(720.0, 1800.0), - )), - ..Default::default() - }, - |ctx| { - egui::CentralPanel::default().show(ctx, |ui| { - draw(ui); - }); - }, - ); - output - .shapes - .into_iter() - .filter_map(|shape| match shape.shape { - egui::epaint::Shape::Text(text) => Some(text.galley.job.text.clone()), - _ => None, - }) - .collect::>() - .join("\n") - } - - #[test] - fn start_page_pipeline_multimodal_uses_only_omni_configuration() { - use openless_core::shared_types::PipelineMode; - - let mut app = disconnected_app(); - // A pending settings draft must not override Core's effective mode. - app.preferences = Some(UserPreferences { - multimodal_pipeline_enabled: false, - pipeline_mode: PipelineMode::Traditional, - ..Default::default() - }); - for omni_configured in [true, false] { - for (asr_configured, llm_configured) in - [(false, false), (true, false), (false, true), (true, true)] - { - app.snapshot = Some(BackendSnapshot { - credentials: openless_core::shared_types::CredentialsStatus { - pipeline_mode: PipelineMode::Multimodal, - omni_configured, - asr_configured, - llm_configured, - ..Default::default() - }, - ..Default::default() - }); - let text = rendered_text(|ui| app.start_ui(ui)); - let expected = if omni_configured { - "Omni:已配置" - } else { - "Omni:尚未配置" - }; - assert!(text.contains(expected), "missing {expected}: {text}"); - assert!(!text.contains("语音识别:尚未配置"), "{text}"); - assert!(!text.contains("ASR 语音识别:"), "{text}"); - assert!(!text.contains("LLM 润色:"), "{text}"); - assert!(text.contains("已配置不代表校验通过"), "{text}"); - } - } - } - - #[test] - fn start_page_pipeline_traditional_reports_asr_and_llm_independently() { - use openless_core::shared_types::PipelineMode; - - let mut app = disconnected_app(); - // Conversely, a multimodal draft must not hide the effective - // traditional pipeline's missing ASR or LLM configuration. - app.preferences = Some(UserPreferences { - multimodal_pipeline_enabled: true, - pipeline_mode: PipelineMode::Multimodal, - ..Default::default() - }); - for omni_configured in [true, false] { - for (asr_configured, llm_configured) in - [(false, false), (true, false), (false, true), (true, true)] - { - app.snapshot = Some(BackendSnapshot { - credentials: openless_core::shared_types::CredentialsStatus { - pipeline_mode: PipelineMode::Traditional, - omni_configured, - asr_configured, - llm_configured, - ..Default::default() - }, - ..Default::default() - }); - let text = rendered_text(|ui| app.start_ui(ui)); - for expected in [ - if asr_configured { - "ASR 语音识别:已配置" - } else { - "ASR 语音识别:尚未配置" - }, - if llm_configured { - "LLM 润色:已配置" - } else { - "LLM 润色:尚未配置" - }, - ] { - assert!(text.contains(expected), "missing {expected}: {text}"); - } - assert!(!text.contains("Omni:"), "{text}"); - assert!(text.contains("已配置不代表校验通过"), "{text}"); - } - } - } - - #[test] - fn background_approval_survives_navigation_and_stale_terminals() { - let mut app = disconnected_app(); - let session = openless_core::SessionId::new(); - for (sequence, kind) in [ - ( - 1, - LessComputerEventKind::User { - text: "task".into(), - fresh: true, - }, - ), - ( - 2, - LessComputerEventKind::Approval { - token: "approval".into(), - command: "echo test".into(), - reason: "fixture".into(), - }, - ), - ] { - app.apply_event(BackendEvent { - sequence, - session_id: Some(session), - kind: BackendEventKind::LessComputerEvent(openless_core::LessComputerEvent { - seq: None, - kind, - }), - }); - } - for page in Page::ALL { - app.navigation.open(page); - assert_eq!(app.page_activity(Page::Agent), Some("待审批")); - let text = rendered_text(|ui| { - app.activity_ui(ui); - app.agent_approval_ui(ui); - }); - for control in ["允许", "拒绝", "取消 Agent"] { - assert!( - text.contains(control), - "missing {control} on {page:?}: {text}" - ); - } - } - app.apply_event(BackendEvent { - sequence: 3, - session_id: Some(openless_core::SessionId::new()), - kind: BackendEventKind::LessComputerEvent(openless_core::LessComputerEvent { - seq: None, - kind: LessComputerEventKind::Cancelled, - }), - }); - assert_eq!( - app.pending_approval, - Some(("approval".into(), "echo test".into())) - ); - assert!(app.less_computer_running); - } - - #[test] - fn qa_and_selection_events_on_settings_keep_drafts_and_action_notices() { - let mut app = disconnected_app(); - app.navigation.open(Page::Settings); - app.qa_input = "unsent question".into(); - let qa_session = openless_core::SessionId::new(); - let mut thinking = QaStateEvent::simple(QaStateKind::Thinking); - thinking.session_id = Some(qa_session.to_string()); - app.apply_event(BackendEvent { - sequence: 1, - session_id: Some(qa_session), - kind: BackendEventKind::QaState(thinking), - }); - let selection_session = openless_core::SessionId::new(); - app.apply_event(BackendEvent { - sequence: 2, - session_id: Some(selection_session), - kind: BackendEventKind::SelectionStateChanged(SelectionSnapshot { - phase: SelectionPhase::Preview, - session_id: Some(selection_session), - preview_text: Some("editable preview".into()), - ..Default::default() - }), - }); - assert_eq!(app.navigation.page, Page::Settings); - assert!(app.navigation.has_update(Page::Qa)); - assert_eq!(app.page_activity(Page::Selection), Some("待确认")); - app.selection_draft = "user edited preview".into(); - app.navigation.open(Page::Qa); - app.navigation.open(Page::Selection); - app.navigation.open(Page::Models); - assert_eq!(app.qa_input, "unsent question"); - assert_eq!(app.selection_draft, "user edited preview"); - assert_eq!( - app.selection.as_ref().unwrap().session_id, - Some(selection_session) - ); - let text = rendered_text(|ui| app.activity_ui(ui)); - assert!(text.contains("取消选区预览"), "{text}"); - } - - #[test] - fn startup_failure_keeps_preparation_steps_without_claiming_connection() { - let mut app = disconnected_app(); - app.environment = Some(LinuxCapabilitySnapshot::from_environment( - Some("wayland-0"), - None, - false, - false, - LinuxPackageKind::AppImage, - )); - app.plugin_check = Some(Ok(FcitxPluginStatus::Updated)); - let text = rendered_text(|ui| app.start_ui(ui)); - for expected in [ - "Core 未连接", - "Wayland", - "D-Bus 探测未通过", - "尚未验证录音", - "fcitx5-diagnose", - "fcitx5-remote -r", - "Secret Service", - ] { - assert!(text.contains(expected), "missing {expected}: {text}"); - } - assert!(!text.contains("Core 已连接")); - assert!(!text.contains("Core:运行中")); - } - - #[test] - fn stopped_or_stale_remote_status_never_shows_pairing_secrets_or_old_urls() { - let mut app = disconnected_app(); - for (running, urls_stale) in [(false, false), (true, true)] { - app.remote_access = Some(( - openless_core::RemoteInputStatus { - enabled: true, - running, - starting: false, - port: 8443, - urls: vec!["https://old.example.invalid".into()], - urls_stale, - locale: "en".into(), - connection_count: 0, - active_session_id: None, - }, - "fixture-pin".into(), - )); - let text = rendered_text(|ui| app.remote_ui(ui)); - assert!(!text.contains("fixture-pin"), "{text}"); - assert!(!text.contains("https://old.example.invalid"), "{text}"); - assert!(text.contains("当前连接数:0"), "{text}"); - } - } - #[test] fn continuation_turn_keeps_receiving_output_and_approval() { let mut app = OpenLessEguiApp::new( Arc::new(tokio::runtime::Runtime::new().unwrap()), Err("fixture".into()), + None, + LinuxUpdateSupport::ManualOnly { + releases_url: openless_linux_egui::RELEASES_URL, + }, ); let first = openless_core::SessionId::new(); let second = openless_core::SessionId::new(); @@ -3094,6 +6867,10 @@ mod linux_app { let mut app = OpenLessEguiApp::new( Arc::new(tokio::runtime::Runtime::new().unwrap()), Err("fixture".into()), + None, + LinuxUpdateSupport::ManualOnly { + releases_url: openless_linux_egui::RELEASES_URL, + }, ); let session = openless_core::SessionId::new(); let mut thinking = QaStateEvent::simple(QaStateKind::Thinking); @@ -3122,6 +6899,279 @@ mod linux_app { assert_eq!(state.chunk.as_deref(), Some("Hello world")); assert_eq!(state.messages.as_ref().unwrap()[0].content, "question"); } + + #[test] + fn settings_conflict_merge_preserves_only_dirty_draft_fields() { + let latest = UserPreferences { + remote_input_port: 9443, + streaming_insert: false, + ..Default::default() + }; + let draft = UserPreferences { + remote_input_port: 7777, + streaming_insert: true, + ..Default::default() + }; + let dirty = SettingsDirty { + streaming_insert: true, + ..Default::default() + }; + + let merged = dirty.merge(&latest, &draft); + + assert!(merged.streaming_insert); + assert_eq!(merged.remote_input_port, 9443); + } + + #[test] + fn style_pack_hotkey_update_preserves_other_pack_bindings() { + let mut preferences = UserPreferences::default(); + let first = openless_core::shared_types::ShortcutBinding { + primary: "1".into(), + modifiers: vec!["ctrl".into()], + }; + let second = openless_core::shared_types::ShortcutBinding { + primary: "2".into(), + modifiers: vec!["alt".into()], + }; + set_style_pack_hotkey(&mut preferences, "first", Some(first.clone())); + set_style_pack_hotkey(&mut preferences, "second", Some(second.clone())); + set_style_pack_hotkey(&mut preferences, "first", None); + + assert_eq!(preferences.style_pack_hotkeys.len(), 1); + assert_eq!(preferences.style_pack_hotkeys[0].pack_id, "second"); + assert_eq!(preferences.style_pack_hotkeys[0].binding, second); + } + + #[test] + fn settings_conflict_merge_preserves_hotkey_drafts_as_one_domain() { + let latest = UserPreferences::default(); + let mut draft = latest.clone(); + draft.open_app_hotkey = Some(openless_core::shared_types::ShortcutBinding { + primary: "O".into(), + modifiers: vec!["ctrl".into(), "shift".into()], + }); + let dirty = SettingsDirty { + hotkeys: true, + ..Default::default() + }; + + let merged = dirty.merge(&latest, &draft); + + assert_eq!(merged.open_app_hotkey, draft.open_app_hotkey); + } + + #[test] + fn settings_conflict_merge_preserves_recording_device_and_appearance_domains() { + let latest = UserPreferences { + remote_input_port: 9443, + ..Default::default() + }; + let mut draft = latest.clone(); + draft.hotkey.mode = openless_core::shared_types::HotkeyMode::Auto; + draft.silence_auto_stop_enabled = true; + draft.silence_auto_stop_seconds = 1.5; + draft.mute_during_recording = true; + draft.audio_cue_on_record = false; + draft.microphone_device_name = "USB microphone".into(); + draft.theme_mode = openless_core::shared_types::ThemeMode::Dark; + draft.show_overview_activity_heatmap = false; + draft.remote_input_port = 7777; + let dirty = SettingsDirty { + recording: true, + microphone: true, + appearance: true, + ..Default::default() + }; + + let merged = dirty.merge(&latest, &draft); + + assert_eq!(merged.hotkey.mode, draft.hotkey.mode); + assert!(merged.silence_auto_stop_enabled); + assert_eq!(merged.silence_auto_stop_seconds, 1.5); + assert!(merged.mute_during_recording); + assert!(!merged.audio_cue_on_record); + assert_eq!(merged.microphone_device_name, "USB microphone"); + assert_eq!( + merged.theme_mode, + openless_core::shared_types::ThemeMode::Dark + ); + assert!(!merged.show_overview_activity_heatmap); + assert_eq!(merged.remote_input_port, 9443); + } + + #[test] + fn ready_install_needs_no_reload_but_continues() { + assert!(reconcile_fcitx5_install(FcitxPluginStatus::Ready).is_ok()); + } + + #[test] + fn missing_install_aborts_without_reloading() { + let error = reconcile_fcitx5_install(FcitxPluginStatus::Missing) + .expect_err("a missing plugin must abort startup"); + assert!( + error.contains("OpenLess fcitx5 插件"), + "unexpected Missing message: {error}" + ); + } + + // ---- Overview summary (Tauri parity) ----------------------------- + + fn session_entry( + created_at: &str, + final_text: &str, + duration_ms: Option, + ) -> openless_core::DictationSession { + openless_core::DictationSession { + id: String::new(), + created_at: created_at.to_string(), + source: openless_core::HistorySource::Voice, + raw_transcript: String::new(), + asr_transcript: None, + final_text: final_text.to_string(), + mode: openless_core::PolishMode::Raw, + style_pack_id: None, + translation_active: false, + polish_source: None, + app_bundle_id: None, + app_name: None, + insert_status: openless_core::HistoryInsertStatus::Inserted, + error_code: None, + duration_ms, + dictionary_entry_count: None, + has_audio_recording: None, + asr_provider: None, + asr_model: None, + llm_provider: None, + llm_model: None, + pipeline_mode: None, + asr_ms: None, + polish_ms: None, + } + } + + fn activity_day(date: &str, count: u32) -> openless_core::ActivityDay { + openless_core::ActivityDay { + date: date.to_string(), + count, + chars: 0, + duration_ms: 0, + } + } + + #[test] + fn overview_metrics_aggregate_only_today_from_history() { + let now = chrono::Local::now(); + let today = now.date_naive(); + let history = vec![ + session_entry(&now.to_rfc3339(), "今天第一句", Some(2000)), + session_entry( + &(now - chrono::Duration::days(1)).to_rfc3339(), + "昨天", + Some(999), + ), + session_entry( + &(now - chrono::Duration::days(2)).to_rfc3339(), + "前天", + None, + ), + ]; + let credentials = openless_core::CredentialsStatus { + active_asr_provider: "volcengine".to_string(), + active_llm_provider: "ark".to_string(), + asr_configured: true, + ..Default::default() + }; + + let summary = overview_summary( + &OverviewData { + credentials, + history, + activity: Vec::new(), + }, + today, + ); + + assert_eq!(summary.segments_today, 1, "only today's entry counts"); + assert_eq!(summary.chars_today, 5, "今日第一句 has 5 chars"); + assert_eq!(summary.duration_ms_today, 2000); + assert_eq!(summary.avg_latency_ms, 2000); + assert_eq!(summary.history_total, 3); + assert_eq!(summary.asr_provider, "volcengine"); + assert!(summary.asr_configured); + assert!(!summary.llm_configured); + assert_eq!(summary.recent.len(), 3, "newest three retained"); + assert_eq!( + summary.recent[0].final_text, "今天第一句", + "recent list is newest-first" + ); + assert_eq!(summary.recent[0].duration_ms, Some(2000)); + } + + #[test] + fn overview_activity_windows_and_heatmap_are_windowed_by_date() { + let today = chrono::NaiveDate::from_ymd_opt(2026, 1, 15).unwrap(); + let credentials = openless_core::CredentialsStatus::default(); + let activity = vec![ + activity_day("2026-01-15", 5), + activity_day("2026-01-08", 2), + activity_day("2026-01-01", 3), + activity_day("2025-06-01", 9), + ]; + + let summary = overview_summary( + &OverviewData { + credentials, + history: Vec::new(), + activity, + }, + today, + ); + + // Last-7 window covers only Jan 15. + assert_eq!(summary.last_7.active_days, 1); + assert_eq!(summary.last_7.segments, 5); + // Last-30 window covers Jan 15, Jan 8 and Jan 1. + assert_eq!(summary.last_30.active_days, 3); + assert_eq!(summary.last_30.segments, 10); + assert_eq!(summary.activity_days_total, 4); + + // The trailing 364-day heatmap sums every in-window day. + let heat_total: u32 = summary + .heatmap_weeks + .iter() + .flat_map(|week| week.iter()) + .sum(); + assert_eq!(heat_total, 19); + assert_eq!(summary.heatmap_days, 364); + } + + #[test] + fn overview_heatmap_excludes_days_outside_trailing_window() { + let today = chrono::NaiveDate::from_ymd_opt(2026, 1, 15).unwrap(); + let far = (today - chrono::Duration::days(400)) + .format("%Y-%m-%d") + .to_string(); + let summary = overview_summary( + &OverviewData { + credentials: openless_core::CredentialsStatus::default(), + history: Vec::new(), + activity: vec![activity_day("2026-01-15", 3), activity_day(&far, 7)], + }, + today, + ); + + let heat_total: u32 = summary + .heatmap_weeks + .iter() + .flat_map(|week| week.iter()) + .sum(); + assert_eq!( + heat_total, 3, + "days older than the trailing window must not appear in the heatmap" + ); + assert_eq!(summary.activity_days_total, 2); + } } } diff --git a/openless-all/app/linux-egui/src/popup.rs b/openless-all/app/linux-egui/src/popup.rs new file mode 100644 index 000000000..8e9a1c508 --- /dev/null +++ b/openless-all/app/linux-egui/src/popup.rs @@ -0,0 +1,1018 @@ +//! Native popup process protocol and lifecycle management. +//! +//! The egui frame must never own or wait for a child process. [`PopupSupervisor`] +//! moves the child, its pipes and all waiting into Tokio tasks and exposes only +//! non-blocking `try_*` methods to the UI thread. + +use std::collections::HashSet; +use std::fmt; +use std::io::{BufRead, Write}; +use std::path::Path; +use std::process::Stdio; +use std::sync::mpsc::{self, Receiver}; + +use serde::de::DeserializeOwned; +use serde::{Deserialize, Serialize}; +use tokio::io::{AsyncBufRead, AsyncBufReadExt, AsyncWriteExt, BufReader}; +use tokio::process::Command; +use tokio::runtime::Handle; +use tokio::sync::mpsc as tokio_mpsc; + +pub const POPUP_PROTOCOL_VERSION: u16 = 1; +pub const MAX_JSONL_LINE_BYTES: usize = 1024 * 1024; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PopupKind { + Qa, + Preview, + Capsule, +} + +impl PopupKind { + pub fn argument(self) -> &'static str { + match self { + Self::Qa => "--qa", + Self::Preview => "--preview", + Self::Capsule => "--capsule", + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PopupChatMessage { + pub role: String, + pub content: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub selection_text: Option, +} + +/// Messages written by the Linux host to a popup's stdin. +/// +/// Every variant is independently versioned and ordered. This deliberately +/// avoids an unversioned outer envelope that can accidentally be discarded by +/// a future enum deserializer. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum HostToPopup { + Preview { + version: u16, + session_id: String, + sequence: u64, + text: String, + source: String, + }, + QaSnapshot { + version: u16, + session_id: String, + sequence: u64, + phase: String, + messages: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + selection_preview: Option, + #[serde(default)] + streaming_answer: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + error: Option, + }, + Capsule { + version: u16, + session_id: String, + sequence: u64, + phase: String, + #[serde(default)] + text: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + audio_level: Option, + }, + Hide { + version: u16, + session_id: String, + sequence: u64, + }, + Shutdown { + version: u16, + session_id: String, + sequence: u64, + }, +} + +impl HostToPopup { + pub fn version(&self) -> u16 { + match self { + Self::Preview { version, .. } + | Self::QaSnapshot { version, .. } + | Self::Capsule { version, .. } + | Self::Hide { version, .. } + | Self::Shutdown { version, .. } => *version, + } + } + + pub fn session_id(&self) -> &str { + match self { + Self::Preview { session_id, .. } + | Self::QaSnapshot { session_id, .. } + | Self::Capsule { session_id, .. } + | Self::Hide { session_id, .. } + | Self::Shutdown { session_id, .. } => session_id, + } + } + + pub fn sequence(&self) -> u64 { + match self { + Self::Preview { sequence, .. } + | Self::QaSnapshot { sequence, .. } + | Self::Capsule { sequence, .. } + | Self::Hide { sequence, .. } + | Self::Shutdown { sequence, .. } => *sequence, + } + } + + pub fn content_kind(&self) -> Option { + match self { + Self::Preview { .. } => Some(PopupKind::Preview), + Self::QaSnapshot { .. } => Some(PopupKind::Qa), + Self::Capsule { .. } => Some(PopupKind::Capsule), + Self::Hide { .. } | Self::Shutdown { .. } => None, + } + } +} + +/// Actions written by a popup to the host's stdout. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum PopupToHost { + Ready { + version: u16, + session_id: String, + sequence: u64, + kind: PopupKind, + }, + ConfirmPreview { + version: u16, + session_id: String, + sequence: u64, + text: String, + }, + CancelPreview { + version: u16, + session_id: String, + sequence: u64, + }, + SubmitQa { + version: u16, + session_id: String, + sequence: u64, + text: String, + }, + ToggleQaRecording { + version: u16, + session_id: String, + sequence: u64, + }, + DismissQa { + version: u16, + session_id: String, + sequence: u64, + }, + DismissCapsule { + version: u16, + session_id: String, + sequence: u64, + }, +} + +impl PopupToHost { + pub fn version(&self) -> u16 { + match self { + Self::Ready { version, .. } + | Self::ConfirmPreview { version, .. } + | Self::CancelPreview { version, .. } + | Self::SubmitQa { version, .. } + | Self::ToggleQaRecording { version, .. } + | Self::DismissQa { version, .. } + | Self::DismissCapsule { version, .. } => *version, + } + } + + pub fn session_id(&self) -> &str { + match self { + Self::Ready { session_id, .. } + | Self::ConfirmPreview { session_id, .. } + | Self::CancelPreview { session_id, .. } + | Self::SubmitQa { session_id, .. } + | Self::ToggleQaRecording { session_id, .. } + | Self::DismissQa { session_id, .. } + | Self::DismissCapsule { session_id, .. } => session_id, + } + } + + pub fn sequence(&self) -> u64 { + match self { + Self::Ready { sequence, .. } + | Self::ConfirmPreview { sequence, .. } + | Self::CancelPreview { sequence, .. } + | Self::SubmitQa { sequence, .. } + | Self::ToggleQaRecording { sequence, .. } + | Self::DismissQa { sequence, .. } + | Self::DismissCapsule { sequence, .. } => *sequence, + } + } + + pub fn kind(&self) -> PopupKind { + match self { + Self::Ready { kind, .. } => *kind, + Self::ConfirmPreview { .. } | Self::CancelPreview { .. } => PopupKind::Preview, + Self::SubmitQa { .. } | Self::ToggleQaRecording { .. } | Self::DismissQa { .. } => { + PopupKind::Qa + } + Self::DismissCapsule { .. } => PopupKind::Capsule, + } + } +} + +#[derive(Debug, Default, Clone, PartialEq, Eq)] +struct PopupActionSlot { + session_id: Option, + sequence: u64, +} + +/// Rejects stale, cross-session and cross-kind actions received from popup +/// children. Each newly spawned process resets only its own sequence domain. +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub struct PopupActionGuard { + qa: PopupActionSlot, + preview: PopupActionSlot, + capsule: PopupActionSlot, +} + +impl PopupActionGuard { + fn slot_mut(&mut self, kind: PopupKind) -> &mut PopupActionSlot { + match kind { + PopupKind::Qa => &mut self.qa, + PopupKind::Preview => &mut self.preview, + PopupKind::Capsule => &mut self.capsule, + } + } + + pub fn reset(&mut self, kind: PopupKind) { + *self.slot_mut(kind) = PopupActionSlot::default(); + } + + pub fn accept( + &mut self, + process_kind: PopupKind, + message: &PopupToHost, + expected_session_id: &str, + ) -> bool { + if message.version() != POPUP_PROTOCOL_VERSION + || message.kind() != process_kind + || message.session_id() != expected_session_id + { + return false; + } + let slot = self.slot_mut(process_kind); + if slot.session_id.as_deref() != Some(expected_session_id) { + slot.session_id = Some(expected_session_id.to_owned()); + slot.sequence = 0; + } + if message.sequence() <= slot.sequence { + return false; + } + slot.sequence = message.sequence(); + true + } +} + +pub trait VersionedMessage { + fn protocol_version(&self) -> u16; +} + +impl VersionedMessage for HostToPopup { + fn protocol_version(&self) -> u16 { + self.version() + } +} + +impl VersionedMessage for PopupToHost { + fn protocol_version(&self) -> u16 { + self.version() + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ProtocolErrorKind { + Io, + Eof, + Truncated, + Oversize, + Malformed, + UnsupportedVersion, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ProtocolError { + pub kind: ProtocolErrorKind, + pub message: String, +} + +impl ProtocolError { + fn new(kind: ProtocolErrorKind, message: impl Into) -> Self { + Self { + kind, + message: message.into(), + } + } +} + +impl fmt::Display for ProtocolError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(formatter, "{}", self.message) + } +} + +impl std::error::Error for ProtocolError {} + +/// Write one complete JSONL frame. Serialization is used for all escaping. +pub fn write_jsonl(writer: &mut impl Write, value: &T) -> Result<(), ProtocolError> { + let encoded = serde_json::to_vec(value) + .map_err(|error| ProtocolError::new(ProtocolErrorKind::Malformed, error.to_string()))?; + if encoded.len() > MAX_JSONL_LINE_BYTES { + return Err(ProtocolError::new( + ProtocolErrorKind::Oversize, + format!("popup JSONL frame is {} bytes", encoded.len()), + )); + } + writer + .write_all(&encoded) + .and_then(|_| writer.write_all(b"\n")) + .and_then(|_| writer.flush()) + .map_err(|error| ProtocolError::new(ProtocolErrorKind::Io, error.to_string())) +} + +/// Read one complete, bounded JSONL frame. +pub fn read_jsonl(reader: &mut impl BufRead) -> Result +where + T: DeserializeOwned + VersionedMessage, +{ + let bytes = read_bounded_line(reader)?; + decode_jsonl(&bytes) +} + +fn read_bounded_line(reader: &mut impl BufRead) -> Result, ProtocolError> { + let mut bytes = Vec::new(); + loop { + let available = reader + .fill_buf() + .map_err(|error| ProtocolError::new(ProtocolErrorKind::Io, error.to_string()))?; + if available.is_empty() { + return if bytes.is_empty() { + Err(ProtocolError::new( + ProtocolErrorKind::Eof, + "popup stream closed", + )) + } else { + Err(ProtocolError::new( + ProtocolErrorKind::Truncated, + "popup stream ended in the middle of a JSONL frame", + )) + }; + } + let newline = available.iter().position(|byte| *byte == b'\n'); + let content_len = bytes + .len() + .saturating_add(newline.unwrap_or(available.len())); + let take = newline.map_or(available.len(), |index| index + 1); + if content_len > MAX_JSONL_LINE_BYTES { + reader.consume(take); + if newline.is_none() { + discard_through_newline(reader)?; + } + return Err(ProtocolError::new( + ProtocolErrorKind::Oversize, + "popup JSONL frame exceeds the 1 MiB limit", + )); + } + bytes.extend_from_slice(&available[..take]); + reader.consume(take); + if newline.is_some() { + bytes.pop(); + if bytes.last() == Some(&b'\r') { + bytes.pop(); + } + return Ok(bytes); + } + } +} + +fn discard_through_newline(reader: &mut impl BufRead) -> Result<(), ProtocolError> { + loop { + let available = reader + .fill_buf() + .map_err(|error| ProtocolError::new(ProtocolErrorKind::Io, error.to_string()))?; + if available.is_empty() { + return Ok(()); + } + let newline = available.iter().position(|byte| *byte == b'\n'); + let take = newline.map_or(available.len(), |index| index + 1); + reader.consume(take); + if newline.is_some() { + return Ok(()); + } + } +} + +fn decode_jsonl(bytes: &[u8]) -> Result +where + T: DeserializeOwned + VersionedMessage, +{ + let message: T = serde_json::from_slice(bytes) + .map_err(|error| ProtocolError::new(ProtocolErrorKind::Malformed, error.to_string()))?; + if message.protocol_version() != POPUP_PROTOCOL_VERSION { + return Err(ProtocolError::new( + ProtocolErrorKind::UnsupportedVersion, + format!( + "unsupported popup protocol version {} (expected {})", + message.protocol_version(), + POPUP_PROTOCOL_VERSION + ), + )); + } + Ok(message) +} + +#[derive(Debug, Clone, Default, PartialEq)] +pub struct PreviewPopupState { + pub text: String, + pub source: String, +} + +#[derive(Debug, Clone, Default, PartialEq)] +pub struct QaPopupState { + pub phase: String, + pub messages: Vec, + pub selection_preview: Option, + pub streaming_answer: String, + pub error: Option, +} + +#[derive(Debug, Clone, Default, PartialEq)] +pub struct CapsulePopupState { + pub phase: String, + pub text: String, + pub audio_level: Option, +} + +#[derive(Debug, Clone, Default, PartialEq)] +pub struct PopupState { + pub session_id: Option, + pub last_sequence: u64, + pub visible: bool, + pub shutdown_requested: bool, + pub preview: PreviewPopupState, + pub qa: QaPopupState, + pub capsule: CapsulePopupState, + retired_sessions: HashSet, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ApplyOutcome { + Applied, + Stale, + Shutdown, +} + +impl PopupState { + /// Apply a host event while rejecting late messages from an old session or + /// duplicate/out-of-order sequence numbers. + pub fn apply(&mut self, message: HostToPopup) -> ApplyOutcome { + let session_id = message.session_id().to_owned(); + let sequence = message.sequence(); + if let Some(current) = self.session_id.as_deref() { + if current == session_id { + if sequence <= self.last_sequence { + return ApplyOutcome::Stale; + } + } else { + let starts_session = matches!( + message, + HostToPopup::Preview { .. } + | HostToPopup::QaSnapshot { .. } + | HostToPopup::Capsule { .. } + ); + if !starts_session || self.retired_sessions.contains(&session_id) { + return ApplyOutcome::Stale; + } + self.retired_sessions.insert(current.to_owned()); + self.last_sequence = 0; + } + } + if sequence <= self.last_sequence { + return ApplyOutcome::Stale; + } + self.session_id = Some(session_id); + self.last_sequence = sequence; + match message { + HostToPopup::Preview { text, source, .. } => { + self.preview = PreviewPopupState { text, source }; + self.visible = true; + } + HostToPopup::QaSnapshot { + phase, + messages, + selection_preview, + streaming_answer, + error, + .. + } => { + self.qa = QaPopupState { + phase, + messages, + selection_preview, + streaming_answer, + error, + }; + self.visible = true; + } + HostToPopup::Capsule { + phase, + text, + audio_level, + .. + } => { + self.capsule = CapsulePopupState { + phase, + text, + audio_level, + }; + self.visible = true; + } + HostToPopup::Hide { .. } => self.visible = false, + HostToPopup::Shutdown { .. } => { + self.visible = false; + self.shutdown_requested = true; + return ApplyOutcome::Shutdown; + } + } + ApplyOutcome::Applied + } +} + +/// Blocking popup-side protocol driver, intended to run on the popup's stdin +/// reader thread. UI mutation must be forwarded by `on_message` to the popup +/// frame through a channel. +pub fn run_popup( + reader: &mut impl BufRead, + mut on_message: impl FnMut(HostToPopup), +) -> Result<(), ProtocolError> { + loop { + match read_jsonl::(reader) { + Ok(message) => { + let shutdown = matches!(message, HostToPopup::Shutdown { .. }); + on_message(message); + if shutdown { + return Ok(()); + } + } + Err(error) if error.kind == ProtocolErrorKind::Eof => return Ok(()), + Err(error) => return Err(error), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PopupSupervisorEvent { + Message(PopupToHost), + ProtocolError(ProtocolError), + Exited { code: Option, crashed: bool }, + SpawnFailed(String), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PopupSendError { + Full, + Closed, +} + +enum SupervisorCommand { + Send(HostToPopup), + Shutdown, +} + +/// Non-blocking handle held by the main egui application. +pub struct PopupSupervisor { + commands: tokio_mpsc::Sender, + events: Receiver, +} + +impl PopupSupervisor { + pub fn spawn(runtime: &Handle, executable: impl AsRef, kind: PopupKind) -> Self { + let mut command = Command::new(executable.as_ref()); + command.arg("--openless-egui-popup").arg(kind.argument()); + Self::spawn_command(runtime, command) + } + + /// Low-level construction seam used by tests and alternative launchers. + pub fn spawn_command(runtime: &Handle, mut command: Command) -> Self { + let (command_tx, command_rx) = tokio_mpsc::channel(64); + let (event_tx, event_rx) = mpsc::sync_channel(256); + command + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()) + .kill_on_drop(true); + runtime.spawn(supervise(command, command_rx, event_tx)); + Self { + commands: command_tx, + events: event_rx, + } + } + + /// Queue a message without ever waiting in an egui frame. + pub fn try_send(&self, message: HostToPopup) -> Result<(), PopupSendError> { + match self.commands.try_send(SupervisorCommand::Send(message)) { + Ok(()) => Ok(()), + Err(tokio_mpsc::error::TrySendError::Full(_)) => Err(PopupSendError::Full), + Err(tokio_mpsc::error::TrySendError::Closed(_)) => Err(PopupSendError::Closed), + } + } + + /// Poll one child event without blocking the egui frame. + pub fn try_recv(&self) -> Result { + self.events.try_recv() + } + + pub fn request_shutdown(&self) -> Result<(), PopupSendError> { + match self.commands.try_send(SupervisorCommand::Shutdown) { + Ok(()) => Ok(()), + Err(tokio_mpsc::error::TrySendError::Full(_)) => Err(PopupSendError::Full), + Err(tokio_mpsc::error::TrySendError::Closed(_)) => Err(PopupSendError::Closed), + } + } +} + +impl Drop for PopupSupervisor { + fn drop(&mut self) { + let _ = self.commands.try_send(SupervisorCommand::Shutdown); + } +} + +async fn supervise( + mut command: Command, + mut commands: tokio_mpsc::Receiver, + events: mpsc::SyncSender, +) { + let mut child = match command.spawn() { + Ok(child) => child, + Err(error) => { + let _ = events.try_send(PopupSupervisorEvent::SpawnFailed(error.to_string())); + return; + } + }; + let Some(mut stdin) = child.stdin.take() else { + let _ = events.try_send(PopupSupervisorEvent::SpawnFailed( + "popup stdin pipe was not created".to_owned(), + )); + let _ = child.kill().await; + return; + }; + let Some(stdout) = child.stdout.take() else { + let _ = events.try_send(PopupSupervisorEvent::SpawnFailed( + "popup stdout pipe was not created".to_owned(), + )); + let _ = child.kill().await; + return; + }; + + let (reader_tx, mut reader_rx) = tokio_mpsc::channel(64); + tokio::spawn(read_child_output(BufReader::new(stdout), reader_tx)); + + let mut reader_open = true; + let mut shutdown_requested = false; + loop { + tokio::select! { + status = child.wait() => { + match status { + Ok(status) => { + let code = status.code(); + let _ = events.try_send(PopupSupervisorEvent::Exited { + code, + crashed: !status.success() && !shutdown_requested, + }); + } + Err(error) => { + let _ = events.try_send(PopupSupervisorEvent::ProtocolError( + ProtocolError::new(ProtocolErrorKind::Io, error.to_string()), + )); + } + } + return; + } + output = reader_rx.recv(), if reader_open => { + match output { + Some(event) => { let _ = events.try_send(event); } + None => reader_open = false, + } + } + command = commands.recv() => { + match command { + Some(SupervisorCommand::Send(message)) => { + match serde_json::to_vec(&message) { + Ok(encoded) if encoded.len() <= MAX_JSONL_LINE_BYTES => { + if let Err(error) = stdin.write_all(&encoded).await { + let _ = events.try_send(PopupSupervisorEvent::ProtocolError( + ProtocolError::new(ProtocolErrorKind::Io, error.to_string()), + )); + } else if let Err(error) = stdin.write_all(b"\n").await { + let _ = events.try_send(PopupSupervisorEvent::ProtocolError( + ProtocolError::new(ProtocolErrorKind::Io, error.to_string()), + )); + } else if let Err(error) = stdin.flush().await { + let _ = events.try_send(PopupSupervisorEvent::ProtocolError( + ProtocolError::new(ProtocolErrorKind::Io, error.to_string()), + )); + } + } + Ok(encoded) => { + let _ = events.try_send(PopupSupervisorEvent::ProtocolError( + ProtocolError::new( + ProtocolErrorKind::Oversize, + format!("popup JSONL frame is {} bytes", encoded.len()), + ), + )); + } + Err(error) => { + let _ = events.try_send(PopupSupervisorEvent::ProtocolError( + ProtocolError::new(ProtocolErrorKind::Malformed, error.to_string()), + )); + } + } + } + Some(SupervisorCommand::Shutdown) | None => { + shutdown_requested = true; + let _ = child.start_kill(); + } + } + } + } + } +} + +async fn read_child_output(mut reader: R, events: tokio_mpsc::Sender) +where + R: AsyncBufRead + Unpin, +{ + loop { + match read_async_bounded_line(&mut reader).await { + Ok(bytes) => { + let event = match decode_jsonl::(&bytes) { + Ok(message) => PopupSupervisorEvent::Message(message), + Err(error) => PopupSupervisorEvent::ProtocolError(error), + }; + if events.send(event).await.is_err() { + return; + } + } + Err(error) if error.kind == ProtocolErrorKind::Eof => return, + Err(error) => { + if events + .send(PopupSupervisorEvent::ProtocolError(error)) + .await + .is_err() + { + return; + } + } + } + } +} + +async fn read_async_bounded_line(reader: &mut R) -> Result, ProtocolError> +where + R: AsyncBufRead + Unpin, +{ + let mut bytes = Vec::new(); + loop { + let available = reader + .fill_buf() + .await + .map_err(|error| ProtocolError::new(ProtocolErrorKind::Io, error.to_string()))?; + if available.is_empty() { + return if bytes.is_empty() { + Err(ProtocolError::new( + ProtocolErrorKind::Eof, + "popup stream closed", + )) + } else { + Err(ProtocolError::new( + ProtocolErrorKind::Truncated, + "popup stream ended in the middle of a JSONL frame", + )) + }; + } + let newline = available.iter().position(|byte| *byte == b'\n'); + let content_len = bytes + .len() + .saturating_add(newline.unwrap_or(available.len())); + let take = newline.map_or(available.len(), |index| index + 1); + if content_len > MAX_JSONL_LINE_BYTES { + reader.consume(take); + if newline.is_none() { + discard_async_through_newline(reader).await?; + } + return Err(ProtocolError::new( + ProtocolErrorKind::Oversize, + "popup JSONL frame exceeds the 1 MiB limit", + )); + } + bytes.extend_from_slice(&available[..take]); + reader.consume(take); + if newline.is_some() { + bytes.pop(); + if bytes.last() == Some(&b'\r') { + bytes.pop(); + } + return Ok(bytes); + } + } +} + +async fn discard_async_through_newline(reader: &mut R) -> Result<(), ProtocolError> +where + R: AsyncBufRead + Unpin, +{ + loop { + let available = reader + .fill_buf() + .await + .map_err(|error| ProtocolError::new(ProtocolErrorKind::Io, error.to_string()))?; + if available.is_empty() { + return Ok(()); + } + let newline = available.iter().position(|byte| *byte == b'\n'); + let take = newline.map_or(available.len(), |index| index + 1); + reader.consume(take); + if newline.is_some() { + return Ok(()); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Cursor; + use std::time::{Duration, Instant}; + + fn preview(text: String) -> HostToPopup { + HostToPopup::Preview { + version: POPUP_PROTOCOL_VERSION, + session_id: "session-一".to_owned(), + sequence: 7, + text, + source: "原文 \\\\ source".to_owned(), + } + } + + #[test] + fn jsonl_round_trip_escapes_quotes_backslashes_and_unicode() { + let expected = preview("他说:\"你好\" C:\\\\tmp\\\\文件".to_owned()); + let mut bytes = Vec::new(); + write_jsonl(&mut bytes, &expected).unwrap(); + assert_eq!(bytes.last(), Some(&b'\n')); + let actual: HostToPopup = read_jsonl(&mut Cursor::new(bytes)).unwrap(); + assert_eq!(actual, expected); + } + + #[test] + fn malformed_oversize_eof_and_truncation_are_classified() { + let malformed = read_jsonl::(&mut Cursor::new(b"not json\n")); + assert_eq!(malformed.unwrap_err().kind, ProtocolErrorKind::Malformed); + + let oversized = vec![b'x'; MAX_JSONL_LINE_BYTES + 2]; + let oversized = read_jsonl::(&mut Cursor::new(oversized)); + assert_eq!(oversized.unwrap_err().kind, ProtocolErrorKind::Oversize); + + let eof = read_jsonl::(&mut Cursor::new(Vec::::new())); + assert_eq!(eof.unwrap_err().kind, ProtocolErrorKind::Eof); + + let truncated = read_jsonl::(&mut Cursor::new(b"{\"type\":")); + assert_eq!(truncated.unwrap_err().kind, ProtocolErrorKind::Truncated); + } + + #[test] + fn popup_state_rejects_late_and_cross_session_messages() { + let mut state = PopupState::default(); + assert_eq!(state.apply(preview("new".into())), ApplyOutcome::Applied); + let mut late = preview("late".into()); + if let HostToPopup::Preview { sequence, .. } = &mut late { + *sequence = 6; + } + assert_eq!(state.apply(late), ApplyOutcome::Stale); + let other = HostToPopup::Hide { + version: POPUP_PROTOCOL_VERSION, + session_id: "other".into(), + sequence: 8, + }; + assert_eq!(state.apply(other), ApplyOutcome::Stale); + assert_eq!(state.preview.text, "new"); + + let mut next_session = preview("next".into()); + if let HostToPopup::Preview { + session_id, + sequence, + .. + } = &mut next_session + { + *session_id = "session-二".into(); + *sequence = 1; + } + assert_eq!(state.apply(next_session), ApplyOutcome::Applied); + let mut retired = preview("retired".into()); + if let HostToPopup::Preview { sequence, .. } = &mut retired { + *sequence = 99; + } + assert_eq!(state.apply(retired), ApplyOutcome::Stale); + assert_eq!(state.preview.text, "next"); + } + + #[test] + fn popup_action_guard_rejects_replay_cross_session_and_cross_kind() { + let mut guard = PopupActionGuard::default(); + let submit = PopupToHost::SubmitQa { + version: POPUP_PROTOCOL_VERSION, + session_id: "qa-session".into(), + sequence: 2, + text: "question".into(), + }; + assert!(guard.accept(PopupKind::Qa, &submit, "qa-session")); + assert!(!guard.accept(PopupKind::Qa, &submit, "qa-session")); + + let stale = PopupToHost::DismissQa { + version: POPUP_PROTOCOL_VERSION, + session_id: "qa-session".into(), + sequence: 1, + }; + assert!(!guard.accept(PopupKind::Qa, &stale, "qa-session")); + assert!(!guard.accept(PopupKind::Preview, &submit, "qa-session")); + assert!(!guard.accept(PopupKind::Qa, &submit, "new-session")); + } + + #[test] + fn popup_action_guard_reset_starts_a_new_child_sequence_domain() { + let mut guard = PopupActionGuard::default(); + let ready = PopupToHost::Ready { + version: POPUP_PROTOCOL_VERSION, + session_id: "session".into(), + sequence: 1, + kind: PopupKind::Preview, + }; + assert!(guard.accept(PopupKind::Preview, &ready, "session")); + assert!(!guard.accept(PopupKind::Preview, &ready, "session")); + guard.reset(PopupKind::Preview); + assert!(guard.accept(PopupKind::Preview, &ready, "session")); + } + + #[test] + fn popup_messages_are_bound_to_their_process_kind() { + assert_eq!( + preview("text".into()).content_kind(), + Some(PopupKind::Preview) + ); + let hide = HostToPopup::Hide { + version: POPUP_PROTOCOL_VERSION, + session_id: "session".into(), + sequence: 8, + }; + assert_eq!(hide.content_kind(), None); + + let wrong_version = PopupToHost::DismissCapsule { + version: POPUP_PROTOCOL_VERSION + 1, + session_id: "session".into(), + sequence: 1, + }; + let mut guard = PopupActionGuard::default(); + assert!(!guard.accept(PopupKind::Capsule, &wrong_version, "session")); + } + + #[tokio::test] + async fn supervisor_reaps_a_crashed_child() { + let mut command = Command::new("/bin/sh"); + command.arg("-c").arg("exit 17"); + let supervisor = PopupSupervisor::spawn_command(&Handle::current(), command); + let deadline = Instant::now() + Duration::from_secs(5); + loop { + match supervisor.try_recv() { + Ok(PopupSupervisorEvent::Exited { code, crashed }) => { + assert_eq!(code, Some(17)); + assert!(crashed); + break; + } + Ok(_) | Err(mpsc::TryRecvError::Empty) if Instant::now() < deadline => { + tokio::time::sleep(Duration::from_millis(10)).await; + } + result => panic!("did not observe crashed child: {result:?}"), + } + } + } +} diff --git a/openless-all/app/linux-egui/src/recordings.rs b/openless-all/app/linux-egui/src/recordings.rs new file mode 100644 index 000000000..f4c90f8fe --- /dev/null +++ b/openless-all/app/linux-egui/src/recordings.rs @@ -0,0 +1,97 @@ +use std::fmt; +use std::path::{Path, PathBuf}; + +pub const MAX_RECORDING_BYTES: u64 = 1024 * 1024 * 1024; + +#[derive(Debug)] +pub enum RecordingError { + InvalidSession, + NotFound, + TooLarge, + InvalidWav, + Io(std::io::Error), +} + +impl fmt::Display for RecordingError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidSession => f.write_str("invalid recording session id"), + Self::NotFound => f.write_str("recording not found"), + Self::TooLarge => f.write_str("recording exceeds the size limit"), + Self::InvalidWav => f.write_str("recording is not canonical PCM WAV"), + Self::Io(error) => write!(f, "recording I/O failed: {error}"), + } + } +} + +impl std::error::Error for RecordingError {} + +pub fn recording_path(data_dir: &Path, session_id: &str) -> Result { + let parsed = uuid::Uuid::parse_str(session_id).map_err(|_| RecordingError::InvalidSession)?; + if parsed.to_string() != session_id { + return Err(RecordingError::InvalidSession); + } + Ok(data_dir + .join("recordings") + .join(format!("{session_id}.wav"))) +} + +pub fn read_recording_wav(data_dir: &Path, session_id: &str) -> Result, RecordingError> { + let path = recording_path(data_dir, session_id)?; + let metadata = std::fs::symlink_metadata(&path).map_err(|error| { + if error.kind() == std::io::ErrorKind::NotFound { + RecordingError::NotFound + } else { + RecordingError::Io(error) + } + })?; + if metadata.file_type().is_symlink() || !metadata.is_file() { + return Err(RecordingError::InvalidWav); + } + if metadata.len() > MAX_RECORDING_BYTES { + return Err(RecordingError::TooLarge); + } + let wav = std::fs::read(path).map_err(RecordingError::Io)?; + recording_pcm(&wav)?; + Ok(wav) +} + +pub fn recording_pcm(wav: &[u8]) -> Result<&[u8], RecordingError> { + if wav.len() <= 44 + || &wav[..4] != b"RIFF" + || &wav[8..12] != b"WAVE" + || &wav[12..16] != b"fmt " + || u16::from_le_bytes([wav[20], wav[21]]) != 1 + || u16::from_le_bytes([wav[22], wav[23]]) != 1 + || u32::from_le_bytes([wav[24], wav[25], wav[26], wav[27]]) != 16_000 + || u16::from_le_bytes([wav[34], wav[35]]) != 16 + || &wav[36..40] != b"data" + || !(wav.len() - 44).is_multiple_of(2) + { + return Err(RecordingError::InvalidWav); + } + let declared = u32::from_le_bytes([wav[40], wav[41], wav[42], wav[43]]) as usize; + if declared != wav.len() - 44 { + return Err(RecordingError::InvalidWav); + } + Ok(&wav[44..]) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn session_path_rejects_traversal_and_wav_contract_is_strict() { + assert!(recording_path(Path::new("/tmp/openless"), "../escape").is_err()); + let id = uuid::Uuid::new_v4().to_string(); + assert!(recording_path(Path::new("/tmp/openless"), &id) + .unwrap() + .ends_with(format!("{id}.wav"))); + let mut wav = crate::audio::wav_header(2).to_vec(); + wav.extend_from_slice(&[1, 0]); + assert_eq!(recording_pcm(&wav).unwrap(), [1, 0]); + wav[24] = 0; + assert!(recording_pcm(&wav).is_err()); + } +} diff --git a/openless-all/app/linux-egui/src/remote_input.rs b/openless-all/app/linux-egui/src/remote_input.rs index 8dced6273..8b980a234 100644 --- a/openless-all/app/linux-egui/src/remote_input.rs +++ b/openless-all/app/linux-egui/src/remote_input.rs @@ -209,13 +209,12 @@ use tokio_rustls::TlsAcceptor; #[cfg(target_os = "linux")] mod assets { - pub const INDEX_HTML: &str = - include_str!("../../src-tauri/src/remote_server/assets/index.html"); - pub const APP_JS: &str = include_str!("../../src-tauri/src/remote_server/assets/app.js"); - pub const STYLE_CSS: &str = include_str!("../../src-tauri/src/remote_server/assets/style.css"); - pub const ICON_PNG: &[u8] = include_bytes!("../../src-tauri/src/remote_server/assets/icon.png"); - pub const MIC_PNG: &[u8] = include_bytes!("../../src-tauri/src/remote_server/assets/mic.png"); - pub const DONE_PNG: &[u8] = include_bytes!("../../src-tauri/src/remote_server/assets/done.png"); + pub const INDEX_HTML: &str = include_str!("../../assets/remote-input/index.html"); + pub const APP_JS: &str = include_str!("../../assets/remote-input/app.js"); + pub const STYLE_CSS: &str = include_str!("../../assets/remote-input/style.css"); + pub const ICON_PNG: &[u8] = include_bytes!("../../assets/remote-input/icon.png"); + pub const MIC_PNG: &[u8] = include_bytes!("../../assets/remote-input/mic.png"); + pub const DONE_PNG: &[u8] = include_bytes!("../../assets/remote-input/done.png"); } #[cfg(target_os = "linux")] diff --git a/openless-all/app/linux-egui/src/resources.rs b/openless-all/app/linux-egui/src/resources.rs index 7cb9c4bb1..ca14606af 100644 --- a/openless-all/app/linux-egui/src/resources.rs +++ b/openless-all/app/linux-egui/src/resources.rs @@ -4,7 +4,6 @@ use openless_core::{BackendError, BackendErrorCode, DirectoryResourceResolver, R pub const FCITX_PLUGIN_LIBRARY: &str = "linux-fcitx5-plugin/libopenless.so"; pub const FCITX_PLUGIN_CONFIG: &str = "linux-fcitx5-plugin/openless.conf"; -pub(crate) const QWEN_ASR_RUNTIME: &str = "qwen-asr/qwen_asr"; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum LinuxPackageKind { @@ -89,29 +88,6 @@ impl ResourceResolver for LinuxResourceResolver { } } -pub(crate) fn qwen_runtime_path( - layout: &LinuxResourceLayout, - explicit: Option, -) -> Result { - if let Some(path) = explicit { - if !path.is_absolute() { - return Err(BackendError::new( - BackendErrorCode::InvalidArgument, - "OPENLESS_QWEN_ASR_BIN must be an absolute path", - )); - } - return Ok(path); - } - layout.resolver()?.resolve(Path::new(QWEN_ASR_RUNTIME)) -} - -pub(crate) fn detect_qwen_runtime_path() -> Result { - qwen_runtime_path( - &LinuxResourceLayout::detect(None)?, - std::env::var_os("OPENLESS_QWEN_ASR_BIN").map(PathBuf::from), - ) -} - #[cfg(test)] mod tests { use super::*; @@ -142,28 +118,4 @@ mod tests { .resource_root .ends_with("bin/../lib/openless/resources")); } - - #[test] - fn qwen_runtime_uses_the_packaged_resource_or_an_absolute_dev_override() { - let layout = LinuxResourceLayout { - package_kind: LinuxPackageKind::SystemPackage, - resource_root: PathBuf::from("/usr/lib/openless/resources"), - }; - - assert_eq!( - qwen_runtime_path(&layout, None).unwrap(), - PathBuf::from("/usr/lib/openless/resources/qwen-asr/qwen_asr") - ); - let override_path = std::env::temp_dir().join("qwen_asr"); - assert_eq!( - qwen_runtime_path(&layout, Some(override_path.clone())).unwrap(), - override_path - ); - assert_eq!( - qwen_runtime_path(&layout, Some(PathBuf::from("qwen_asr"))) - .unwrap_err() - .code, - BackendErrorCode::InvalidArgument - ); - } } diff --git a/openless-all/app/linux-egui/src/selection.rs b/openless-all/app/linux-egui/src/selection.rs index a564bd205..f7deb043f 100644 --- a/openless-all/app/linux-egui/src/selection.rs +++ b/openless-all/app/linux-egui/src/selection.rs @@ -117,6 +117,17 @@ impl SelectionRuntimeAdapter for LinuxSelectionRuntime { source_text: text.clone(), replacement_text: None, }); + // Honest `source_app`: fcitx5 exposes the surrounding text via + // DBus but gives the host no reliable foreground-application + // identity that holds across both X11 and Wayland clients, and + // the PRIMARY/clipboard cannot prove the original control is + // still the current focus. There is no IBus/global-hotkey + // fallback by design (Linux supports fcitx5 only), so we report + // the app as unknown instead of faking an identity. Post-insertion + // edit observation for streamed dictation is likewise unsupported + // on Linux: Core's Noop HostContextAdapter/EditObservationAdapter + // (the factory does not inject real ones) keep those use-cases on + // the explicit `Unsupported` path rather than simulating edits. Ok(SelectionCapture { text, source_app: None, diff --git a/openless-all/app/linux-egui/src/settings.rs b/openless-all/app/linux-egui/src/settings.rs index 8081afd00..5f7362eef 100644 --- a/openless-all/app/linux-egui/src/settings.rs +++ b/openless-all/app/linux-egui/src/settings.rs @@ -17,6 +17,13 @@ pub trait LinuxSettingsEffects: Send + Sync { fn apply_hotkeys(&self, target: &HotkeyRuntimeTarget) -> Result<(), BackendError>; fn set_active_asr_provider(&self, provider_id: &str) -> Result<(), BackendError>; + + fn set_launch_at_login(&self, _enabled: bool) -> Result<(), BackendError> { + Err(BackendError::new( + BackendErrorCode::Unsupported, + "launch-at-login is unavailable", + )) + } } /// Linux implementation of the shared settings transaction runtime. @@ -29,6 +36,7 @@ impl LinuxSettingsRuntime { pub fn new(credentials: LinuxCredentialStore) -> Self { Self::with_effects(Arc::new(Fcitx5SettingsEffects { credentials: Some(credentials), + autostart: production_autostart(), })) } @@ -38,46 +46,15 @@ impl LinuxSettingsRuntime { /// also injecting a matching `SettingsRuntime`. Active-provider changes then /// fail explicitly with `Unsupported` instead of silently diverging. pub fn hotkeys_only() -> Self { - Self::with_effects(Arc::new(Fcitx5SettingsEffects { credentials: None })) + Self::with_effects(Arc::new(Fcitx5SettingsEffects { + credentials: None, + autostart: production_autostart(), + })) } pub fn with_effects(effects: Arc) -> Self { Self { effects } } - - fn reject_unsupported_hotkey_changes(plan: &SettingsEffectPlan) -> Result<(), BackendError> { - let Some(change) = &plan.hotkeys else { - return Ok(()); - }; - let previous = &change.previous; - let next = &change.next; - let unsupported = [ - ( - previous.switch_style != next.switch_style, - "switch-style hotkey", - ), - (previous.open_app != next.open_app, "open-app hotkey"), - ( - previous.style_packs != next.style_packs, - "style-pack hotkeys", - ), - ]; - let names = unsupported - .into_iter() - .filter_map(|(changed, name)| changed.then_some(name)) - .collect::>(); - if names.is_empty() { - Ok(()) - } else { - Err(BackendError::new( - BackendErrorCode::Unsupported, - format!( - "Linux fcitx5 settings adapter does not support changing {}", - names.join(", ") - ), - )) - } - } } impl SettingsRuntime for LinuxSettingsRuntime { @@ -95,6 +72,12 @@ impl SettingsRuntime for LinuxSettingsRuntime { } let mut receipt = SettingsEffectReceipt::default(); + if let Some(change) = &plan.launch_at_login { + if let Err(error) = self.effects.set_launch_at_login(change.next) { + return Err(SettingsEffectFailure::after_side_effect(error, receipt)); + } + receipt.applied.push(SettingsEffectKind::LaunchAtLogin); + } if let Some(change) = &plan.active_asr_provider { if let Err(error) = self.effects.set_active_asr_provider(&change.next) { return Err(SettingsEffectFailure::after_side_effect(error, receipt)); @@ -109,8 +92,6 @@ impl SettingsRuntime for LinuxSettingsRuntime { plan: &SettingsEffectPlan, receipt: &mut SettingsEffectReceipt, ) -> Result<(), SettingsEffectFailure> { - Self::reject_unsupported_hotkey_changes(plan) - .map_err(SettingsEffectFailure::before_side_effect)?; let Some(change) = &plan.hotkeys else { return Ok(()); }; @@ -130,6 +111,11 @@ impl SettingsRuntime for LinuxSettingsRuntime { let mut failures = Vec::new(); for effect in receipt.applied.iter().rev() { let result = match effect { + SettingsEffectKind::LaunchAtLogin => plan + .launch_at_login + .as_ref() + .map(|change| self.effects.set_launch_at_login(change.previous)) + .unwrap_or(Ok(())), SettingsEffectKind::Hotkeys => plan .hotkeys .as_ref() @@ -162,6 +148,16 @@ impl SettingsRuntime for LinuxSettingsRuntime { struct Fcitx5SettingsEffects { credentials: Option, + autostart: Result, +} + +fn production_autostart() -> Result { + std::env::current_exe() + .map_err(|error| format!("resolve current executable for autostart: {error}")) + .and_then(|executable| { + crate::AutostartManager::detect(executable) + .map_err(|error| format!("initialize XDG autostart manager: {error}")) + }) } impl LinuxSettingsEffects for Fcitx5SettingsEffects { @@ -173,6 +169,23 @@ impl LinuxSettingsEffects for Fcitx5SettingsEffects { target.selection_polish.as_ref(), )?; apply_action_hotkey("SetTranslationHotkeyRaw", Some(&target.translation))?; + tolerate_optional_fcitx_method(apply_action_hotkey( + "SetSwitchStyleHotkeyRaw", + target.switch_style.as_ref(), + ))?; + tolerate_optional_fcitx_method(apply_action_hotkey( + "SetOpenAppHotkeyRaw", + target.open_app.as_ref(), + ))?; + let style_pack_hotkeys = target + .style_packs + .iter() + .map(|hotkey| { + shortcut_to_raw(&hotkey.binding) + .map(|(symbol, states)| (hotkey.pack_id.clone(), symbol, states)) + }) + .collect::, _>>()?; + tolerate_optional_fcitx_method(crate::fcitx5::set_style_pack_hotkeys(style_pack_hotkeys))?; let (symbol, states) = target .coding_agent_voice .as_ref() @@ -182,7 +195,7 @@ impl LinuxSettingsEffects for Fcitx5SettingsEffects { .map(shortcut_to_raw) .transpose()? .unwrap_or((0, 0)); - crate::fcitx5::set_less_computer_hotkey_raw(symbol, states) + tolerate_optional_fcitx_method(crate::fcitx5::set_less_computer_hotkey_raw(symbol, states)) } fn set_active_asr_provider(&self, provider_id: &str) -> Result<(), BackendError> { @@ -194,6 +207,35 @@ impl LinuxSettingsEffects for Fcitx5SettingsEffects { }; credentials.set_active_provider_immediate(ProviderSlot::Asr, provider_id) } + + fn set_launch_at_login(&self, enabled: bool) -> Result<(), BackendError> { + let manager = self + .autostart + .as_ref() + .map_err(|message| BackendError::new(BackendErrorCode::Platform, message.clone()))?; + manager.set_enabled(enabled).map_err(|error| { + BackendError::new( + BackendErrorCode::Platform, + format!("update XDG launch-at-login entry: {error}"), + ) + }) + } +} + +fn tolerate_optional_fcitx_method(result: Result<(), BackendError>) -> Result<(), BackendError> { + match result { + Err(error) + if error.message.contains("Unknown method") + || error.message.contains("UnknownMethod") => + { + log::warn!( + "[fcitx] running addon lacks an optional extended hotkey method; continuing with the legacy interface: {}", + error.message + ); + Ok(()) + } + result => result, + } } fn apply_dictation_hotkey(binding: &ShortcutBinding) -> Result<(), BackendError> { @@ -239,7 +281,7 @@ fn normalize_fcitx_primary(primary: &str) -> String { } } -fn shortcut_to_raw(binding: &ShortcutBinding) -> Result<(u32, u32), BackendError> { +pub(crate) fn shortcut_to_raw(binding: &ShortcutBinding) -> Result<(u32, u32), BackendError> { if let Some(trigger) = legacy_modifier_trigger(binding) { return Ok((modifier_trigger_keysym(trigger)?, 0)); } @@ -381,4 +423,27 @@ mod tests { }; assert_eq!(shortcut_to_raw(&shortcut).unwrap(), (b'/' as u32, 5)); } + + #[test] + fn legacy_addon_may_omit_optional_extended_hotkey_methods() { + for message in [ + "Unknown method SetSwitchStyleHotkeyRaw", + "org.freedesktop.DBus.Error.UnknownMethod", + ] { + assert!(tolerate_optional_fcitx_method(Err(BackendError::new( + BackendErrorCode::Platform, + message, + ))) + .is_ok()); + } + } + + #[test] + fn optional_hotkey_compatibility_does_not_hide_other_failures() { + let error = BackendError::new(BackendErrorCode::Platform, "session bus unavailable"); + assert_eq!( + tolerate_optional_fcitx_method(Err(error.clone())).unwrap_err(), + error + ); + } } diff --git a/openless-all/app/linux-egui/src/tray.rs b/openless-all/app/linux-egui/src/tray.rs new file mode 100644 index 000000000..40ec96f8f --- /dev/null +++ b/openless-all/app/linux-egui/src/tray.rs @@ -0,0 +1,579 @@ +//! Freedesktop StatusNotifierItem tray integration without GTK or Tauri. +//! +//! The D-Bus worker owns no Core state. It emits typed commands that the egui +//! thread drains, and accepts menu snapshots for microphone checkmarks. A tray +//! is considered available only after the session bus name is owned and the +//! desktop watcher has acknowledged registration. + +use std::collections::HashMap; +use std::fmt; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{mpsc, Arc, Mutex}; +use std::thread::JoinHandle; +use std::time::Duration; + +use crate::{tr_l10n, Lang}; + +const ITEM_PATH: &str = "/StatusNotifierItem"; +const MENU_PATH: &str = "/MenuBar"; +const ITEM_INTERFACE: &str = "org.kde.StatusNotifierItem"; +const MENU_INTERFACE: &str = "com.canonical.dbusmenu"; +const WATCHER_NAME: &str = "org.kde.StatusNotifierWatcher"; +const WATCHER_PATH: &str = "/StatusNotifierWatcher"; +const WATCHER_INTERFACE: &str = "org.kde.StatusNotifierWatcher"; +const DBUS_PROPERTIES: &str = "org.freedesktop.DBus.Properties"; +const DBUS_INTROSPECTABLE: &str = "org.freedesktop.DBus.Introspectable"; +const PROCESS_INTERVAL: Duration = Duration::from_millis(100); +const REGISTRATION_TIMEOUT: Duration = Duration::from_secs(3); + +const SHOW_ID: i32 = 1; +const PREVIOUS_STYLE_ID: i32 = 2; +const MICROPHONES_ID: i32 = 3; +const SEPARATOR_ID: i32 = 4; +const QUIT_ID: i32 = 5; +const FIRST_MICROPHONE_ID: i32 = 100; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum TrayCommand { + ShowMain, + ActivatePreviousStyle, + SelectMicrophone(String), + Quit, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TrayMicrophone { + pub name: String, + pub is_default: bool, + pub selected: bool, +} + +#[derive(Debug)] +pub enum TrayError { + Dbus(String), + Worker(String), +} + +impl fmt::Display for TrayError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Dbus(message) => write!(f, "tray D-Bus initialization failed: {message}"), + Self::Worker(message) => write!(f, "tray worker failed: {message}"), + } + } +} + +impl std::error::Error for TrayError {} + +enum TrayControl { + SetMicrophones(Vec), + SetLang(Lang), + Shutdown, +} + +#[derive(Default)] +struct TrayMenuState { + revision: u32, + microphones: Vec, + lang: Option, +} + +impl TrayMenuState { + fn command_for_id(&self, id: i32) -> Option { + match id { + SHOW_ID => Some(TrayCommand::ShowMain), + PREVIOUS_STYLE_ID => Some(TrayCommand::ActivatePreviousStyle), + QUIT_ID => Some(TrayCommand::Quit), + FIRST_MICROPHONE_ID => Some(TrayCommand::SelectMicrophone(String::new())), + id if id > FIRST_MICROPHONE_ID => self + .microphones + .get((id - FIRST_MICROPHONE_ID - 1) as usize) + .map(|device| TrayCommand::SelectMicrophone(device.name.clone())), + _ => None, + } + } +} + +pub struct LinuxTray { + commands: mpsc::Receiver, + control: mpsc::Sender, + last_error: Arc>>, + shutdown: Arc, + worker: Option>, +} + +impl LinuxTray { + /// Start and register a StatusNotifierItem. `Ok` means the desktop watcher + /// accepted it; callers may then truthfully expose `supports_tray=true`. + pub fn start() -> Result { + #[cfg(target_os = "linux")] + { + let (command_tx, command_rx) = mpsc::channel(); + let (control_tx, control_rx) = mpsc::channel(); + let (ready_tx, ready_rx) = mpsc::sync_channel(1); + let last_error = Arc::new(Mutex::new(None)); + let shutdown = Arc::new(AtomicBool::new(false)); + let worker_error = Arc::clone(&last_error); + let worker_shutdown = Arc::clone(&shutdown); + let worker = std::thread::Builder::new() + .name("openless-tray".into()) + .spawn(move || { + let result = run_dbus_worker(command_tx, control_rx, worker_shutdown, ready_tx); + if let Err(error) = result { + *worker_error.lock().expect("tray error lock poisoned") = + Some(error.to_string()); + } + }) + .map_err(|error| TrayError::Worker(error.to_string()))?; + + match ready_rx.recv_timeout(REGISTRATION_TIMEOUT) { + Ok(Ok(())) => Ok(Self { + commands: command_rx, + control: control_tx, + last_error, + shutdown, + worker: Some(worker), + }), + Ok(Err(error)) => { + shutdown.store(true, Ordering::Release); + let _ = worker.join(); + Err(error) + } + Err(error) => { + shutdown.store(true, Ordering::Release); + let _ = worker.join(); + Err(TrayError::Worker(format!( + "timed out waiting for tray registration: {error}" + ))) + } + } + } + #[cfg(not(target_os = "linux"))] + { + Err(TrayError::Worker( + "StatusNotifierItem is available only on Linux".into(), + )) + } + } + + pub fn drain(&self, mut apply: impl FnMut(TrayCommand)) -> usize { + let mut count = 0; + while let Ok(command) = self.commands.try_recv() { + count += 1; + apply(command); + } + count + } + + pub fn set_microphones(&self, microphones: Vec) -> Result<(), TrayError> { + self.control + .send(TrayControl::SetMicrophones(microphones)) + .map_err(|_| TrayError::Worker("tray worker has stopped".into())) + } + + /// Set the UI language used for the tray menu labels. Callers should keep + /// this in sync with the persisted Linux-UI locale preference whenever it + /// changes so a system tray re-layout reads the right language. + pub fn set_lang(&self, lang: Lang) -> Result<(), TrayError> { + self.control + .send(TrayControl::SetLang(lang)) + .map_err(|_| TrayError::Worker("tray worker has stopped".into())) + } + + pub fn take_error(&self) -> Option { + self.last_error + .lock() + .expect("tray error lock poisoned") + .take() + } +} + +impl Drop for LinuxTray { + fn drop(&mut self) { + self.shutdown.store(true, Ordering::Release); + let _ = self.control.send(TrayControl::Shutdown); + if let Some(worker) = self.worker.take() { + let _ = worker.join(); + } + } +} + +#[cfg(target_os = "linux")] +fn run_dbus_worker( + command_tx: mpsc::Sender, + control_rx: mpsc::Receiver, + shutdown: Arc, + ready: mpsc::SyncSender>, +) -> Result<(), TrayError> { + use dbus::blocking::stdintf::org_freedesktop_dbus::RequestNameReply; + use dbus::blocking::Connection; + use dbus::channel::{MatchingReceiver, Sender}; + use dbus::message::MatchRule; + + let connection = Connection::new_session().map_err(dbus_error)?; + let service_name = format!("org.kde.StatusNotifierItem-{}-1", std::process::id()); + let ownership = connection + .request_name(&service_name, false, true, true) + .map_err(dbus_error)?; + if ownership != RequestNameReply::PrimaryOwner { + let error = TrayError::Dbus(format!("D-Bus name {service_name} is already owned")); + let _ = ready.send(Err(TrayError::Dbus(error.to_string()))); + return Err(error); + } + + let menu = Arc::new(Mutex::new(TrayMenuState::default())); + let callback_menu = Arc::clone(&menu); + let callback_commands = command_tx; + connection.start_receive( + MatchRule::new_method_call(), + Box::new(move |message, connection| { + if let Some(reply) = handle_method_call(&message, &callback_menu, &callback_commands) { + let _ = connection.send(reply); + } + true + }), + ); + + let watcher = connection.with_proxy(WATCHER_NAME, WATCHER_PATH, REGISTRATION_TIMEOUT); + let registration: Result<(), dbus::Error> = watcher.method_call( + WATCHER_INTERFACE, + "RegisterStatusNotifierItem", + (service_name.as_str(),), + ); + if let Err(error) = registration { + let error = TrayError::Dbus(format!( + "StatusNotifierWatcher rejected registration: {error}" + )); + let _ = ready.send(Err(TrayError::Dbus(error.to_string()))); + return Err(error); + } + let _ = ready.send(Ok(())); + + while !shutdown.load(Ordering::Acquire) { + while let Ok(control) = control_rx.try_recv() { + match control { + TrayControl::SetMicrophones(microphones) => { + let revision = { + let mut state = menu.lock().expect("tray menu lock poisoned"); + state.microphones = microphones; + state.revision = state.revision.wrapping_add(1).max(1); + state.revision + }; + let signal = + dbus::Message::new_signal(MENU_PATH, MENU_INTERFACE, "LayoutUpdated") + .map_err(TrayError::Dbus)? + .append2(revision, 0i32); + connection.send(signal).map_err(|_| { + TrayError::Dbus("failed to publish tray menu update".into()) + })?; + } + TrayControl::SetLang(lang) => { + let revision = { + let mut state = menu.lock().expect("tray menu lock poisoned"); + state.lang = Some(lang); + state.revision = state.revision.wrapping_add(1).max(1); + state.revision + }; + let signal = + dbus::Message::new_signal(MENU_PATH, MENU_INTERFACE, "LayoutUpdated") + .map_err(TrayError::Dbus)? + .append2(revision, 0i32); + connection.send(signal).map_err(|_| { + TrayError::Dbus("failed to publish tray menu update".into()) + })?; + } + TrayControl::Shutdown => return Ok(()), + } + } + connection.process(PROCESS_INTERVAL).map_err(dbus_error)?; + } + Ok(()) +} + +#[cfg(target_os = "linux")] +fn dbus_error(error: dbus::Error) -> TrayError { + TrayError::Dbus(error.to_string()) +} + +#[cfg(target_os = "linux")] +type Properties = HashMap>>; + +#[cfg(target_os = "linux")] +type Children = Vec>>; + +#[cfg(target_os = "linux")] +fn property( + value: T, +) -> dbus::arg::Variant> { + dbus::arg::Variant(Box::new(value)) +} + +#[cfg(target_os = "linux")] +fn menu_properties(label: &str) -> Properties { + HashMap::from([ + ("label".into(), property(label.to_string())), + ("enabled".into(), property(true)), + ("visible".into(), property(true)), + ]) +} + +#[cfg(target_os = "linux")] +fn menu_item( + id: i32, + properties: Properties, + children: Children, +) -> dbus::arg::Variant> { + property((id, properties, children)) +} + +#[cfg(target_os = "linux")] +fn menu_layout(state: &TrayMenuState) -> (i32, Properties, Children) { + let lang = state.lang.unwrap_or(Lang::ZhCn); + let mut microphone_children = Vec::new(); + let default_selected = state.microphones.iter().all(|device| !device.selected); + let mut default_props = menu_properties(tr_l10n(lang, "settings.system_default")); + default_props.insert("toggle-type".into(), property("checkmark".to_string())); + default_props.insert("toggle-state".into(), property(i32::from(default_selected))); + microphone_children.push(menu_item(FIRST_MICROPHONE_ID, default_props, Vec::new())); + for (index, device) in state.microphones.iter().enumerate() { + let mut props = menu_properties(&device.name); + props.insert("toggle-type".into(), property("checkmark".to_string())); + props.insert("toggle-state".into(), property(i32::from(device.selected))); + if device.is_default { + props.insert("x-openless-default".into(), property(true)); + } + microphone_children.push(menu_item( + FIRST_MICROPHONE_ID + index as i32 + 1, + props, + Vec::new(), + )); + } + let mut microphone_props = menu_properties(tr_l10n(lang, "settings.microphone")); + microphone_props.insert("children-display".into(), property("submenu".to_string())); + let separator = HashMap::from([("type".into(), property("separator".to_string()))]); + ( + 0, + HashMap::new(), + vec![ + menu_item( + SHOW_ID, + menu_properties(tr_l10n(lang, "tray.show")), + Vec::new(), + ), + menu_item( + PREVIOUS_STYLE_ID, + menu_properties(tr_l10n(lang, "tray.previous_style")), + Vec::new(), + ), + menu_item(MICROPHONES_ID, microphone_props, microphone_children), + menu_item(SEPARATOR_ID, separator, Vec::new()), + menu_item( + QUIT_ID, + menu_properties(tr_l10n(lang, "tray.quit")), + Vec::new(), + ), + ], + ) +} + +#[cfg(target_os = "linux")] +fn handle_method_call( + message: &dbus::Message, + menu: &Arc>, + commands: &mpsc::Sender, +) -> Option { + use dbus::arg::Variant; + + let path = message.path()?.to_string(); + let interface = message.interface()?.to_string(); + let member = message.member()?.to_string(); + + if interface == DBUS_INTROSPECTABLE && member == "Introspect" { + return Some(message.method_return().append1(INTROSPECTION_XML)); + } + if path == ITEM_PATH && interface == ITEM_INTERFACE { + if member == "Activate" || member == "SecondaryActivate" { + let _ = commands.send(TrayCommand::ShowMain); + } + return Some(message.method_return()); + } + if path == MENU_PATH && interface == MENU_INTERFACE { + match member.as_str() { + "GetLayout" => { + let state = menu.lock().expect("tray menu lock poisoned"); + return Some( + message + .method_return() + .append2(state.revision, menu_layout(&state)), + ); + } + "GetGroupProperties" => { + let entries: Vec<(i32, Properties)> = Vec::new(); + return Some(message.method_return().append1(entries)); + } + "Event" => { + if let Ok((id, event, _data, _timestamp)) = + message.read4::>, u32>() + { + if event == "clicked" { + if let Some(command) = menu + .lock() + .expect("tray menu lock poisoned") + .command_for_id(id) + { + let _ = commands.send(command); + } + } + } + return Some(message.method_return()); + } + "AboutToShow" => return Some(message.method_return().append1(false)), + _ => return Some(message.method_return()), + } + } + if interface == DBUS_PROPERTIES && member == "Get" { + if let Ok((requested_interface, name)) = message.read2::() { + let value = item_property(&requested_interface, &name) + .or_else(|| menu_property(&requested_interface, &name)); + if let Some(value) = value { + return Some(message.method_return().append1(value)); + } + } + } + if interface == DBUS_PROPERTIES && member == "GetAll" { + let requested_interface = message.read1::().unwrap_or_default(); + let properties = if requested_interface == ITEM_INTERFACE { + item_properties() + } else if requested_interface == MENU_INTERFACE { + menu_properties_all() + } else { + HashMap::new() + }; + return Some(message.method_return().append1(properties)); + } + dbus::channel::default_reply(message) +} + +#[cfg(target_os = "linux")] +fn item_property( + interface: &str, + name: &str, +) -> Option>> { + if interface != ITEM_INTERFACE { + return None; + } + match name { + "Category" => Some(property("ApplicationStatus".to_string())), + "Id" => Some(property("openless".to_string())), + "Title" => Some(property("OpenLess".to_string())), + "Status" => Some(property("Active".to_string())), + "IconName" => Some(property("openless".to_string())), + "Menu" => Some(property( + dbus::Path::new(MENU_PATH).expect("static D-Bus path"), + )), + "ItemIsMenu" => Some(property(false)), + _ => None, + } +} + +#[cfg(target_os = "linux")] +fn item_properties() -> Properties { + [ + "Category", + "Id", + "Title", + "Status", + "IconName", + "Menu", + "ItemIsMenu", + ] + .into_iter() + .filter_map(|name| item_property(ITEM_INTERFACE, name).map(|value| (name.into(), value))) + .collect() +} + +#[cfg(target_os = "linux")] +fn menu_property( + interface: &str, + name: &str, +) -> Option>> { + if interface != MENU_INTERFACE { + return None; + } + match name { + "Version" => Some(property(3u32)), + "TextDirection" => Some(property("ltr".to_string())), + "Status" => Some(property("normal".to_string())), + "IconThemePath" => Some(property(Vec::::new())), + _ => None, + } +} + +#[cfg(target_os = "linux")] +fn menu_properties_all() -> Properties { + ["Version", "TextDirection", "Status", "IconThemePath"] + .into_iter() + .filter_map(|name| menu_property(MENU_INTERFACE, name).map(|value| (name.into(), value))) + .collect() +} + +#[cfg(target_os = "linux")] +const INTROSPECTION_XML: &str = r#" + + + + + + + + + + + +"#; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn menu_ids_map_only_to_supported_commands() { + let state = TrayMenuState { + revision: 1, + microphones: vec![TrayMicrophone { + name: "Studio Mic".into(), + is_default: true, + selected: true, + }], + lang: None, + }; + assert_eq!(state.command_for_id(SHOW_ID), Some(TrayCommand::ShowMain)); + assert_eq!( + state.command_for_id(PREVIOUS_STYLE_ID), + Some(TrayCommand::ActivatePreviousStyle) + ); + assert_eq!(state.command_for_id(QUIT_ID), Some(TrayCommand::Quit)); + assert_eq!( + state.command_for_id(FIRST_MICROPHONE_ID + 1), + Some(TrayCommand::SelectMicrophone("Studio Mic".into())) + ); + assert_eq!(state.command_for_id(MICROPHONES_ID), None); + assert_eq!( + state.command_for_id(FIRST_MICROPHONE_ID), + Some(TrayCommand::SelectMicrophone(String::new())) + ); + assert_eq!(state.command_for_id(999), None); + } + + #[test] + fn tray_capability_is_not_inferred_from_the_desktop_environment() { + let snapshot = crate::LinuxCapabilitySnapshot::from_environment( + None, + Some(":0"), + true, + false, + crate::LinuxPackageKind::Development, + ); + assert!(!snapshot.capabilities.supports_tray); + } +} diff --git a/openless-all/app/linux-egui/src/ui/frontend/icons.rs b/openless-all/app/linux-egui/src/ui/frontend/icons.rs new file mode 100644 index 000000000..5e79caf35 --- /dev/null +++ b/openless-all/app/linux-egui/src/ui/frontend/icons.rs @@ -0,0 +1,464 @@ +use eframe::egui; + +#[derive(Clone, Copy)] +pub enum IconName { + Overview, + History, + Vocab, + Style, + SelectionAsk, + Translation, + Settings, + Mic, + Sparkle, + Hash, + Clock, + Bolt, + Copy, + Search, + Trash, + Refresh, + Download, + Play, + ChevronDown, +} + +/// Draw an icon centred at `center` with the given `color`. +pub fn draw_icon(ui: &egui::Ui, center: egui::Pos2, icon: IconName, color: egui::Color32) { + let p = ui.painter(); + let stroke = egui::Stroke::new(1.25, color); + match icon { + IconName::Overview => { + let s = 2.0 / 3.0; + p.add(egui::Shape::line( + [ + center + egui::vec2(-9.0 * s, -9.0 * s), + center + egui::vec2(-9.0 * s, 9.0 * s), + center + egui::vec2(9.0 * s, 9.0 * s), + ] + .to_vec(), + stroke, + )); + for (x, top) in [(6.0, 9.0), (1.0, 5.0), (-4.0, 14.0)] { + p.line_segment( + [ + center + egui::vec2(x * s, 5.0 * s), + center + egui::vec2(x * s, (top - 12.0) * s), + ], + stroke, + ); + } + } + IconName::History | IconName::Clock => { + p.circle_stroke(center, 6.0, stroke); + p.line_segment([center, center + egui::vec2(0.0, -3.5)], stroke); + p.line_segment([center, center + egui::vec2(3.0, 2.0)], stroke); + } + IconName::Search => { + let s = 0.5; + let stroke = egui::Stroke::new(1.0, color); + p.circle_stroke(center + egui::vec2(-0.5, -0.5), 4.0, stroke); + p.line_segment( + [ + center + egui::vec2(4.65 * s, 4.65 * s), + center + egui::vec2(9.0 * s, 9.0 * s), + ], + stroke, + ); + } + IconName::Trash => { + let s = 0.54; + let stroke = egui::Stroke::new(1.0, color); + let pt = |x: f32, y: f32| center + egui::vec2((x - 12.0) * s, (y - 12.0) * s); + p.line_segment([pt(3.0, 6.0), pt(21.0, 6.0)], stroke); + p.line_segment([pt(19.0, 6.0), pt(19.0, 20.0)], stroke); + p.line_segment([pt(19.0, 20.0), pt(17.0, 22.0)], stroke); + p.line_segment([pt(17.0, 22.0), pt(7.0, 22.0)], stroke); + p.line_segment([pt(7.0, 22.0), pt(5.0, 20.0)], stroke); + p.line_segment([pt(5.0, 20.0), pt(5.0, 6.0)], stroke); + p.line_segment([pt(8.0, 6.0), pt(8.0, 4.0)], stroke); + p.add(egui::Shape::QuadraticBezier( + egui::epaint::QuadraticBezierShape::from_points_stroke( + [pt(8.0, 4.0), pt(8.0, 2.0), pt(10.0, 2.0)], + false, + egui::Color32::TRANSPARENT, + stroke, + ), + )); + p.line_segment([pt(10.0, 2.0), pt(14.0, 2.0)], stroke); + p.add(egui::Shape::QuadraticBezier( + egui::epaint::QuadraticBezierShape::from_points_stroke( + [pt(14.0, 2.0), pt(16.0, 2.0), pt(16.0, 4.0)], + false, + egui::Color32::TRANSPARENT, + stroke, + ), + )); + p.line_segment([pt(16.0, 4.0), pt(16.0, 6.0)], stroke); + p.line_segment([pt(10.0, 11.0), pt(10.0, 17.0)], stroke); + p.line_segment([pt(14.0, 11.0), pt(14.0, 17.0)], stroke); + } + IconName::Refresh => { + let s = 0.54; + let stroke = egui::Stroke::new(1.0, color); + let pt = |x: f32, y: f32| center + egui::vec2((x - 12.0) * s, (y - 12.0) * s); + let arc = (0..=24) + .map(|step| { + let t = step as f32 / 24.0; + let angle = std::f32::consts::PI + - t * (std::f32::consts::PI + std::f32::consts::FRAC_PI_2); + center + egui::vec2(angle.cos() * 9.0 * s, angle.sin() * 9.0 * s) + }) + .collect::>(); + p.add(egui::Shape::line(arc, stroke)); + p.line_segment([pt(3.0, 3.0), pt(3.0, 8.0)], stroke); + p.line_segment([pt(3.0, 3.0), pt(8.0, 3.0)], stroke); + } + IconName::Download => { + let s = 0.54; + let stroke = egui::Stroke::new(1.0, color); + let pt = |x: f32, y: f32| center + egui::vec2((x - 12.0) * s, (y - 12.0) * s); + p.add(egui::Shape::line( + [ + pt(21.0, 15.0), + pt(21.0, 19.0), + pt(19.0, 21.0), + pt(5.0, 21.0), + pt(3.0, 19.0), + pt(3.0, 15.0), + ] + .to_vec(), + stroke, + )); + p.add(egui::Shape::line( + [pt(7.0, 10.0), pt(12.0, 15.0), pt(17.0, 10.0)].to_vec(), + stroke, + )); + p.line_segment([pt(12.0, 15.0), pt(12.0, 3.0)], stroke); + } + IconName::Play => { + let s = 0.54; + let stroke = egui::Stroke::new(1.0, color); + let pt = |x: f32, y: f32| center + egui::vec2((x - 12.0) * s, (y - 12.0) * s); + p.add(egui::Shape::line( + [pt(5.0, 3.0), pt(19.0, 12.0), pt(5.0, 21.0), pt(5.0, 3.0)].to_vec(), + stroke, + )); + } + IconName::ChevronDown => { + let s = 0.54; + let stroke = egui::Stroke::new(1.0, color); + let pt = |x: f32, y: f32| center + egui::vec2((x - 12.0) * s, (y - 12.0) * s); + p.add(egui::Shape::line( + [pt(6.0, 9.0), pt(12.0, 15.0), pt(18.0, 9.0)].to_vec(), + stroke, + )); + } + IconName::Vocab => { + let s = 2.0 / 3.0; + let point = |x: f32, y: f32| center + egui::vec2((x - 12.0) * s, (y - 12.0) * s); + p.add(egui::Shape::QuadraticBezier( + egui::epaint::QuadraticBezierShape::from_points_stroke( + [point(4.0, 19.5), point(4.0, 17.0), point(6.5, 17.0)], + false, + egui::Color32::TRANSPARENT, + stroke, + ), + )); + p.add(egui::Shape::line( + [point(6.5, 17.0), point(20.0, 17.0)].to_vec(), + stroke, + )); + p.add(egui::Shape::QuadraticBezier( + egui::epaint::QuadraticBezierShape::from_points_stroke( + [point(4.0, 19.5), point(4.0, 22.0), point(6.5, 22.0)], + false, + egui::Color32::TRANSPARENT, + stroke, + ), + )); + p.add(egui::Shape::line( + [ + point(6.5, 22.0), + point(20.0, 22.0), + point(20.0, 4.0), + point(6.5, 4.0), + ] + .to_vec(), + stroke, + )); + p.add(egui::Shape::QuadraticBezier( + egui::epaint::QuadraticBezierShape::from_points_stroke( + [point(6.5, 4.0), point(4.0, 4.0), point(4.0, 6.5)], + false, + egui::Color32::TRANSPARENT, + stroke, + ), + )); + p.line_segment([point(4.0, 6.5), point(4.0, 19.5)], stroke); + } + IconName::Style => { + let s = 2.0 / 3.0; + p.line_segment( + [ + center + egui::vec2(0.0, -10.0 * s), + center + egui::vec2(0.0, 10.0 * s), + ], + stroke, + ); + p.add(egui::Shape::line( + [ + center + egui::vec2(5.0 * s, -7.0 * s), + center + egui::vec2(-2.5 * s, -7.0 * s), + center + egui::vec2(-5.5 * s, -5.0 * s), + center + egui::vec2(-5.5 * s, -1.5 * s), + center + egui::vec2(-3.5 * s, 1.5 * s), + center + egui::vec2(3.0 * s, 1.5 * s), + center + egui::vec2(5.0 * s, 3.5 * s), + center + egui::vec2(4.0 * s, 6.0 * s), + center + egui::vec2(1.0 * s, 7.0 * s), + center + egui::vec2(-6.0 * s, 7.0 * s), + ] + .to_vec(), + stroke, + )); + } + IconName::SelectionAsk => { + p.rect_stroke( + egui::Rect::from_center_size( + center + egui::vec2(0.0, -1.0), + egui::vec2(13.0, 10.0), + ), + egui::CornerRadius::same(2), + stroke, + egui::StrokeKind::Inside, + ); + p.line_segment( + [ + center + egui::vec2(-2.0, 4.0), + center + egui::vec2(-5.0, 7.0), + ], + stroke, + ); + } + IconName::Translation => { + p.circle_stroke(center, 6.7, stroke); + p.line_segment( + [ + center + egui::vec2(-6.7, 0.0), + center + egui::vec2(6.7, 0.0), + ], + stroke, + ); + for sign in [-1.0, 1.0] { + p.add(egui::Shape::line( + [ + center + egui::vec2(0.0, -6.7), + center + egui::vec2(sign * 2.8, -4.0), + center + egui::vec2(sign * 3.3, 0.0), + center + egui::vec2(sign * 2.8, 4.0), + center + egui::vec2(0.0, 6.7), + ] + .to_vec(), + stroke, + )); + } + } + IconName::Mic => { + p.rect_stroke( + egui::Rect::from_center_size(center + egui::vec2(0.0, -2.0), egui::vec2(7.0, 11.0)), + egui::CornerRadius::same(4), + stroke, + egui::StrokeKind::Inside, + ); + p.line_segment( + [ + center + egui::vec2(-4.0, -2.0), + center + egui::vec2(-4.0, 1.0), + ], + stroke, + ); + p.line_segment( + [ + center + egui::vec2(4.0, -2.0), + center + egui::vec2(4.0, 1.0), + ], + stroke, + ); + p.line_segment( + [ + center + egui::vec2(-4.0, 1.0), + center + egui::vec2(4.0, 1.0), + ], + stroke, + ); + p.line_segment( + [center + egui::vec2(0.0, 1.0), center + egui::vec2(0.0, 5.0)], + stroke, + ); + p.line_segment( + [ + center + egui::vec2(-3.0, 5.0), + center + egui::vec2(3.0, 5.0), + ], + stroke, + ); + } + IconName::Sparkle => { + p.line_segment( + [ + center + egui::vec2(0.0, -7.0), + center + egui::vec2(2.5, -2.5), + ], + stroke, + ); + p.line_segment( + [ + center + egui::vec2(2.5, -2.5), + center + egui::vec2(7.0, 0.0), + ], + stroke, + ); + p.line_segment( + [center + egui::vec2(7.0, 0.0), center + egui::vec2(2.5, 2.5)], + stroke, + ); + p.line_segment( + [center + egui::vec2(2.5, 2.5), center + egui::vec2(0.0, 7.0)], + stroke, + ); + p.line_segment( + [ + center + egui::vec2(0.0, 7.0), + center + egui::vec2(-2.5, 2.5), + ], + stroke, + ); + p.line_segment( + [ + center + egui::vec2(-2.5, 2.5), + center + egui::vec2(-7.0, 0.0), + ], + stroke, + ); + p.line_segment( + [ + center + egui::vec2(-7.0, 0.0), + center + egui::vec2(-2.5, -2.5), + ], + stroke, + ); + p.line_segment( + [ + center + egui::vec2(-2.5, -2.5), + center + egui::vec2(0.0, -7.0), + ], + stroke, + ); + } + IconName::Hash => { + p.line_segment( + [ + center + egui::vec2(-6.0, -3.0), + center + egui::vec2(6.0, -3.0), + ], + stroke, + ); + p.line_segment( + [ + center + egui::vec2(-6.0, 3.0), + center + egui::vec2(6.0, 3.0), + ], + stroke, + ); + p.line_segment( + [ + center + egui::vec2(-2.0, -7.0), + center + egui::vec2(-4.0, 7.0), + ], + stroke, + ); + p.line_segment( + [ + center + egui::vec2(4.0, -7.0), + center + egui::vec2(2.0, 7.0), + ], + stroke, + ); + } + IconName::Bolt => { + p.line_segment( + [ + center + egui::vec2(1.0, -8.0), + center + egui::vec2(-5.0, 1.0), + ], + stroke, + ); + p.line_segment( + [ + center + egui::vec2(-5.0, 1.0), + center + egui::vec2(1.0, 1.0), + ], + stroke, + ); + p.line_segment( + [ + center + egui::vec2(1.0, 1.0), + center + egui::vec2(-1.0, 8.0), + ], + stroke, + ); + p.line_segment( + [ + center + egui::vec2(-1.0, 8.0), + center + egui::vec2(6.0, -1.0), + ], + stroke, + ); + p.line_segment( + [ + center + egui::vec2(6.0, -1.0), + center + egui::vec2(1.0, -1.0), + ], + stroke, + ); + p.line_segment( + [ + center + egui::vec2(1.0, -1.0), + center + egui::vec2(1.0, -8.0), + ], + stroke, + ); + } + IconName::Copy => { + p.rect_stroke( + egui::Rect::from_center_size(center + egui::vec2(1.5, 1.5), egui::vec2(10.0, 12.0)), + egui::CornerRadius::same(1), + stroke, + egui::StrokeKind::Inside, + ); + p.rect_stroke( + egui::Rect::from_center_size(center + egui::vec2(-1.5, -2.5), egui::vec2(8.0, 5.0)), + egui::CornerRadius::same(1), + stroke, + egui::StrokeKind::Inside, + ); + } + IconName::Settings => { + p.circle_stroke(center, 4.5, stroke); + for angle in [ + 0.0, + std::f32::consts::FRAC_PI_4, + std::f32::consts::FRAC_PI_2, + 3.0 * std::f32::consts::FRAC_PI_4, + std::f32::consts::PI, + 5.0 * std::f32::consts::FRAC_PI_4, + 3.0 * std::f32::consts::FRAC_PI_2, + 7.0 * std::f32::consts::FRAC_PI_4, + ] { + let direction = egui::vec2(angle.cos(), angle.sin()); + p.line_segment([center + direction * 5.0, center + direction * 7.0], stroke); + } + } + } +} diff --git a/openless-all/app/linux-egui/src/ui/frontend/layout.rs b/openless-all/app/linux-egui/src/ui/frontend/layout.rs new file mode 100644 index 000000000..0a122d7db --- /dev/null +++ b/openless-all/app/linux-egui/src/ui/frontend/layout.rs @@ -0,0 +1,691 @@ +use eframe::egui; + +use super::icons::{self, IconName}; +use super::theme; +use super::view_model::{FrontendAction, FrontendViewModel, Page}; + +pub const SIDEBAR_WIDTH: f32 = 188.0; +pub const TITLEBAR_HEIGHT: f32 = 38.0; +const WINDOW_MARGIN: f32 = 6.0; +const WINDOW_RADIUS: u8 = 14; + +// ── Window geometry helpers ───────────────────────────────────────────────── + +pub fn window_rect(ctx: &egui::Context) -> egui::Rect { + ctx.content_rect().shrink(WINDOW_MARGIN) +} + +pub fn body_rect(ctx: &egui::Context) -> egui::Rect { + let window = window_rect(ctx); + egui::Rect::from_min_max(window.min + egui::vec2(0.0, TITLEBAR_HEIGHT), window.max) +} + +// ── App icon ──────────────────────────────────────────────────────────────── + +pub fn load_app_icon(ctx: &egui::Context) -> egui::TextureHandle { + let id = egui::Id::new("openless-frontend-app-icon"); + if let Some(texture) = ctx.data(|data| data.get_temp::(id)) { + return texture; + } + let image = image::load_from_memory(include_bytes!("../../../../public/AppIcon.png")) + .expect("OpenLess AppIcon.png must be valid") + .into_rgba8(); + let color = egui::ColorImage::from_rgba_unmultiplied( + [image.width() as usize, image.height() as usize], + image.as_raw(), + ); + let texture = ctx.load_texture("openless-app-icon", color, egui::TextureOptions::LINEAR); + ctx.data_mut(|data| data.insert_temp(id, texture.clone())); + texture +} + +pub fn paint_app_icon(ui: &egui::Ui, rect: egui::Rect, texture: &egui::TextureHandle) { + ui.painter().image( + texture.id(), + rect, + egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)), + egui::Color32::WHITE, + ); +} + +// ── Window background ─────────────────────────────────────────────────────── + +pub fn paint_window_background(ctx: &egui::Context) { + let window = window_rect(ctx); + let body = body_rect(ctx); + let painter = ctx.layer_painter(egui::LayerId::new( + egui::Order::Background, + egui::Id::new("openless-window-background"), + )); + painter.rect_filled( + window, + egui::CornerRadius::same(WINDOW_RADIUS), + theme::SURFACE, + ); + painter.rect_filled( + body, + egui::CornerRadius { + nw: 0, + ne: 0, + sw: WINDOW_RADIUS, + se: WINDOW_RADIUS, + }, + theme::CANVAS, + ); + painter.rect_stroke( + window, + egui::CornerRadius::same(WINDOW_RADIUS), + egui::Stroke::new(1.0, theme::LINE), + egui::StrokeKind::Inside, + ); +} + +// ── Titlebar ──────────────────────────────────────────────────────────────── + +pub fn titlebar(ctx: &egui::Context, actions: &mut Vec) { + let window = window_rect(ctx); + let titlebar = egui::Rect::from_min_max( + window.min, + egui::pos2(window.max.x, window.min.y + TITLEBAR_HEIGHT), + ); + + egui::Area::new(egui::Id::new("openless-titlebar")) + .order(egui::Order::Middle) + // The titlebar defines its own drag zone and window-control buttons. + // Keep the area in the hit-test stack for its children, without adding + // an area-wide click target that would consume their input. + .sense(egui::Sense::hover()) + .fixed_pos(window.min) + .show(ctx, |ui| { + ui.set_min_size(egui::vec2(window.width(), TITLEBAR_HEIGHT)); + let drag = ui.interact( + titlebar, + ui.id().with("titlebar-drag"), + egui::Sense::click_and_drag(), + ); + if drag.drag_started() { + ctx.send_viewport_cmd(egui::ViewportCommand::StartDrag); + } + let texture = load_app_icon(ctx); + paint_app_icon( + ui, + egui::Rect::from_center_size( + window.min + egui::vec2(16.0, TITLEBAR_HEIGHT / 2.0), + egui::vec2(18.0, 18.0), + ), + &texture, + ); + ui.painter().text( + window.min + egui::vec2(34.0, TITLEBAR_HEIGHT / 2.0 + 0.5), + egui::Align2::LEFT_CENTER, + "OpenLess", + egui::FontId::proportional(13.0), + theme::INK_2, + ); + + let button_width = 40.0; + let close = egui::Rect::from_min_max( + egui::pos2(titlebar.right() - button_width, titlebar.top()), + titlebar.right_bottom(), + ); + let maximize = close.translate(egui::vec2(-button_width, 0.0)); + let minimize = maximize.translate(egui::vec2(-button_width, 0.0)); + let close_response = ui.interact(close, ui.id().with("close"), egui::Sense::click()); + let maximize_response = + ui.interact(maximize, ui.id().with("maximize"), egui::Sense::click()); + let minimize_response = + ui.interact(minimize, ui.id().with("minimize"), egui::Sense::click()); + if close_response.clicked() { + actions.push(FrontendAction::WindowClose); + } + if maximize_response.clicked() { + actions.push(FrontendAction::WindowMaximize); + } + if minimize_response.clicked() { + actions.push(FrontendAction::WindowMinimize); + } + for (rect, response) in [ + (minimize, &minimize_response), + (maximize, &maximize_response), + (close, &close_response), + ] { + if response.hovered() { + ui.painter() + .rect_filled(rect, egui::CornerRadius::same(6), theme::SURFACE_2); + } + } + let stroke = egui::Stroke::new(1.0, theme::INK_3); + ui.painter().line_segment( + [ + minimize.center() - egui::vec2(5.0, 0.0), + minimize.center() + egui::vec2(5.0, 0.0), + ], + stroke, + ); + ui.painter().rect_stroke( + maximize.shrink(14.0), + egui::CornerRadius::ZERO, + stroke, + egui::StrokeKind::Inside, + ); + ui.painter().line_segment( + [ + close.center() - egui::vec2(5.0, 5.0), + close.center() + egui::vec2(5.0, 5.0), + ], + stroke, + ); + ui.painter().line_segment( + [ + close.center() + egui::vec2(5.0, -5.0), + close.center() + egui::vec2(-5.0, 5.0), + ], + stroke, + ); + }); +} + +// ── Resize handles ────────────────────────────────────────────────────────── + +pub fn resize_handles(ctx: &egui::Context) { + let window = window_rect(ctx); + let edge = 10.0; + let corner = 18.0; + let left = window.left(); + let right = window.right(); + let top = window.top(); + let bottom = window.bottom(); + let zones = [ + ( + egui::Rect::from_min_max( + egui::pos2(left, top), + egui::pos2(left + corner, top + corner), + ), + egui::ResizeDirection::NorthWest, + ), + ( + egui::Rect::from_min_max( + egui::pos2(right - corner, top), + egui::pos2(right, top + corner), + ), + egui::ResizeDirection::NorthEast, + ), + ( + egui::Rect::from_min_max( + egui::pos2(left, bottom - corner), + egui::pos2(left + corner, bottom), + ), + egui::ResizeDirection::SouthWest, + ), + ( + egui::Rect::from_min_max( + egui::pos2(right - corner, bottom - corner), + egui::pos2(right, bottom), + ), + egui::ResizeDirection::SouthEast, + ), + ( + egui::Rect::from_min_max( + egui::pos2(left + corner, top), + egui::pos2(right - corner, top + edge), + ), + egui::ResizeDirection::North, + ), + ( + egui::Rect::from_min_max( + egui::pos2(left + corner, bottom - edge), + egui::pos2(right - corner, bottom), + ), + egui::ResizeDirection::South, + ), + ( + egui::Rect::from_min_max( + egui::pos2(left, top + corner), + egui::pos2(left + edge, bottom - corner), + ), + egui::ResizeDirection::West, + ), + ( + egui::Rect::from_min_max( + egui::pos2(right - edge, top + corner), + egui::pos2(right, bottom - corner), + ), + egui::ResizeDirection::East, + ), + ]; + + // Each edge gets its own foreground area. A single window-sized Area would + // become the top hit-test layer for the entire UI, including its transparent + // interior, and would swallow every button click. + for (index, (rect, direction)) in zones.into_iter().enumerate() { + let response = egui::Area::new(egui::Id::new(("openless-resize", index))) + .order(egui::Order::Foreground) + .fixed_pos(rect.min) + .default_size(rect.size()) + .sense(egui::Sense::drag()) + .show(ctx, |ui| ui.set_min_size(rect.size())) + .response; + if response.drag_started() { + ctx.send_viewport_cmd(egui::ViewportCommand::BeginResize(direction)); + } + } +} + +// ── Sidebar ───────────────────────────────────────────────────────────────── + +pub fn sidebar(ctx: &egui::Context, vm: &mut FrontendViewModel, actions: &mut Vec) { + let body = body_rect(ctx); + egui::Area::new(egui::Id::new("openless-sidebar")) + .order(egui::Order::Middle) + // Navigation rows own their input. A hover-only area preserves their + // layer while avoiding an invisible area-wide click target. + .sense(egui::Sense::hover()) + .fixed_pos(body.min) + .show(ctx, |ui| { + ui.set_min_size(egui::vec2(SIDEBAR_WIDTH, body.height())); + ui.set_clip_rect(egui::Rect::from_min_size( + body.min, + egui::vec2(SIDEBAR_WIDTH, body.height()), + )); + ui.painter().rect_filled(ui.max_rect(), 0.0, theme::SURFACE); + ui.painter().line_segment( + [ + egui::pos2(SIDEBAR_WIDTH, 0.0), + egui::pos2(SIDEBAR_WIDTH, body.height()), + ], + egui::Stroke::new(1.0, theme::LINE), + ); + egui::Frame::NONE + .inner_margin(egui::Margin::symmetric(10, 12)) + .show(ui, |ui| { + ui.set_width(SIDEBAR_WIDTH - 20.0); + ui.horizontal(|ui| { + let (rect, _) = + ui.allocate_exact_size(egui::vec2(20.0, 22.0), egui::Sense::hover()); + let texture = load_app_icon(ctx); + paint_app_icon(ui, rect, &texture); + ui.label(egui::RichText::new("OpenLess").strong().size(14.0)); + }); + ui.add_space(16.0); + nav(ui, vm, "概览", Page::Overview, IconName::Overview, actions); + nav(ui, vm, "历史", Page::History, IconName::History, actions); + nav(ui, vm, "词汇表", Page::Vocab, IconName::Vocab, actions); + ui.add_space(4.0); + group(ui, vm, "风格", IconName::Style, actions); + if vm.style_open { + subnav(ui, vm, "润色模式", Page::Style, actions); + subnav(ui, vm, "风格市场", Page::Marketplace, actions); + } + group(ui, vm, "工具", IconName::SelectionAsk, actions); + if vm.tools_open { + subnav(ui, vm, "划词追问", Page::SelectionAsk, actions); + subnav(ui, vm, "翻译", Page::Translation, actions); + } + ui.with_layout(egui::Layout::bottom_up(egui::Align::Min), |ui| { + nav_with_icon(ui, vm, "设置", Page::Settings, IconName::Settings, actions); + ui.add_space(10.0); + ui.horizontal(|ui| { + ui.add_space(10.0); + ui.vertical(|ui| { + egui::Frame::new() + .fill(theme::BLUE_SOFT) + .corner_radius(egui::CornerRadius::same(7)) + .inner_margin(egui::Margin::symmetric(6, 2)) + .show(ui, |ui| { + ui.label( + egui::RichText::new("BETA") + .size(9.5) + .strong() + .color(theme::BLUE), + ); + }); + ui.add_space(3.0); + ui.label( + egui::RichText::new(format!("版本 {}", vm.version)) + .size(10.5) + .color(theme::INK_4), + ); + }); + }); + }); + }); + }); +} + +fn nav( + ui: &mut egui::Ui, + vm: &mut FrontendViewModel, + label: &str, + page: Page, + icon: IconName, + actions: &mut Vec, +) { + nav_with_icon(ui, vm, label, page, icon, actions); +} + +fn nav_with_icon( + ui: &mut egui::Ui, + vm: &mut FrontendViewModel, + label: &str, + page: Page, + icon: IconName, + actions: &mut Vec, +) { + let active = vm.active_page == page; + let (rect, response) = + ui.allocate_exact_size(egui::vec2(SIDEBAR_WIDTH - 20.0, 32.0), egui::Sense::click()); + if active { + ui.painter() + .rect_filled(rect, egui::CornerRadius::same(8), theme::SURFACE_2); + } + let color = if active { theme::INK } else { theme::INK_3 }; + icons::draw_icon(ui, rect.min + egui::vec2(20.0, 16.0), icon, color); + ui.painter().text( + rect.min + egui::vec2(38.0, 16.0), + egui::Align2::LEFT_CENTER, + label, + egui::FontId::proportional(13.0), + color, + ); + if response.clicked() { + actions.push(FrontendAction::Navigate(page)); + if page == Page::Settings { + actions.push(FrontendAction::ToggleSettings); + } + } +} + +fn subnav( + ui: &mut egui::Ui, + vm: &mut FrontendViewModel, + label: &str, + page: Page, + actions: &mut Vec, +) { + let active = vm.active_page == page; + let (rect, response) = + ui.allocate_exact_size(egui::vec2(SIDEBAR_WIDTH - 20.0, 30.0), egui::Sense::click()); + if active { + ui.painter() + .rect_filled(rect, egui::CornerRadius::same(8), theme::SURFACE_2); + } + ui.painter().text( + rect.min + egui::vec2(30.0, 15.0), + egui::Align2::LEFT_CENTER, + label, + egui::FontId::proportional(12.5), + if active { theme::INK } else { theme::INK_3 }, + ); + if response.clicked() { + actions.push(FrontendAction::Navigate(page)); + } +} + +fn group( + ui: &mut egui::Ui, + vm: &mut FrontendViewModel, + label: &str, + icon: IconName, + actions: &mut Vec, +) { + let (rect, response) = + ui.allocate_exact_size(egui::vec2(SIDEBAR_WIDTH - 20.0, 32.0), egui::Sense::click()); + let color = if response.hovered() { + theme::INK_2 + } else { + theme::INK_3 + }; + icons::draw_icon(ui, rect.min + egui::vec2(20.0, 16.0), icon, color); + ui.painter().text( + rect.min + egui::vec2(38.0, 16.0), + egui::Align2::LEFT_CENTER, + label, + egui::FontId::proportional(13.0), + color, + ); + let x = rect.max.x - 18.0; + let y = rect.center().y; + let is_open = match icon { + IconName::Style => vm.style_open, + _ => vm.tools_open, + }; + if is_open { + ui.painter().line_segment( + [egui::pos2(x - 3.0, y - 1.0), egui::pos2(x, y + 2.0)], + egui::Stroke::new(1.2, color), + ); + ui.painter().line_segment( + [egui::pos2(x, y + 2.0), egui::pos2(x + 3.0, y - 1.0)], + egui::Stroke::new(1.2, color), + ); + } else { + ui.painter().line_segment( + [egui::pos2(x - 1.0, y - 3.0), egui::pos2(x + 2.0, y)], + egui::Stroke::new(1.2, color), + ); + ui.painter().line_segment( + [egui::pos2(x + 2.0, y), egui::pos2(x - 1.0, y + 3.0)], + egui::Stroke::new(1.2, color), + ); + } + if response.clicked() { + match icon { + IconName::Style => actions.push(FrontendAction::SidebarToggleStyle), + _ => actions.push(FrontendAction::SidebarToggleTools), + } + } +} + +// ── Content panel ─────────────────────────────────────────────────────────── + +pub fn content_panel(ctx: &egui::Context, add_contents: impl FnOnce(&mut egui::Ui)) { + let body = body_rect(ctx); + let content = egui::Rect::from_min_max( + egui::pos2(body.left() + SIDEBAR_WIDTH + 28.0, body.top()), + egui::pos2(body.right() - 2.0, body.bottom() - 8.0), + ); + egui::Area::new(egui::Id::new("openless-content")) + .order(egui::Order::Middle) + // Buttons and text fields inside the panel register their own hit targets. + .sense(egui::Sense::hover()) + .fixed_pos(content.min) + .show(ctx, |ui| { + ui.set_min_size(content.size()); + ui.set_max_size(content.size()); + ui.set_clip_rect(content); + let scroll = &mut ui.style_mut().spacing.scroll; + scroll.floating = true; + scroll.bar_width = 8.0; + scroll.handle_min_length = 24.0; + scroll.bar_inner_margin = 0.0; + scroll.bar_outer_margin = 0.0; + scroll.foreground_color = false; + scroll.floating_width = 6.0; + scroll.floating_allocated_width = 0.0; + let visuals = &mut ui.style_mut().visuals.widgets; + visuals.inactive.corner_radius = egui::CornerRadius::same(6); + visuals.hovered.corner_radius = egui::CornerRadius::same(6); + visuals.active.corner_radius = egui::CornerRadius::same(6); + add_contents(ui); + }); +} + +// ── Shared helpers ────────────────────────────────────────────────────────── + +pub fn icon_text_button( + ui: &mut egui::Ui, + label: &str, + icon: IconName, + width: f32, +) -> egui::Response { + let (rect, response) = ui.allocate_exact_size(egui::vec2(width, 30.0), egui::Sense::click()); + let hovered = response.hovered(); + ui.painter().rect_filled( + rect, + egui::CornerRadius::same(8), + if hovered { + theme::SURFACE_2 + } else { + theme::SURFACE + }, + ); + ui.painter().rect_stroke( + rect, + egui::CornerRadius::same(8), + egui::Stroke::new(0.8, theme::LINE), + egui::StrokeKind::Inside, + ); + let icon_center = egui::pos2(rect.left() + 16.0, rect.center().y); + icons::draw_icon(ui, icon_center, icon, theme::INK_3); + ui.painter().text( + egui::pos2(rect.left() + 28.0, rect.center().y), + egui::Align2::LEFT_CENTER, + label, + egui::FontId::proportional(11.5), + theme::INK_2, + ); + response +} + +pub fn text_chevron_button(ui: &mut egui::Ui, label: &str, width: f32) -> egui::Response { + let (rect, response) = ui.allocate_exact_size(egui::vec2(width, 30.0), egui::Sense::click()); + ui.painter().rect_filled( + rect, + egui::CornerRadius::same(8), + if response.hovered() { + theme::SURFACE_2 + } else { + theme::SURFACE + }, + ); + ui.painter().rect_stroke( + rect, + egui::CornerRadius::same(8), + egui::Stroke::new(0.8, theme::LINE), + egui::StrokeKind::Inside, + ); + ui.painter().text( + egui::pos2(rect.left() + 12.0, rect.center().y), + egui::Align2::LEFT_CENTER, + label, + egui::FontId::proportional(12.0), + theme::INK_2, + ); + icons::draw_icon( + ui, + egui::pos2(rect.right() - 14.0, rect.center().y), + IconName::ChevronDown, + theme::INK_3, + ); + response +} + +pub fn small_pill( + ui: &mut egui::Ui, + text: &str, + fill: egui::Color32, + border: egui::Color32, + color: egui::Color32, +) -> egui::Response { + let width = (text.chars().count() as f32 * 10.0 + 16.0).max(42.0); + let (rect, response) = ui.allocate_exact_size(egui::vec2(width, 22.0), egui::Sense::hover()); + ui.painter() + .rect_filled(rect, egui::CornerRadius::same(9), fill); + ui.painter().rect_stroke( + rect, + egui::CornerRadius::same(9), + egui::Stroke::new(0.7, border), + egui::StrokeKind::Inside, + ); + ui.painter().text( + rect.center(), + egui::Align2::CENTER_CENTER, + text, + egui::FontId::proportional(10.5), + color, + ); + response +} + +pub fn card_at(ui: &mut egui::Ui, rect: egui::Rect, contents: impl FnOnce(&mut egui::Ui)) { + ui.painter() + .rect_filled(rect, egui::CornerRadius::same(14), theme::SURFACE); + ui.painter().rect_stroke( + rect, + egui::CornerRadius::same(14), + egui::Stroke::new(1.0, theme::LINE), + egui::StrokeKind::Inside, + ); + let inner = rect.shrink(17.0); + ui.scope_builder( + egui::UiBuilder::new() + .max_rect(inner) + .layout(egui::Layout::top_down(egui::Align::Min)), + |ui| { + ui.set_clip_rect(ui.clip_rect().intersect(rect)); + contents(ui); + }, + ); +} + +pub fn tag(ui: &egui::Ui, pos: egui::Pos2, text: &str, blue: bool) { + let width = (text.chars().count() as f32 * 10.0 + 16.0).max(48.0); + let rect = egui::Rect::from_min_size(pos, egui::vec2(width, 20.0)); + ui.painter().rect_filled( + rect, + egui::CornerRadius::same(9), + if blue { + theme::BLUE_SOFT + } else { + theme::SURFACE_2 + }, + ); + ui.painter().text( + rect.center(), + egui::Align2::CENTER_CENTER, + text, + egui::FontId::proportional(10.0), + if blue { theme::BLUE } else { theme::INK_3 }, + ); +} + +pub fn soft_separator(ui: &mut egui::Ui) { + let rect = ui + .allocate_exact_size(egui::vec2(ui.available_width(), 1.0), egui::Sense::hover()) + .0; + ui.painter().line_segment( + [rect.left_center(), rect.right_center()], + egui::Stroke::new(0.5, egui::Color32::from_rgb(242, 242, 244)), + ); +} + +pub fn unsupported_page(ui: &mut egui::Ui, title: &str) { + ui.add_space(28.0); + ui.label( + egui::RichText::new(title) + .size(28.0) + .strong() + .color(theme::INK), + ); + ui.add_space(22.0); + egui::Frame::new() + .fill(theme::SURFACE) + .stroke(egui::Stroke::new(1.0, theme::LINE)) + .corner_radius(egui::CornerRadius::same(14)) + .inner_margin(egui::Margin::same(28)) + .show(ui, |ui| { + ui.vertical_centered(|ui| { + ui.label( + egui::RichText::new("此页面暂未接线") + .size(13.0) + .color(theme::INK_3), + ); + ui.add_space(4.0); + ui.label( + egui::RichText::new("数据桥接将在后续阶段完成") + .size(11.0) + .color(theme::INK_4), + ); + }); + }); +} diff --git a/openless-all/app/linux-egui/src/ui/frontend/marketplace.rs b/openless-all/app/linux-egui/src/ui/frontend/marketplace.rs new file mode 100644 index 000000000..0ac98539a --- /dev/null +++ b/openless-all/app/linux-egui/src/ui/frontend/marketplace.rs @@ -0,0 +1,472 @@ +use eframe::egui; + +use super::theme; +use super::view_model::{FrontendAction, FrontendViewModel, MarketplaceSort}; + +/// Render the marketplace page. All data comes from the view model; this +/// function is pure rendering — it reads from `vm` and pushes actions. +pub fn marketplace_page( + ui: &mut egui::Ui, + vm: &mut FrontendViewModel, + actions: &mut Vec, + body_rect: egui::Rect, +) { + ui.horizontal(|ui| { + ui.label( + egui::RichText::new("探索社区风格包") + .size(13.0) + .color(theme::INK_3), + ); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let refresh = ui.add( + egui::Button::new(egui::RichText::new("↻ 刷新").size(11.5)) + .fill(theme::SURFACE) + .stroke(egui::Stroke::new(0.8, theme::LINE)) + .corner_radius(egui::CornerRadius::same(8)) + .min_size(egui::vec2(70.0, 29.0)), + ); + if refresh.clicked() { + actions.push(FrontendAction::MarketplaceRefresh); + } + ui.add_space(8.0); + let mine = ui.add( + egui::Button::new(egui::RichText::new("我的发布").size(11.5)) + .fill(theme::SURFACE) + .stroke(egui::Stroke::new(0.8, theme::LINE)) + .corner_radius(egui::CornerRadius::same(8)) + .min_size(egui::vec2(78.0, 29.0)), + ); + if mine.clicked() { + actions.push(FrontendAction::MarketplaceMyPacks); + } + }); + }); + ui.add_space(18.0); + + // Search + sort + ui.horizontal(|ui| { + let search_width = (ui.available_width() - 250.0).max(180.0); + egui::Frame::new() + .fill(theme::SURFACE) + .stroke(egui::Stroke::new(1.0, theme::LINE)) + .corner_radius(egui::CornerRadius::same(10)) + .inner_margin(egui::Margin::symmetric(10, 6)) + .show(ui, |ui| { + ui.set_width(search_width); + ui.horizontal(|ui| { + let (icon_rect, _) = + ui.allocate_exact_size(egui::vec2(18.0, 18.0), egui::Sense::hover()); + let icon_center = icon_rect.center() - egui::vec2(1.5, 1.5); + let icon_stroke = egui::Stroke::new(1.4, theme::INK_3); + ui.painter().circle_stroke(icon_center, 5.5, icon_stroke); + ui.painter().line_segment( + [ + icon_center + egui::vec2(4.0, 4.0), + icon_center + egui::vec2(8.0, 8.0), + ], + icon_stroke, + ); + let mut query = vm.marketplace_query.clone(); + let resp = ui.add( + egui::TextEdit::singleline(&mut query) + .hint_text("搜索风格包") + .frame(false) + .desired_width(search_width - 34.0), + ); + if resp.changed() { + actions.push(FrontendAction::MarketplaceSearch(query)); + } + }); + }); + ui.add_space(10.0); + for (mode, label) in [ + (MarketplaceSort::Popular, "热门"), + (MarketplaceSort::New, "最新"), + (MarketplaceSort::Liked, "我赞过的"), + ] { + let selected = vm.marketplace_sort == mode; + let response = ui.add( + egui::Button::new(egui::RichText::new(label).size(12.0).color(if selected { + theme::BLUE + } else { + theme::INK_2 + })) + .fill(if selected { + theme::BLUE_SOFT + } else { + theme::SURFACE + }) + .stroke(egui::Stroke::new(1.0, theme::LINE)) + .corner_radius(egui::CornerRadius::same(8)) + .min_size(egui::vec2(64.0, 30.0)), + ); + if response.clicked() { + actions.push(FrontendAction::MarketplaceSort(mode)); + } + } + }); + ui.add_space(16.0); + + // Notice + if let Some(notice) = &vm.marketplace_notice { + egui::Frame::new() + .fill(theme::BLUE_SOFT) + .corner_radius(egui::CornerRadius::same(8)) + .inner_margin(egui::Margin::symmetric(10, 7)) + .show(ui, |ui| { + ui.label(egui::RichText::new(notice).size(11.5).color(theme::BLUE)); + }); + ui.add_space(10.0); + } + + if vm.marketplace_loading { + ui.horizontal(|ui| { + ui.spinner(); + ui.label("正在加载风格市场…"); + }); + return; + } + + if vm.marketplace_unsupported { + egui::Frame::new() + .fill(theme::SURFACE) + .stroke(egui::Stroke::new(1.0, theme::LINE)) + .corner_radius(egui::CornerRadius::same(12)) + .inner_margin(egui::Margin::same(28)) + .show(ui, |ui| { + ui.vertical_centered(|ui| { + ui.label( + egui::RichText::new("风格市场暂未接线") + .size(13.0) + .color(theme::INK_3), + ); + ui.add_space(4.0); + ui.label( + egui::RichText::new("市场后端桥接将在后续阶段完成") + .size(11.0) + .color(theme::INK_4), + ); + }); + }); + return; + } + + if vm.marketplace_packs.is_empty() { + egui::Frame::new() + .fill(theme::SURFACE) + .stroke(egui::Stroke::new(1.0, theme::LINE)) + .corner_radius(egui::CornerRadius::same(12)) + .inner_margin(egui::Margin::same(28)) + .show(ui, |ui| { + ui.vertical_centered(|ui| { + ui.label( + egui::RichText::new("暂时没有找到风格包") + .size(13.0) + .color(theme::INK_3), + ); + ui.add_space(4.0); + ui.label( + egui::RichText::new("试试其他关键词或筛选条件") + .size(11.0) + .color(theme::INK_4), + ); + }); + }); + } else { + let columns = if ui.available_width() >= 900.0 { + 3 + } else if ui.available_width() >= 600.0 { + 2 + } else { + 1 + }; + let gap = 12.0; + let card_width = (ui.available_width() - gap * (columns - 1) as f32) / columns as f32; + ui.columns(columns, |uis| { + for (column, column_ui) in uis.iter_mut().enumerate() { + for (pack_index, pack) in vm.marketplace_packs.iter().enumerate() { + if pack_index % columns != column { + continue; + } + marketplace_card(column_ui, card_width, pack, pack_index, vm, actions); + column_ui.add_space(gap); + } + } + }); + } + + // Detail modal + if let Some(index) = vm.marketplace_selected { + if let Some(pack) = vm.marketplace_packs.get(index) { + marketplace_detail( + ui.ctx(), + pack, + index, + vm.marketplace_liked.contains(&index), + body_rect, + actions, + ); + } + } +} + +fn marketplace_card( + ui: &mut egui::Ui, + width: f32, + pack: &super::view_model::MarketplacePack, + index: usize, + _vm: &FrontendViewModel, + actions: &mut Vec, +) { + let (rect, response) = ui.allocate_exact_size(egui::vec2(width, 156.0), egui::Sense::click()); + let fill = if response.hovered() { + theme::SURFACE_2 + } else { + theme::SURFACE + }; + ui.painter() + .rect_filled(rect, egui::CornerRadius::same(12), fill); + ui.painter().rect_stroke( + rect, + egui::CornerRadius::same(12), + egui::Stroke::new(1.0, theme::LINE), + egui::StrokeKind::Inside, + ); + let inner = rect.shrink(14.0); + let mut card_ui = ui.new_child( + egui::UiBuilder::new() + .max_rect(inner) + .layout(egui::Layout::top_down(egui::Align::Min)), + ); + let ui = &mut card_ui; + ui.style_mut().interaction.selectable_labels = false; + ui.horizontal(|ui| { + ui.label( + egui::RichText::new(&pack.name) + .size(14.0) + .strong() + .color(theme::INK), + ); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.label( + egui::RichText::new(format!("v{}", pack.version)) + .size(10.0) + .color(theme::INK_4) + .family(egui::FontFamily::Monospace), + ); + }); + }); + ui.add_space(6.0); + let description_width = ui.available_width(); + ui.allocate_ui_with_layout( + egui::vec2(description_width, 36.0), + egui::Layout::top_down(egui::Align::Min), + |ui| { + ui.add( + egui::Label::new( + egui::RichText::new(&pack.description) + .size(12.0) + .color(theme::INK_3), + ) + .wrap(), + ); + }, + ); + ui.add_space(8.0); + ui.horizontal(|ui| { + pill(ui, &pack.mode, true); + for tag in pack.tags.iter().take(2) { + pill(ui, tag, false); + } + }); + ui.with_layout(egui::Layout::bottom_up(egui::Align::Min), |ui| { + ui.horizontal(|ui| { + ui.label( + egui::RichText::new(format!("@{}", pack.author)) + .size(11.0) + .color(theme::INK_3), + ); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let download = ui.add( + egui::Button::new(egui::RichText::new("下载 ZIP").size(10.0)) + .fill(theme::SURFACE_2) + .stroke(egui::Stroke::new(0.7, theme::LINE)) + .corner_radius(egui::CornerRadius::same(7)) + .min_size(egui::vec2(62.0, 23.0)), + ); + if download.clicked() { + actions.push(FrontendAction::MarketplaceDownload(index)); + } + ui.add_space(7.0); + ui.label( + egui::RichText::new(format!("☆ {} · ↓ {}", pack.likes, pack.downloads)) + .size(10.5) + .color(theme::INK_4), + ); + }); + }); + }); + if response.clicked() { + actions.push(FrontendAction::MarketplaceDetail(index)); + } +} + +fn pill(ui: &mut egui::Ui, text: &str, outline: bool) { + egui::Frame::new() + .fill(if outline { + egui::Color32::TRANSPARENT + } else { + theme::SURFACE_2 + }) + .stroke(egui::Stroke::new( + 0.5, + if outline { + theme::LINE + } else { + egui::Color32::TRANSPARENT + }, + )) + .corner_radius(egui::CornerRadius::same(8)) + .inner_margin(egui::Margin::symmetric(7, 2)) + .show(ui, |ui| { + ui.label(egui::RichText::new(text).size(10.0).color(theme::INK_3)); + }); +} + +fn marketplace_detail( + ctx: &egui::Context, + pack: &super::view_model::MarketplacePack, + index: usize, + liked: bool, + body_rect: egui::Rect, + actions: &mut Vec, +) { + let modal_width = (body_rect.width() - 48.0).clamp(320.0, 480.0); + let viewport_center = ctx.content_rect().center(); + let body_center_offset = body_rect.center() - viewport_center; + + // Backdrop + let backdrop_layer = egui::LayerId::new( + egui::Order::Foreground, + egui::Id::new("marketplace-detail-backdrop"), + ); + ctx.layer_painter(backdrop_layer).rect_filled( + body_rect, + egui::CornerRadius { + nw: 0, + ne: 0, + sw: 14, + se: 14, + }, + egui::Color32::from_rgba_unmultiplied(0, 0, 0, 56), + ); + // Input capture + egui::Area::new(egui::Id::new("marketplace-detail-backdrop-input")) + .order(egui::Order::Foreground) + .fixed_pos(body_rect.min) + .default_size(body_rect.size()) + .constrain(false) + .interactable(true) + .show(ctx, |ui| { + ui.set_min_size(body_rect.size()); + ui.set_max_size(body_rect.size()); + let _ = ui.allocate_exact_size(body_rect.size(), egui::Sense::click()); + }); + + egui::Area::new(egui::Id::new("marketplace-detail-overlay")) + .order(egui::Order::Tooltip) + .anchor(egui::Align2::CENTER_CENTER, body_center_offset) + .constrain_to(body_rect) + .show(ctx, |ui| { + egui::Frame::new() + .fill(theme::SURFACE) + .stroke(egui::Stroke::new(1.0, theme::LINE)) + .corner_radius(egui::CornerRadius::same(14)) + .inner_margin(egui::Margin::same(20)) + .show(ui, |ui| { + ui.set_width(modal_width - 40.0); + ui.horizontal(|ui| { + ui.label(egui::RichText::new(&pack.name).size(18.0).strong()); + ui.label(egui::RichText::new(&pack.mode).size(11.0).color(theme::INK_3)); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.label( + egui::RichText::new(format!("v{}", pack.version)) + .size(10.0) + .color(theme::INK_4), + ); + }); + }); + ui.label( + egui::RichText::new(format!( + "@{} · ☆ {} · ↓ {}", + pack.author, pack.likes, pack.downloads + )) + .size(11.0) + .color(theme::INK_4), + ); + ui.add_space(10.0); + ui.label( + egui::RichText::new(&pack.description) + .size(13.0) + .color(theme::INK_2), + ); + ui.add_space(12.0); + egui::Frame::new() + .fill(theme::SURFACE_2) + .stroke(egui::Stroke::new(0.5, theme::LINE)) + .corner_radius(egui::CornerRadius::same(10)) + .inner_margin(egui::Margin::same(12)) + .show(ui, |ui| { + ui.label( + egui::RichText::new( + "本地占位预览\n将原始表达保留在上下文中,优化语气、结构和可读性。\n这段内容会由真实风格包提示词替换。", + ) + .size(12.0) + .color(theme::INK_2) + .family(egui::FontFamily::Monospace), + ); + }); + ui.add_space(14.0); + ui.horizontal(|ui| { + if ui + .add( + egui::Button::new( + egui::RichText::new(if liked { "★" } else { "☆" }), + ) + .fill(theme::SURFACE) + .stroke(egui::Stroke::new(1.0, theme::LINE)) + .corner_radius(egui::CornerRadius::same(8)), + ) + .clicked() + { + actions.push(FrontendAction::MarketplaceToggleLike(index)); + } + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui + .add( + egui::Button::new("安装到本地") + .fill(theme::BLUE) + .stroke(egui::Stroke::NONE) + .corner_radius(egui::CornerRadius::same(8)), + ) + .clicked() + { + actions.push(FrontendAction::MarketplaceInstall(index)); + actions.push(FrontendAction::MarketplaceCloseDetail); + } + if ui + .add( + egui::Button::new("取消") + .fill(theme::SURFACE) + .stroke(egui::Stroke::new(1.0, theme::LINE)) + .corner_radius(egui::CornerRadius::same(8)), + ) + .clicked() + { + actions.push(FrontendAction::MarketplaceCloseDetail); + } + }); + }); + }); + }); +} diff --git a/openless-all/app/linux-egui/src/ui/frontend/mod.rs b/openless-all/app/linux-egui/src/ui/frontend/mod.rs new file mode 100644 index 000000000..482bf8faf --- /dev/null +++ b/openless-all/app/linux-egui/src/ui/frontend/mod.rs @@ -0,0 +1,228 @@ +pub mod icons; +pub mod layout; +pub mod marketplace; +pub mod pages; +pub mod settings; +pub mod view_model; + +use eframe::egui; +use view_model::{FrontendAction, FrontendViewModel, Page}; + +/// Re-export the theme module from the parent ui module. +pub use super::theme; + +/// Render the complete egui frontend for one frame. This is the single entry +/// point called from `OpenLessEguiApp::update`. It replaces the old +/// `shell::titlebar` + `shell::sidebar` + `shell::content_panel` calls. +/// +/// The frontend is a pure function of `ctx` and `vm` — it reads display state +/// from the view model and pushes user actions into the `actions` vec. The host +/// drains actions after this call and dispatches them to existing Core/backend +/// methods. +pub fn render(ctx: &egui::Context, vm: &mut FrontendViewModel, actions: &mut Vec) { + // Paint the rounded window surface and body canvas. + layout::paint_window_background(ctx); + + // Titlebar with window controls. + layout::titlebar(ctx, actions); + + // Sidebar with navigation. + layout::sidebar(ctx, vm, actions); + + // Resize handles for borderless window. + layout::resize_handles(ctx); + + // Content area. + layout::content_panel(ctx, |ui| { + let body = layout::body_rect(ctx); + + // Style page owns its own scroll viewport. + if vm.active_page == Page::Style { + let width = (ui.available_width() - 24.0).max(1.0); + ui.set_min_width(width); + ui.set_max_width(width); + ui.add_space(28.0); + ui.label( + egui::RichText::new("润色模式") + .size(28.0) + .strong() + .color(theme::INK), + ); + ui.add_space(22.0); + pages::style_page(ui, vm, actions); + ui.add_space(32.0); + return; + } + + // History owns two independent scroll regions (list and detail). + // Do not wrap in another ScrollArea. + if vm.active_page == Page::History { + pages::history_page(ui, vm, actions); + ui.add_space(32.0); + return; + } + + egui::ScrollArea::vertical() + .id_salt("openless-main-scroll") + .auto_shrink([false, false]) + .show(ui, |ui| { + let width = (ui.available_width() - 24.0).max(1.0); + ui.set_min_width(width); + ui.set_max_width(width); + + match vm.active_page { + Page::Overview => { + pages::overview_page(ui, vm, actions); + } + Page::Vocab => { + pages::vocab_page(ui, vm, actions); + } + Page::Marketplace => { + ui.add_space(28.0); + ui.label( + egui::RichText::new("风格市场") + .size(28.0) + .strong() + .color(theme::INK), + ); + ui.add_space(22.0); + marketplace::marketplace_page(ui, vm, actions, body); + } + Page::SelectionAsk => { + ui.add_space(28.0); + ui.label( + egui::RichText::new("划词追问") + .size(28.0) + .strong() + .color(theme::INK), + ); + ui.add_space(22.0); + pages::selection_ask_page(ui, vm, actions); + } + Page::Translation => { + ui.add_space(28.0); + ui.label( + egui::RichText::new("翻译") + .size(28.0) + .strong() + .color(theme::INK), + ); + ui.add_space(22.0); + pages::translation_page(ui, vm, actions); + } + Page::History | Page::Style | Page::Settings => { + // Handled above or via overlay. + } + } + ui.add_space(32.0); + }); + + // Settings overlay (rendered on top of everything). + if vm.settings_open { + settings::settings_overlay(ctx, vm, actions, body); + } + }); +} + +#[cfg(test)] +mod tests { + use super::*; + + fn viewport() -> egui::Rect { + egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1240.0, 800.0)) + } + + fn frame(ctx: &egui::Context, events: Vec) -> Vec { + ctx.begin_pass(egui::RawInput { + screen_rect: Some(viewport()), + events, + ..Default::default() + }); + let mut vm = FrontendViewModel::default(); + let mut actions = Vec::new(); + render(ctx, &mut vm, &mut actions); + let _ = ctx.end_pass(); + actions + } + + #[test] + fn sidebar_navigation_receives_pointer_clicks_above_window_layers() { + let ctx = egui::Context::default(); + + // Areas use their first pass to establish their screen rectangles. + frame(&ctx, Vec::new()); + frame(&ctx, Vec::new()); + + let pointer = egui::pos2(50.0, 142.0); + assert_eq!( + ctx.layer_id_at(pointer), + Some(egui::LayerId::new( + egui::Order::Middle, + egui::Id::new("openless-sidebar"), + )), + "the sidebar must be the top input layer at a navigation button" + ); + frame( + &ctx, + vec![ + egui::Event::PointerMoved(pointer), + egui::Event::PointerButton { + pos: pointer, + button: egui::PointerButton::Primary, + pressed: true, + modifiers: egui::Modifiers::NONE, + }, + ], + ); + let actions = frame( + &ctx, + vec![egui::Event::PointerButton { + pos: pointer, + button: egui::PointerButton::Primary, + pressed: false, + modifiers: egui::Modifiers::NONE, + }], + ); + + assert!( + actions + .iter() + .any(|action| matches!(action, FrontendAction::Navigate(Page::History))), + "the foreground resize layer must not consume sidebar clicks" + ); + } + + #[test] + fn titlebar_close_control_receives_pointer_clicks() { + let ctx = egui::Context::default(); + frame(&ctx, Vec::new()); + frame(&ctx, Vec::new()); + + let pointer = egui::pos2(1214.0, 20.0); + frame( + &ctx, + vec![egui::Event::PointerButton { + pos: pointer, + button: egui::PointerButton::Primary, + pressed: true, + modifiers: egui::Modifiers::NONE, + }], + ); + let actions = frame( + &ctx, + vec![egui::Event::PointerButton { + pos: pointer, + button: egui::PointerButton::Primary, + pressed: false, + modifiers: egui::Modifiers::NONE, + }], + ); + + assert!( + actions + .iter() + .any(|action| matches!(action, FrontendAction::WindowClose)), + "the titlebar container must not consume the close button click" + ); + } +} diff --git a/openless-all/app/linux-egui/src/ui/frontend/pages.rs b/openless-all/app/linux-egui/src/ui/frontend/pages.rs new file mode 100644 index 000000000..10fb74117 --- /dev/null +++ b/openless-all/app/linux-egui/src/ui/frontend/pages.rs @@ -0,0 +1,2460 @@ +use eframe::egui; + +use super::icons::{self, IconName}; +use super::layout; +use super::theme; +use super::view_model::{FrontendAction, FrontendViewModel}; + +const SUPPORTED_LANGUAGES: [&str; 15] = [ + "简体中文", + "繁体中文", + "English", + "日本語", + "한국어", + "Français", + "Deutsch", + "Español", + "Italiano", + "Português", + "Русский", + "العربية", + "Tiếng Việt", + "ไทย", + "हिन्दी", +]; + +fn truncate_text(text: &str, max_chars: usize) -> String { + let mut value: String = text.chars().take(max_chars).collect(); + if text.chars().count() > max_chars { + value.push('…'); + } + value +} + +// ── Overview page ─────────────────────────────────────────────────────────── + +pub fn overview_page( + ui: &mut egui::Ui, + vm: &FrontendViewModel, + _actions: &mut Vec, +) { + let width = (ui.available_width() - 24.0).max(1.0); + + if vm.overview_loading { + ui.add_space(28.0); + ui.label( + egui::RichText::new("今日概览") + .size(28.0) + .strong() + .color(theme::INK), + ); + ui.add_space(22.0); + ui.horizontal(|ui| { + ui.spinner(); + ui.label(egui::RichText::new("正在加载概览数据…").color(theme::INK_3)); + }); + return; + } + + if let Some(error) = &vm.overview_error { + ui.add_space(28.0); + ui.label( + egui::RichText::new("今日概览") + .size(28.0) + .strong() + .color(theme::INK), + ); + ui.add_space(22.0); + ui.colored_label(egui::Color32::from_rgb(220, 80, 80), error); + return; + } + + let Some(summary) = &vm.overview else { + ui.add_space(28.0); + ui.label( + egui::RichText::new("今日概览") + .size(28.0) + .strong() + .color(theme::INK), + ); + ui.add_space(22.0); + ui.label(egui::RichText::new("暂无数据").color(theme::INK_3)); + return; + }; + + ui.set_min_width(width); + ui.set_max_width(width); + ui.add_space(28.0); + ui.label( + egui::RichText::new("今日概览") + .size(28.0) + .strong() + .color(theme::INK), + ); + ui.add_space(22.0); + + let gap = 12.0; + let provider_width = (width - gap) / 2.0; + let provider_height = 98.0; + let provider_row = ui + .allocate_exact_size(egui::vec2(width, provider_height), egui::Sense::hover()) + .0; + provider_card( + ui, + egui::Rect::from_min_size( + provider_row.min, + egui::vec2(provider_width, provider_height), + ), + "ASR 语音", + &summary.asr_provider, + summary.asr_configured, + IconName::Mic, + ); + provider_card( + ui, + egui::Rect::from_min_size( + egui::pos2( + provider_row.left() + provider_width + gap, + provider_row.top(), + ), + egui::vec2(provider_width, provider_height), + ), + "LLM 模型", + &summary.llm_provider, + summary.llm_configured, + IconName::Sparkle, + ); + ui.add_space(32.0); + + // Metric row + let metric_width = (width - gap * 3.0) / 4.0; + let metric_row = ui + .allocate_exact_size(egui::vec2(width, 108.0), egui::Sense::hover()) + .0; + for (index, (icon, label, value, detail, accent)) in [ + ( + IconName::Hash, + "今日字数", + summary.chars_today.to_string(), + format!("{} 段", summary.segments_today), + false, + ), + ( + IconName::Mic, + "今日总时长", + if summary.duration_ms_today > 0 { + format_duration(summary.duration_ms_today) + } else { + "—".into() + }, + String::new(), + false, + ), + ( + IconName::Clock, + "平均段落", + if summary.avg_latency_ms > 0 { + format_duration(summary.avg_latency_ms) + } else { + "—".into() + }, + "暂无数据".to_string(), + false, + ), + ( + IconName::Bolt, + "累计记录", + summary.history_total.to_string(), + "本机存档".to_string(), + true, + ), + ] + .into_iter() + .enumerate() + { + metric_card( + ui, + egui::Rect::from_min_size( + egui::pos2( + metric_row.left() + index as f32 * (metric_width + gap), + metric_row.top(), + ), + egui::vec2(metric_width, 108.0), + ), + icon, + label, + &value, + &detail, + accent, + ); + } + ui.add_space(18.0); + + // Activity heatmap + activity_heatmap(ui, width, summary); + ui.add_space(18.0); + + // Bottom row: period + recent + let row_width = width - gap; + let left_width = row_width / 2.4; + let right_width = row_width - left_width; + let bottom_row = ui + .allocate_exact_size(egui::vec2(width, 304.0), egui::Sense::hover()) + .0; + period_card( + ui, + egui::Rect::from_min_size(bottom_row.min, egui::vec2(left_width, 304.0)), + summary, + ); + recent_card( + ui, + egui::Rect::from_min_size( + egui::pos2(bottom_row.left() + left_width + gap, bottom_row.top()), + egui::vec2(right_width, 304.0), + ), + summary, + ); +} + +fn format_duration(ms: u64) -> String { + if ms < 1000 { + format!("{}ms", ms) + } else if ms < 60_000 { + format!("{:.1}s", ms as f64 / 1000.0) + } else { + let minutes = ms / 60_000; + let seconds = (ms % 60_000) / 1000; + format!("{}m{}s", minutes, seconds) + } +} + +fn provider_card( + ui: &mut egui::Ui, + rect: egui::Rect, + kind: &str, + name: &str, + configured: bool, + icon: IconName, +) { + layout::card_at(ui, rect, |ui| { + ui.horizontal(|ui| { + let (icon_rect, _) = + ui.allocate_exact_size(egui::vec2(38.0, 38.0), egui::Sense::hover()); + ui.painter() + .rect_filled(icon_rect, egui::CornerRadius::same(10), theme::BLUE_SOFT); + icons::draw_icon(ui, icon_rect.center(), icon, theme::BLUE); + ui.add_space(12.0); + ui.vertical(|ui| { + ui.label(egui::RichText::new(kind).size(10.5).color(theme::INK_4)); + ui.horizontal(|ui| { + ui.label(egui::RichText::new(name).size(14.0).strong()); + if configured { + ui.label(egui::RichText::new("● 已配置").size(10.5).color(theme::OK)); + } else { + ui.label(egui::RichText::new("未配置").size(10.5).color(theme::INK_4)); + } + }); + }); + }); + }); +} + +fn metric_card( + ui: &mut egui::Ui, + rect: egui::Rect, + icon: IconName, + label: &str, + value: &str, + detail: &str, + accent: bool, +) { + layout::card_at(ui, rect, |ui| { + ui.horizontal(|ui| { + icons::draw_icon( + ui, + ui.cursor().min + egui::vec2(7.0, 8.0), + icon, + theme::INK_3, + ); + ui.add_space(16.0); + ui.label(egui::RichText::new(label).size(11.5).color(theme::INK_3)); + }); + ui.add_space(8.0); + ui.label( + egui::RichText::new(value) + .size(26.0) + .strong() + .color(if accent { theme::BLUE } else { theme::INK }), + ); + if !detail.is_empty() { + ui.label(egui::RichText::new(detail).size(10.5).color(theme::INK_4)); + } + }); +} + +fn heat_color(count: u32) -> egui::Color32 { + match count { + 0 => theme::SURFACE_2, + 1..=2 => egui::Color32::from_rgb(80, 140, 220), + 3..=5 => egui::Color32::from_rgb(90, 120, 235), + 6..=10 => egui::Color32::from_rgb(110, 100, 235), + _ => egui::Color32::from_rgb(150, 90, 235), + } +} + +fn activity_heatmap(ui: &mut egui::Ui, width: f32, summary: &super::view_model::OverviewSummary) { + let rect = ui + .allocate_exact_size(egui::vec2(width, 184.0), egui::Sense::hover()) + .0; + layout::card_at(ui, rect, |ui| { + ui.label( + egui::RichText::new("年度活动") + .size(12.0) + .strong() + .color(theme::INK_2), + ); + ui.add_space(10.0); + let grid_rect = ui + .allocate_exact_size( + egui::vec2(ui.available_width(), 126.0), + egui::Sense::hover(), + ) + .0; + let painter = ui.painter().with_clip_rect(grid_rect); + let label_width = 30.0; + let columns = 53; + let cell_gap = 3.0; + let column_step = ((grid_rect.width() - label_width) / columns as f32).max(6.0); + let cell_width = (column_step - cell_gap).max(5.0); + let cell_height = ((grid_rect.height() - 22.0 - 6.0 * cell_gap) / 7.0).max(5.0); + let months = [ + "9月", "10月", "11月", "12月", "1月", "2月", "3月", "4月", "5月", "6月", "7月", "8月", + ]; + let month_columns = [0, 4, 9, 13, 18, 22, 27, 31, 36, 40, 45, 49]; + for (index, month) in months.iter().enumerate() { + let x = grid_rect.left() + label_width + month_columns[index] as f32 * column_step; + painter.text( + egui::pos2(x, grid_rect.top()), + egui::Align2::LEFT_TOP, + *month, + egui::FontId::proportional(9.0), + theme::INK_4, + ); + } + for row in 0..7 { + let y = grid_rect.top() + 22.0 + row as f32 * (cell_height + cell_gap); + painter.text( + egui::pos2(grid_rect.left(), y + cell_height / 2.0), + egui::Align2::LEFT_CENTER, + match row { + 0 => "周日", + 1 => "周一", + 2 => "周二", + 3 => "周三", + 4 => "周四", + 5 => "周五", + _ => "周六", + }, + egui::FontId::proportional(10.0), + theme::INK_4, + ); + for col in 0..columns { + let intensity = if col < summary.heatmap_weeks.len() as i32 { + summary.heatmap_weeks[col as usize][row] + } else { + 0 + }; + let color = heat_color(intensity); + let x = grid_rect.left() + label_width + col as f32 * column_step; + painter.rect_filled( + egui::Rect::from_min_size( + egui::pos2(x, y), + egui::vec2(cell_width, cell_height), + ), + egui::CornerRadius::same(2), + color, + ); + } + } + ui.horizontal(|ui| { + ui.label("少"); + for count in [0u32, 1, 4, 8, 15] { + let (swatch, _) = + ui.allocate_exact_size(egui::vec2(10.0, 10.0), egui::Sense::hover()); + ui.painter().rect_filled(swatch, 2.0, heat_color(count)); + } + ui.label("多"); + ui.label(format!( + "{} 天 · {} 天活跃", + summary.heatmap_days, summary.activity_days_total + )); + }); + }); +} + +fn period_card(ui: &mut egui::Ui, rect: egui::Rect, summary: &super::view_model::OverviewSummary) { + layout::card_at(ui, rect, |ui| { + let row_width = ui.available_width(); + ui.allocate_exact_size(egui::vec2(row_width, 28.0), egui::Sense::hover()); + ui.label( + egui::RichText::new("近期活动") + .size(13.0) + .strong() + .color(theme::INK), + ); + ui.add_space(12.0); + for (label, segments, _chars, _duration) in [ + ("近 7 天", summary.last_7_segments, "—", "—"), + ("近 30 天", summary.last_30_segments, "—", "—"), + ] { + ui.horizontal(|ui| { + ui.label(egui::RichText::new(label).size(12.0).color(theme::INK_2)); + ui.label( + egui::RichText::new(format!("{} 段", segments)) + .size(12.0) + .color(theme::INK_3), + ); + }); + ui.add_space(4.0); + } + }); +} + +fn recent_card(ui: &mut egui::Ui, rect: egui::Rect, summary: &super::view_model::OverviewSummary) { + layout::card_at(ui, rect, |ui| { + ui.label( + egui::RichText::new("最近记录") + .size(13.0) + .strong() + .color(theme::INK), + ); + ui.add_space(12.0); + if summary.recent.is_empty() { + ui.label( + egui::RichText::new("暂无记录") + .size(12.0) + .color(theme::INK_4), + ); + return; + } + for entry in &summary.recent { + ui.label( + egui::RichText::new(format!( + "{} · {}", + entry.created_at, + entry + .duration_ms + .map(|d| format_duration(d)) + .unwrap_or_else(|| "—".into()) + )) + .size(11.0) + .color(theme::INK_3), + ); + let text = if entry.final_text.trim().is_empty() { + "(无文字)" + } else { + &entry.final_text + }; + ui.label(egui::RichText::new(text).size(12.0).color(theme::INK_2)); + ui.add_space(8.0); + } + }); +} + +// ── History page ──────────────────────────────────────────────────────────── + +pub fn history_page( + ui: &mut egui::Ui, + vm: &mut FrontendViewModel, + actions: &mut Vec, +) { + let width = (ui.available_width() - 24.0).max(1.0); + ui.set_min_width(width); + ui.set_max_width(width); + ui.add_space(28.0); + + let header_rect = ui + .allocate_exact_size(egui::vec2(width, 84.0), egui::Sense::hover()) + .0; + ui.scope_builder( + egui::UiBuilder::new() + .max_rect(header_rect) + .layout(egui::Layout::top_down(egui::Align::Min)), + |ui| { + ui.horizontal(|ui| { + ui.vertical(|ui| { + ui.label( + egui::RichText::new("HISTORY") + .size(11.0) + .strong() + .color(theme::INK_4), + ); + ui.add_space(6.0); + ui.label( + egui::RichText::new("历史记录") + .size(28.0) + .strong() + .color(theme::INK), + ); + ui.add_space(5.0); + ui.label( + egui::RichText::new("本机保存的识别记录。") + .size(13.0) + .color(theme::INK_3), + ); + }); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Min), |ui| { + let clear = layout::icon_text_button(ui, "清空", IconName::Trash, 70.0); + if clear.clicked() { + actions.push(FrontendAction::HistoryClear); + } + ui.add_space(8.0); + let refresh = layout::icon_text_button(ui, "刷新", IconName::Refresh, 70.0); + if refresh.clicked() { + actions.push(FrontendAction::HistoryRefresh); + } + }); + }); + }, + ); + + if vm.history_entries.is_empty() { + egui::Frame::new() + .fill(theme::SURFACE) + .stroke(egui::Stroke::new(1.0, theme::LINE)) + .corner_radius(egui::CornerRadius::same(14)) + .inner_margin(egui::Margin::same(28)) + .show(ui, |ui| { + ui.vertical_centered(|ui| { + ui.label( + egui::RichText::new(if vm.history_cleared { + "暂无历史记录" + } else { + "历史记录暂未接线" + }) + .size(13.0) + .color(theme::INK_3), + ); + ui.add_space(4.0); + ui.label( + egui::RichText::new("数据桥接将在后续阶段完成") + .size(11.0) + .color(theme::INK_4), + ); + }); + }); + return; + } + + let gap = 14.0; + let list_width = 300.0; + let detail_width = (width - list_width - gap).max(300.0); + let body_height = ui.available_height().max(300.0); + let body = ui + .allocate_exact_size(egui::vec2(width, body_height), egui::Sense::hover()) + .0; + let list_rect = egui::Rect::from_min_size(body.min, egui::vec2(list_width, body.height())); + let detail_rect = egui::Rect::from_min_size( + egui::pos2(body.left() + list_width + gap, body.top()), + egui::vec2(detail_width, body.height()), + ); + + // List panel + layout::card_at(ui, list_rect, |ui| { + ui.add_space(1.0); + let search_width = ui.available_width(); + egui::Frame::new() + .fill(theme::SURFACE_2) + .stroke(egui::Stroke::new(0.8, theme::LINE)) + .corner_radius(egui::CornerRadius::same(8)) + .inner_margin(egui::Margin::symmetric(10, 5)) + .show(ui, |ui| { + ui.set_width((search_width - 20.0).max(1.0)); + ui.horizontal(|ui| { + let (icon_rect, _) = + ui.allocate_exact_size(egui::vec2(18.0, 24.0), egui::Sense::hover()); + icons::draw_icon(ui, icon_rect.center(), IconName::Search, theme::INK_3); + ui.add_space(6.0); + let mut query = vm.history_query.clone(); + let resp = ui.add_sized( + [ui.available_width(), 24.0], + egui::TextEdit::singleline(&mut query) + .hint_text("搜索转写内容…") + .font(egui::FontId::proportional(12.5)) + .vertical_align(egui::Align::Center) + .frame(false), + ); + if resp.changed() { + actions.push(FrontendAction::HistorySearch(query)); + } + }); + }); + ui.label( + egui::RichText::new(format!("共 {} 条记录", vm.history_entries.len())) + .size(10.5) + .color(theme::INK_4), + ); + ui.add_space(8.0); + ui.horizontal_wrapped(|ui| { + let filters = ["全部", "原文", "轻度润色", "清晰结构", "正式表达"]; + for (index, label) in filters.iter().enumerate() { + let selected = vm.history_filter == index; + let filter_width = (label.chars().count() as f32 * 9.0 + 18.0).max(42.0); + let response = ui.add( + egui::Button::new(egui::RichText::new(*label).size(11.5).color(if selected { + theme::SURFACE + } else { + theme::INK_3 + })) + .fill(if selected { theme::INK } else { theme::SURFACE }) + .stroke(egui::Stroke::new( + if selected { 0.0 } else { 0.8 }, + if selected { + egui::Color32::TRANSPARENT + } else { + theme::LINE + }, + )) + .corner_radius(egui::CornerRadius::same(10)) + .min_size(egui::vec2(filter_width, 24.0)), + ); + if response.clicked() { + actions.push(FrontendAction::HistoryFilter(index)); + } + } + }); + ui.separator(); + egui::ScrollArea::vertical() + .id_salt("openless-history-list") + .auto_shrink([false, false]) + .show(ui, |ui| { + for (index, entry) in vm.history_entries.iter().enumerate() { + if !vm.history_query.is_empty() + && !entry + .text + .to_lowercase() + .contains(&vm.history_query.to_lowercase()) + { + continue; + } + let selected = vm.history_selected == index; + let (rect, response) = ui.allocate_exact_size( + egui::vec2(ui.available_width(), 84.0), + egui::Sense::click(), + ); + if selected { + ui.painter().rect_filled( + rect, + egui::CornerRadius::same(8), + theme::BLUE_SOFT, + ); + let indicator_color = egui::Color32::from_rgb(29, 78, 216); + let left = rect.left() + 1.0; + let right = rect.left() + 4.0; + let top = rect.top() + 2.0; + let bottom = rect.bottom() - 2.0; + let radius = 3.0; + let mut indicator = Vec::with_capacity(18); + indicator.push(egui::pos2(right, top)); + indicator.push(egui::pos2(left + radius, top)); + for step in 0..=6 { + let angle = -std::f32::consts::FRAC_PI_2 + - std::f32::consts::FRAC_PI_2 * step as f32 / 6.0; + indicator.push(egui::pos2( + left + radius + angle.cos() * radius, + top + radius + angle.sin() * radius, + )); + } + indicator.push(egui::pos2(left, bottom - radius)); + for step in 0..=6 { + let angle = std::f32::consts::PI + - std::f32::consts::FRAC_PI_2 * step as f32 / 6.0; + indicator.push(egui::pos2( + left + radius + angle.cos() * radius, + bottom - radius + angle.sin() * radius, + )); + } + indicator.push(egui::pos2(right, bottom)); + ui.painter().add(egui::Shape::convex_polygon( + indicator, + indicator_color, + egui::Stroke::NONE, + )); + } + ui.painter().text( + rect.min + egui::vec2(12.0, 14.0), + egui::Align2::LEFT_CENTER, + &entry.time, + egui::FontId::monospace(10.5), + theme::INK_3, + ); + ui.painter().text( + egui::pos2(rect.right() - 12.0, rect.top() + 14.0), + egui::Align2::RIGHT_CENTER, + &entry.duration, + egui::FontId::monospace(10.0), + theme::INK_4, + ); + let chars_per_line = (((rect.width() - 24.0) / 11.5).floor() as usize).max(1); + let chars: Vec = entry.text.chars().collect(); + let first_line: String = chars.iter().take(chars_per_line).collect(); + let mut second_line: String = chars + .iter() + .skip(chars_per_line) + .take(chars_per_line) + .collect(); + if chars.len() > chars_per_line * 2 { + second_line.pop(); + second_line.push('…'); + } + ui.painter().text( + rect.min + egui::vec2(12.0, 29.0), + egui::Align2::LEFT_TOP, + first_line, + egui::FontId::proportional(11.5), + theme::INK_2, + ); + if !second_line.is_empty() { + ui.painter().text( + rect.min + egui::vec2(12.0, 44.0), + egui::Align2::LEFT_TOP, + second_line, + egui::FontId::proportional(11.5), + theme::INK_2, + ); + } + layout::tag( + ui, + egui::pos2(rect.min.x + 12.0, rect.bottom() - 23.0), + &entry.tag, + false, + ); + if response.clicked() { + actions.push(FrontendAction::HistorySelect(index)); + } + ui.add_space(1.0); + } + }); + }); + + // Detail panel + layout::card_at(ui, detail_rect, |ui| { + egui::ScrollArea::vertical() + .id_salt("openless-history-detail-scroll") + .auto_shrink([false, false]) + .show(ui, |ui| { + if let Some(entry) = vm.history_entries.get(vm.history_selected) { + ui.horizontal(|ui| { + ui.label( + egui::RichText::new(&entry.time) + .size(12.0) + .color(theme::INK_3), + ); + ui.add_space(8.0); + let _ = layout::small_pill( + ui, + &entry.tag, + theme::SURFACE_2, + theme::LINE, + theme::INK_3, + ); + ui.add_space(8.0); + ui.label( + egui::RichText::new(format!("录音 {}", entry.duration)) + .size(11.0) + .color(theme::INK_4), + ); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let del = layout::icon_text_button(ui, "删除", IconName::Trash, 70.0); + if del.clicked() { + actions.push(FrontendAction::HistoryDelete(vm.history_selected)); + } + ui.add_space(8.0); + let export = + layout::icon_text_button(ui, "导出录音", IconName::Download, 92.0); + if export.clicked() { + actions.push(FrontendAction::HistoryExport(vm.history_selected)); + } + }); + }); + ui.add_space(12.0); + layout::soft_separator(ui); + ui.add_space(10.0); + let play = layout::icon_text_button( + ui, + if vm.history_audio_playing { + "停止播放" + } else { + "播放录音" + }, + IconName::Play, + 92.0, + ); + if play.clicked() { + actions.push(FrontendAction::HistoryTogglePlay); + } + if vm.history_audio_playing { + ui.label( + egui::RichText::new("正在播放录音…") + .size(11.0) + .color(theme::BLUE), + ); + } + ui.add_space(10.0); + for (step, _provider, _status) in [ + ("识别", "ASR 服务", "—"), + ("润色", "LLM 服务", "—"), + ("插入", "—", "—"), + ] { + ui.horizontal(|ui| { + let _ = layout::small_pill( + ui, + step, + theme::SURFACE_2, + theme::LINE, + theme::INK_3, + ); + ui.label( + egui::RichText::new(_provider) + .size(10.5) + .color(theme::INK_2), + ); + ui.with_layout( + egui::Layout::right_to_left(egui::Align::Center), + |ui| { + ui.label( + egui::RichText::new(_status).size(10.5).color(theme::INK_4), + ); + }, + ); + }); + ui.add_space(4.0); + } + ui.add_space(8.0); + let inner_width = ui.available_width(); + let column_gap = 12.0; + let column_width = ((inner_width - column_gap) / 2.0).max(120.0); + let cards_row = ui + .allocate_exact_size(egui::vec2(inner_width, 165.0), egui::Sense::hover()) + .0; + let raw_rect = egui::Rect::from_min_size( + cards_row.min, + egui::vec2(column_width, cards_row.height()), + ); + let polished_rect = egui::Rect::from_min_size( + egui::pos2( + cards_row.left() + column_width + column_gap, + cards_row.top(), + ), + egui::vec2(column_width, cards_row.height()), + ); + detail_text_card(ui, raw_rect, "原文", &entry.text, false); + detail_text_card(ui, polished_rect, "润色结果", &entry.text, true); + } else { + ui.label("请选择一条记录"); + } + }); + }); +} + +fn detail_text_card(ui: &egui::Ui, rect: egui::Rect, title: &str, text: &str, blue: bool) { + ui.painter().rect_filled( + rect, + egui::CornerRadius::same(10), + if blue { + theme::BLUE_SOFT + } else { + theme::SURFACE_2 + }, + ); + ui.painter().rect_stroke( + rect, + egui::CornerRadius::same(10), + egui::Stroke::new(0.5, if blue { theme::BLUE } else { theme::LINE }), + egui::StrokeKind::Inside, + ); + ui.painter().text( + rect.min + egui::vec2(14.0, 18.0), + egui::Align2::LEFT_CENTER, + title, + egui::FontId::proportional(10.5), + if blue { theme::BLUE } else { theme::INK_3 }, + ); + let text_rect = egui::Rect::from_min_max( + rect.min + egui::vec2(14.0, 42.0), + rect.max - egui::vec2(14.0, 40.0), + ); + let text_painter = ui.painter().with_clip_rect(text_rect); + let galley = text_painter.layout( + text.to_owned(), + egui::FontId::proportional(12.5), + theme::INK_2, + text_rect.width(), + ); + text_painter.galley(text_rect.left_top(), galley, theme::INK_2); + let copy = egui::Rect::from_min_size( + egui::pos2(rect.right() - 58.0, rect.top() + 8.0), + egui::vec2(48.0, 22.0), + ); + ui.painter().rect_stroke( + copy, + egui::CornerRadius::same(6), + egui::Stroke::new(0.5, theme::LINE), + egui::StrokeKind::Inside, + ); + ui.painter().text( + copy.center(), + egui::Align2::CENTER_CENTER, + "复制", + egui::FontId::proportional(10.5), + theme::INK_2, + ); +} + +// ── Vocab page ────────────────────────────────────────────────────────────── + +pub fn vocab_page( + ui: &mut egui::Ui, + vm: &mut FrontendViewModel, + actions: &mut Vec, +) { + let width = (ui.available_width() - 24.0).max(1.0); + ui.set_min_width(width); + ui.set_max_width(width); + ui.label(egui::RichText::new("词汇表").size(11.0).color(theme::INK_4)); + ui.add_space(6.0); + ui.horizontal(|ui| { + ui.label( + egui::RichText::new("词汇表") + .size(28.0) + .strong() + .color(theme::INK), + ); + ui.add_space(8.0); + ui.label( + egui::RichText::new("自定义热词,提升专有名词识别率") + .size(13.0) + .color(theme::INK_3), + ); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui + .add( + egui::Button::new( + egui::RichText::new("↻ 刷新") + .size(11.5) + .color(theme::INK_2), + ) + .fill(theme::SURFACE) + .stroke(egui::Stroke::new(0.8, theme::LINE)) + .corner_radius(egui::CornerRadius::same(8)) + .min_size(egui::vec2(70.0, 30.0)), + ) + .clicked() + { + vm.vocab_error = None; + } + }); + }); + ui.add_space(24.0); + + if vm.vocab_unsupported { + layout::unsupported_page(ui, ""); + return; + } + + // Presets card + vocab_card( + ui, + width, + "预设", + "选择一组常用词汇快速添加。", + &mut vm.vocab_presets_open, + |ui| { + ui.horizontal_wrapped(|ui| { + let preset_names = ["开发工具", "产品与平台", "技术术语", "英文写作"]; + for (index, name) in preset_names.iter().enumerate() { + let selected = vm.vocab_selected_presets.contains(&index); + let response = ui.add( + egui::Button::new(egui::RichText::new(*name).size(12.5)) + .fill(if selected { + theme::BLUE_SOFT + } else { + theme::SURFACE_2 + }) + .stroke(egui::Stroke::new(0.5, theme::LINE)) + .corner_radius(egui::CornerRadius::same(12)) + .min_size(egui::vec2(88.0, 32.0)), + ); + if response.clicked() { + actions.push(FrontendAction::VocabApplyPreset(index)); + } + } + if ui + .add( + egui::Button::new(egui::RichText::new("创建预设").size(12.5)) + .fill(theme::SURFACE) + .stroke(egui::Stroke::new(0.5, theme::LINE)) + .corner_radius(egui::CornerRadius::same(8)) + .min_size(egui::vec2(96.0, 34.0)), + ) + .clicked() + { + vm.vocab_editing_preset = Some(usize::MAX); + vm.vocab_preset_name = "新预设".into(); + vm.vocab_preset_phrases.clear(); + } + if ui + .add( + egui::Button::new( + egui::RichText::new("应用").color(theme::SURFACE).size(12.0), + ) + .fill(theme::INK) + .stroke(egui::Stroke::NONE) + .corner_radius(egui::CornerRadius::same(8)) + .min_size(egui::vec2(64.0, 32.0)), + ) + .clicked() + { + actions.push(FrontendAction::VocabApplyPreset(usize::MAX)); + } + }); + if vm.vocab_editing_preset.is_some() { + ui.add_space(10.0); + let input_width = ui.available_width(); + let input_content_width = (input_width - 20.0).max(1.0); + egui::Frame::new() + .fill(theme::SURFACE) + .stroke(egui::Stroke::new(0.8, theme::LINE)) + .corner_radius(egui::CornerRadius::same(8)) + .inner_margin(egui::Margin::symmetric(10, 6)) + .show(ui, |ui| { + ui.set_width(input_content_width); + ui.add_sized( + [input_content_width, 20.0], + egui::TextEdit::singleline(&mut vm.vocab_preset_name) + .hint_text("预设名称") + .desired_width(input_content_width) + .frame(false), + ); + }); + egui::Frame::new() + .fill(theme::SURFACE) + .stroke(egui::Stroke::new(0.8, theme::LINE)) + .corner_radius(egui::CornerRadius::same(8)) + .inner_margin(egui::Margin::symmetric(10, 6)) + .show(ui, |ui| { + ui.set_width(input_content_width); + ui.add_sized( + [input_content_width, 64.0], + egui::TextEdit::multiline(&mut vm.vocab_preset_phrases) + .desired_rows(3) + .desired_width(input_content_width) + .hint_text("词汇,用逗号或换行分隔") + .frame(false), + ); + }); + ui.horizontal(|ui| { + if ui + .add( + egui::Button::new( + egui::RichText::new("保存").color(theme::SURFACE).size(12.0), + ) + .fill(theme::INK) + .stroke(egui::Stroke::NONE) + .corner_radius(egui::CornerRadius::same(8)) + .min_size(egui::vec2(72.0, 30.0)), + ) + .clicked() + { + let name = vm.vocab_preset_name.trim().to_owned(); + let phrases = vm.vocab_preset_phrases.clone(); + if !name.is_empty() { + actions.push(FrontendAction::VocabCreatePreset { name, phrases }); + } + vm.vocab_editing_preset = None; + } + if ui + .add( + egui::Button::new(egui::RichText::new("取消").size(12.0)) + .fill(theme::SURFACE) + .stroke(egui::Stroke::new(0.8, theme::LINE)) + .corner_radius(egui::CornerRadius::same(8)) + .min_size(egui::vec2(72.0, 30.0)), + ) + .clicked() + { + vm.vocab_editing_preset = None; + } + }); + } + if vm.vocab_editing_preset.is_none() && !vm.vocab_saved_presets.is_empty() { + ui.add_space(10.0); + ui.horizontal_wrapped(|ui| { + let saved_presets = vm.vocab_saved_presets.clone(); + for (index, preset) in saved_presets.iter().enumerate() { + if ui + .add( + egui::Button::new( + egui::RichText::new(format!("编辑 {}", preset.name)) + .size(12.5), + ) + .fill(theme::SURFACE_2) + .stroke(egui::Stroke::new(0.6, theme::LINE)) + .corner_radius(egui::CornerRadius::same(14)) + .min_size(egui::vec2(92.0, 30.0)), + ) + .clicked() + { + vm.vocab_preset_name = preset.name.clone(); + vm.vocab_preset_phrases = preset.phrases.clone(); + vm.vocab_editing_preset = Some(index); + } + } + }); + } + }, + ); + + // Correction rules card + vocab_card( + ui, + width, + "纠错规则", + "将识别结果中的常见错误自动替换为正确写法。", + &mut vm.vocab_corrections_open, + |ui| { + ui.horizontal(|ui| { + let spacing = ui.spacing().item_spacing.x; + let add_width = 72.0; + let arrow_width = 24.0; + let input_width = + ((ui.available_width() - add_width - arrow_width - spacing * 3.0) / 2.0) + .max(60.0); + let input_content_width = (input_width - 20.0).max(1.0); + egui::Frame::new() + .fill(theme::SURFACE_2) + .stroke(egui::Stroke::new(0.8, theme::LINE)) + .corner_radius(egui::CornerRadius::same(8)) + .inner_margin(egui::Margin::symmetric(10, 6)) + .show(ui, |ui| { + ui.set_width(input_content_width); + ui.add_sized( + [input_content_width, 20.0], + egui::TextEdit::singleline(&mut vm.vocab_pattern) + .desired_width(input_content_width) + .hint_text("原文,例如:{num}粒") + .frame(false), + ); + }); + ui.add_sized( + [arrow_width, 32.0], + egui::Label::new(egui::RichText::new("→").color(theme::INK_4)) + .wrap_mode(egui::TextWrapMode::Extend), + ); + egui::Frame::new() + .fill(theme::SURFACE_2) + .stroke(egui::Stroke::new(0.8, theme::LINE)) + .corner_radius(egui::CornerRadius::same(8)) + .inner_margin(egui::Margin::symmetric(10, 6)) + .show(ui, |ui| { + ui.set_width(input_content_width); + ui.add_sized( + [input_content_width, 20.0], + egui::TextEdit::singleline(&mut vm.vocab_replacement) + .desired_width(input_content_width) + .hint_text("替换为") + .frame(false), + ); + }); + if ui + .add( + egui::Button::new( + egui::RichText::new("添加").color(theme::SURFACE).size(12.0), + ) + .fill(theme::INK) + .stroke(egui::Stroke::NONE) + .corner_radius(egui::CornerRadius::same(8)) + .min_size(egui::vec2(add_width, 32.0)), + ) + .clicked() + && !vm.vocab_pattern.trim().is_empty() + { + actions.push(FrontendAction::VocabAddRule { + pattern: vm.vocab_pattern.trim().into(), + replacement: vm.vocab_replacement.trim().into(), + }); + vm.vocab_pattern.clear(); + vm.vocab_replacement.clear(); + } + }); + ui.add_space(10.0); + ui.horizontal_wrapped(|ui| { + let mut remove_index = None; + for index in 0..vm.vocab_rules.len() { + let rule = &vm.vocab_rules[index]; + let label = format!( + "{} → {}{}", + rule.pattern, + rule.replacement, + if rule.learned { " 自动" } else { "" } + ); + let (toggle, remove) = correction_chip(ui, &label, rule.enabled); + if remove { + remove_index = Some(index); + break; + } + if toggle { + actions.push(FrontendAction::VocabToggleRule(index)); + } + } + if let Some(index) = remove_index { + actions.push(FrontendAction::VocabRemoveRule(index)); + } + if vm.vocab_rules.is_empty() { + ui.label( + egui::RichText::new("暂无纠错规则") + .size(12.0) + .color(theme::INK_4), + ); + } + }); + }, + ); + + // Vocabulary entries card + vocab_card( + ui, + width, + "词汇", + "添加需要优先识别的自定义词汇。", + &mut vm.vocab_entries_open, + |ui| { + ui.horizontal(|ui| { + let input_width = (ui.available_width() - 90.0).max(80.0); + let input_content_width = (input_width - 20.0).max(1.0); + egui::Frame::new() + .fill(theme::SURFACE) + .stroke(egui::Stroke::new(0.8, theme::LINE)) + .corner_radius(egui::CornerRadius::same(8)) + .inner_margin(egui::Margin::symmetric(10, 6)) + .show(ui, |ui| { + ui.set_width(input_content_width); + let resp = ui.add_sized( + [input_content_width, 20.0], + egui::TextEdit::singleline(&mut vm.vocab_input) + .desired_width(input_content_width) + .hint_text("输入词汇,按回车添加") + .frame(false), + ); + if resp.lost_focus() + && ui.input(|input| input.key_pressed(egui::Key::Enter)) + && !vm.vocab_input.trim().is_empty() + { + let phrase = vm.vocab_input.trim().to_string(); + if !phrase.is_empty() { + actions.push(FrontendAction::VocabAddPhrase(phrase)); + } + vm.vocab_input.clear(); + } + }); + if ui + .add( + egui::Button::new( + egui::RichText::new("+ 添加") + .color(theme::SURFACE) + .size(12.0), + ) + .fill(theme::INK) + .stroke(egui::Stroke::NONE) + .corner_radius(egui::CornerRadius::same(8)) + .min_size(egui::vec2(78.0, 32.0)), + ) + .clicked() + || (ui.input(|input| input.key_pressed(egui::Key::Enter)) + && !vm.vocab_input.trim().is_empty()) + { + let phrase = vm.vocab_input.trim().to_string(); + if !phrase.is_empty() { + actions.push(FrontendAction::VocabAddPhrase(phrase)); + } + vm.vocab_input.clear(); + } + }); + ui.add_space(12.0); + ui.horizontal_wrapped(|ui| { + let mut remove_index = None; + for index in 0..vm.vocab_entries.len() { + let entry = &vm.vocab_entries[index]; + let (toggle, remove) = vocab_chip(ui, entry); + if remove { + remove_index = Some(index); + break; + } + if toggle { + actions.push(FrontendAction::VocabTogglePhrase(index)); + } + } + if let Some(index) = remove_index { + actions.push(FrontendAction::VocabRemovePhrase(index)); + } + }); + let learned = vm + .vocab_entries + .iter() + .filter(|entry| entry.learned) + .count(); + if learned > 0 { + ui.separator(); + ui.horizontal(|ui| { + ui.label(format!("自动收集 ({learned})")); + if ui.button("全部删除").clicked() { + // Remove all learned entries + let indices: Vec = vm + .vocab_entries + .iter() + .enumerate() + .filter(|(_, e)| e.learned) + .map(|(i, _)| i) + .rev() + .collect(); + for i in indices { + actions.push(FrontendAction::VocabRemovePhrase(i)); + } + } + }); + } + if let Some(error) = &vm.vocab_error { + ui.label( + egui::RichText::new(error) + .size(12.0) + .color(egui::Color32::from_rgb(185, 28, 28)), + ); + } + }, + ); +} + +// ── Style page ────────────────────────────────────────────────────────────── + +pub fn style_page( + ui: &mut egui::Ui, + vm: &mut FrontendViewModel, + actions: &mut Vec, +) { + let width = (ui.available_width() - 24.0).max(1.0); + ui.set_min_width(width); + ui.set_max_width(width); + + if vm.style_unsupported { + layout::unsupported_page(ui, ""); + return; + } + + ui.horizontal(|ui| { + ui.label( + egui::RichText::new("选择润色风格,让每次输出都保持一致") + .size(12.0) + .color(theme::INK_3), + ); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let import = ui.add( + egui::Button::new(egui::RichText::new("▣ 导入 ZIP").size(11.5)) + .fill(theme::BLUE) + .stroke(egui::Stroke::NONE) + .corner_radius(egui::CornerRadius::same(8)) + .min_size(egui::vec2(92.0, 29.0)), + ); + if import.clicked() { + actions.push(FrontendAction::StyleImport); + } + ui.add_space(8.0); + let refresh = ui.add( + egui::Button::new( + egui::RichText::new("↻ 刷新") + .size(11.5) + .color(theme::INK_2), + ) + .fill(theme::SURFACE) + .stroke(egui::Stroke::new(0.7, theme::LINE)) + .corner_radius(egui::CornerRadius::same(8)) + .min_size(egui::vec2(70.0, 29.0)), + ); + if refresh.clicked() { + vm.style_notice = Some("风格包列表已刷新".into()); + } + }); + }); + ui.add_space(14.0); + + let mut selected_action: Option = None; + let mut editor_prompt: Option = None; + let style_card_height = ui.available_height().max(320.0); + layout::card_at( + ui, + egui::Rect::from_min_size(ui.cursor().min, egui::vec2(width, style_card_height)), + |ui| { + let raw_active = !vm.style_selection_workflow && vm.style_selected == usize::MAX; + ui.horizontal(|ui| { + ui.vertical(|ui| { + ui.horizontal(|ui| { + ui.label(egui::RichText::new("本地风格包").size(15.0).strong()); + let raw = ui.add( + egui::Button::new(egui::RichText::new("原文").size(11.5).color( + if raw_active { + theme::SURFACE + } else { + theme::INK_3 + }, + )) + .fill(if raw_active { + theme::BLUE + } else { + egui::Color32::TRANSPARENT + }) + .stroke(egui::Stroke::NONE) + .corner_radius(egui::CornerRadius::same(6)) + .min_size(egui::vec2(52.0, 24.0)), + ); + if raw.clicked() { + selected_action = Some(usize::MAX); + vm.style_selection_workflow = false; + } + }); + ui.add_space(3.0); + ui.label( + egui::RichText::new("浏览和切换风格包。") + .size(11.5) + .color(theme::INK_3), + ); + }); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + egui::Frame::new() + .fill(theme::SURFACE) + .stroke(egui::Stroke::new(0.8, theme::LINE)) + .corner_radius(egui::CornerRadius::same(8)) + .inner_margin(egui::Margin::symmetric(7, 3)) + .show(ui, |ui| { + ui.label( + egui::RichText::new(format!("{} 个风格包", vm.style_packs.len())) + .size(10.5) + .color(theme::INK_3), + ); + }); + let selection = ui.add( + egui::Button::new(egui::RichText::new("选区润色").size(11.5).color( + if vm.style_selection_workflow { + theme::SURFACE + } else { + theme::INK_3 + }, + )) + .fill(if vm.style_selection_workflow { + theme::BLUE + } else { + egui::Color32::TRANSPARENT + }) + .stroke(egui::Stroke::NONE) + .corner_radius(egui::CornerRadius::same(6)) + .min_size(egui::vec2(70.0, 24.0)), + ); + if selection.clicked() { + vm.style_selection_workflow = true; + } + let dictation = ui.add( + egui::Button::new(egui::RichText::new("语音润色").size(11.5).color( + if !vm.style_selection_workflow && !raw_active { + theme::SURFACE + } else { + theme::INK_3 + }, + )) + .fill(if !vm.style_selection_workflow && !raw_active { + theme::BLUE + } else { + egui::Color32::TRANSPARENT + }) + .stroke(egui::Stroke::NONE) + .corner_radius(egui::CornerRadius::same(6)) + .min_size(egui::vec2(70.0, 24.0)), + ); + if dictation.clicked() { + vm.style_selection_workflow = false; + } + }); + }); + ui.add_space(16.0); + ui.separator(); + ui.add_space(16.0); + + egui::ScrollArea::vertical() + .id_salt("style-packs-scroll") + .auto_shrink([false, false]) + .show(ui, |ui| { + let grid_width = ui.available_width(); + let gap = 12.0; + let columns = if grid_width >= 820.0 { + 3 + } else if grid_width >= 560.0 { + 2 + } else { + 1 + }; + let card_width = + ((grid_width - gap * (columns - 1) as f32) / columns as f32).max(1.0); + let total_tiles = vm.style_packs.len() + 1; + for (row_index, start) in (0..total_tiles).step_by(columns).enumerate() { + if row_index > 0 { + ui.add_space(12.0); + } + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 0.0; + for slot in start..(start + columns).min(total_tiles) { + if slot == vm.style_packs.len() { + if new_style_pack_card(ui, egui::vec2(card_width, 232.0)) { + editor_prompt = Some( + "# 角色\n你是 OpenLess 的润色助手。\n\n# 任务\n把输入整理成自然、清晰、可直接使用的文字。\n\n# 输出\n只输出最终文本,不添加解释。\n".into(), + ); + } + continue; + } + let pack = &vm.style_packs[slot]; + match style_pack_card( + ui, + egui::vec2(card_width, 232.0), + slot, + vm.style_selected, + &pack.name, + &pack.description, + &pack.tags, + pack.accent, + pack.is_active, + pack.is_builtin, + ) { + StyleCardAction::Activate => { + selected_action = Some(slot) + } + StyleCardAction::Export => { + actions.push(FrontendAction::StyleExport(slot)); + } + StyleCardAction::Edit => { + editor_prompt = Some(format!( + "# 角色\n你是 OpenLess 的{}助手。\n\n# 任务\n把输入整理成自然、清晰、可直接使用的文字。\n\n# 输出\n只输出最终文本,不添加解释。\n", + pack.name + )); + } + StyleCardAction::None => {} + } + if slot + 1 < (start + columns).min(total_tiles) { + ui.add_space(gap); + } + } + }); + } + }); + }, + ); + + if let Some(index) = selected_action { + if index == usize::MAX { + vm.style_selected = usize::MAX; + vm.style_notice = Some("已切换到「原文」".into()); + } else if let Some(pack) = vm.style_packs.get(index) { + actions.push(FrontendAction::StyleActivate(index)); + vm.style_notice = Some(format!("已切换到「{}」", pack.name)); + } + } + if let Some(prompt) = editor_prompt { + vm.style_prompt = prompt; + vm.style_editor_open = true; + } + + if let Some(notice) = vm.style_notice.clone() { + ui.add_space(10.0); + ui.horizontal(|ui| { + ui.label(egui::RichText::new("✓").color(theme::OK).strong()); + ui.label(egui::RichText::new(notice).size(11.5).color(theme::INK_2)); + if ui.small_button("×").clicked() { + vm.style_notice = None; + } + }); + } + style_editor_overlay(ui.ctx(), vm, actions); +} + +// ── Selection ask page ────────────────────────────────────────────────────── + +pub fn selection_ask_page( + ui: &mut egui::Ui, + vm: &mut FrontendViewModel, + actions: &mut Vec, +) { + let width = (ui.available_width() - 24.0).max(1.0); + ui.set_min_width(width); + ui.set_max_width(width); + + if vm.selection_unsupported { + layout::unsupported_page(ui, ""); + return; + } + + // History toggle + let history_width = 142.0; + let history_rect = ui + .allocate_exact_size(egui::vec2(history_width, 36.0), egui::Sense::hover()) + .0; + ui.painter().rect_filled( + history_rect, + egui::CornerRadius::same(10), + egui::Color32::from_rgb(241, 241, 242), + ); + ui.painter().rect_stroke( + history_rect, + egui::CornerRadius::same(10), + egui::Stroke::new(0.5, theme::LINE), + egui::StrokeKind::Inside, + ); + ui.painter().text( + history_rect.min + egui::vec2(14.0, 18.0), + egui::Align2::LEFT_CENTER, + "保存历史", + egui::FontId::proportional(12.5), + theme::INK_2, + ); + + let toggle_rect = egui::Rect::from_min_size( + egui::pos2(history_rect.right() - 50.0, history_rect.center().y - 10.0), + egui::vec2(36.0, 20.0), + ); + let toggle = ui.interact( + toggle_rect, + ui.id().with("selection-ask-history"), + egui::Sense::click(), + ); + let toggle_color = if vm.qa_save_history { + theme::BLUE + } else { + egui::Color32::from_rgb(184, 184, 187) + }; + ui.painter() + .rect_filled(toggle_rect, egui::CornerRadius::same(10), toggle_color); + let knob_x = if vm.qa_save_history { + toggle_rect.right() - 10.0 + } else { + toggle_rect.left() + 10.0 + }; + ui.painter().circle_filled( + egui::pos2(knob_x, toggle_rect.center().y), + 8.0, + egui::Color32::WHITE, + ); + if toggle.clicked() { + actions.push(FrontendAction::SelectionAskToggleHistory); + } + ui.add_space(12.0); + + // Usage card + layout::card_at( + ui, + egui::Rect::from_min_size(ui.cursor().min, egui::vec2(width, 196.0)), + |ui| { + ui.label( + egui::RichText::new("使用方法") + .size(13.0) + .strong() + .color(theme::INK), + ); + ui.add_space(10.0); + let steps = [ + "按 ⌘⇧; 打开浮窗。", + "在任意 app 选中文字。", + "按 右Ctrl 录音,再按一次提交。", + "可继续按 右Ctrl 多轮追问。", + "按 Esc 关闭浮窗并清空历史。", + ]; + for (index, step) in steps.into_iter().enumerate() { + ui.horizontal(|ui| { + ui.add_sized( + [18.0, 20.0], + egui::Label::new( + egui::RichText::new(format!("{:}.", index + 1)) + .size(12.5) + .color(theme::INK_3), + ), + ); + ui.label(egui::RichText::new(step).size(12.5).color(theme::INK_2)); + }); + if index < 4 { + ui.add_space(5.0); + } + } + }, + ); +} + +// ── Translation page ───────────────────────────────────────────────────────── + +pub fn translation_page( + ui: &mut egui::Ui, + vm: &mut FrontendViewModel, + actions: &mut Vec, +) { + let width = (ui.available_width() - 24.0).max(1.0); + ui.set_min_width(width); + ui.set_max_width(width); + + if vm.translation_unsupported { + layout::unsupported_page(ui, ""); + return; + } + + let gap = 12.0; + let card_width = width.min(760.0); + + // Working languages card + translation_card(ui, card_width, |ui| { + ui.label(egui::RichText::new("工作语言").size(13.0).strong()); + ui.add_space(12.0); + ui.horizontal_wrapped(|ui| { + ui.spacing_mut().item_spacing = egui::vec2(6.0, 6.0); + for language in SUPPORTED_LANGUAGES { + let selected = vm + .translation_working_languages + .iter() + .any(|value| value == language); + let response = ui.add( + egui::Button::new(egui::RichText::new(language).size(12.5).color( + if selected { + egui::Color32::WHITE + } else { + theme::INK_2 + }, + )) + .fill(if selected { + theme::BLUE + } else { + theme::SURFACE_2 + }) + .stroke(egui::Stroke::NONE) + .corner_radius(egui::CornerRadius::same(255)) + .min_size(egui::vec2(0.0, 28.0)), + ); + if response.clicked() { + actions.push(FrontendAction::TranslationToggleLanguage( + language.to_string(), + )); + } + } + }); + }); + ui.add_space(gap); + + // Target language card + let target = vm.translation_target_language.clone(); + let redundant = !target.is_empty() + && vm.translation_working_languages.len() == 1 + && vm.translation_working_languages[0] == target; + let enabled = !target.is_empty() && !redundant; + translation_card(ui, card_width, |ui| { + ui.horizontal(|ui| { + ui.label(egui::RichText::new("翻译目标语言").size(13.0).strong()); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.label( + egui::RichText::new(if enabled { "已启用" } else { "未启用" }) + .size(10.5) + .strong() + .color(if enabled { theme::BLUE } else { theme::INK_4 }), + ); + }); + }); + ui.add_space(12.0); + ui.scope(|ui| { + let style = ui.style_mut(); + style.visuals.menu_corner_radius = egui::CornerRadius::same(10); + style.spacing.button_padding = egui::vec2(10.0, 0.0); + style.spacing.icon_spacing = 8.0; + style.spacing.icon_width = 11.0; + for widget in [ + &mut style.visuals.widgets.inactive, + &mut style.visuals.widgets.hovered, + &mut style.visuals.widgets.active, + &mut style.visuals.widgets.open, + ] { + widget.corner_radius = egui::CornerRadius::same(8); + widget.weak_bg_fill = theme::SURFACE; + widget.bg_fill = theme::SURFACE; + widget.bg_stroke = egui::Stroke::new(0.8, theme::LINE); + widget.fg_stroke = egui::Stroke::new(1.0, theme::INK_2); + } + let mut selected_target = target.clone(); + egui::ComboBox::from_id_salt("translation-target-language") + .width(360.0) + .height(32.0) + .truncate() + .icon(|ui, rect, visuals, _| { + let center = rect.center(); + let stroke = egui::Stroke::new(1.1, visuals.fg_stroke.color); + ui.painter() + .line_segment([center + egui::vec2(-3.5, -1.5), center], stroke); + ui.painter() + .line_segment([center, center + egui::vec2(3.5, -1.5)], stroke); + }) + .selected_text(if target.is_empty() { + egui::RichText::new("不启用(Shift 按下不触发翻译)").color(theme::INK_4) + } else { + egui::RichText::new(target.as_str()).color(theme::INK) + }) + .show_ui(ui, |ui| { + if ui + .selectable_label( + selected_target.is_empty(), + "不启用(Shift 按下不触发翻译)", + ) + .clicked() + { + selected_target = String::new(); + ui.close(); + } + for language in SUPPORTED_LANGUAGES { + if ui + .selectable_label(selected_target == language, language) + .clicked() + { + selected_target = language.to_string(); + ui.close(); + } + } + }); + if selected_target != target { + actions.push(FrontendAction::TranslationSetTarget(selected_target)); + } + }); + ui.add_space(12.0); + ui.separator(); + ui.add_space(12.0); + ui.horizontal(|ui| { + ui.vertical(|ui| { + ui.label(egui::RichText::new("翻译风格").size(12.0).strong()); + ui.add_space(2.0); + ui.label( + egui::RichText::new("自动继承“风格”页当前激活的风格包。") + .size(11.5) + .color(theme::INK_4), + ); + }); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let style_name = if let Some(pack) = vm.style_packs.get(vm.style_selected) { + pack.name.as_str() + } else if vm.style_selected == usize::MAX { + "原样保留" + } else { + "轻度润色" + }; + egui::Frame::new() + .fill(theme::BLUE_SOFT) + .stroke(egui::Stroke::NONE) + .corner_radius(egui::CornerRadius::same(10)) + .inner_margin(egui::Margin::symmetric(9, 4)) + .show(ui, |ui| { + ui.label( + egui::RichText::new(style_name) + .size(11.0) + .strong() + .color(theme::BLUE), + ); + }); + }); + }); + if redundant { + ui.add_space(10.0); + egui::Frame::new() + .fill(egui::Color32::from_rgba_unmultiplied(217, 119, 6, 20)) + .stroke(egui::Stroke::new( + 0.5, + egui::Color32::from_rgba_unmultiplied(217, 119, 6, 62), + )) + .corner_radius(egui::CornerRadius::same(10)) + .inner_margin(egui::Margin::symmetric(12, 8)) + .show(ui, |ui| { + ui.label( + egui::RichText::new( + "目标语言与唯一工作语言相同,按翻译快捷键不会触发翻译。", + ) + .size(11.5) + .color(egui::Color32::from_rgb(180, 103, 10)), + ); + }); + } + }); + ui.add_space(gap); + + // Usage card + translation_card(ui, card_width, |ui| { + ui.label(egui::RichText::new("使用方法").size(13.0).strong()); + ui.add_space(10.0); + for (number, text) in [ + ("1", "按右 Option 开始录音。"), + ("2", "再次按右 Option 停止录音。"), + ("3", "录音过程中按翻译快捷键切换到翻译模式。"), + ("4", "松开按键后,译文会自动插入当前应用。"), + ("5", "翻译模式会在胶囊顶部显示状态。"), + ] { + ui.horizontal(|ui| { + ui.label( + egui::RichText::new(format!("{number}.")) + .size(12.5) + .color(theme::INK_3), + ); + ui.label(egui::RichText::new(text).size(12.5).color(theme::INK_2)); + }); + ui.add_space(4.0); + } + }); +} + +// ── Vocab helpers ─────────────────────────────────────────────────────────── + +fn vocab_card( + ui: &mut egui::Ui, + width: f32, + title: &str, + desc: &str, + open: &mut bool, + contents: impl FnOnce(&mut egui::Ui), +) { + let frame = egui::Frame::new() + .fill(theme::SURFACE) + .stroke(egui::Stroke::new(1.0, theme::LINE)) + .corner_radius(egui::CornerRadius::same(14)) + .inner_margin(egui::Margin::same(17)); + frame.show(ui, |ui| { + ui.set_width(width - 34.0); + let header = ui.horizontal(|ui| { + let (arrow_rect, response) = + ui.allocate_exact_size(egui::vec2(20.0, 24.0), egui::Sense::click()); + let arrow_stroke = egui::Stroke::new(1.4, theme::INK_4); + let center = arrow_rect.center(); + if *open { + ui.painter().line_segment( + [ + center + egui::vec2(-4.0, -2.0), + center + egui::vec2(0.0, 2.0), + ], + arrow_stroke, + ); + ui.painter().line_segment( + [ + center + egui::vec2(0.0, 2.0), + center + egui::vec2(4.0, -2.0), + ], + arrow_stroke, + ); + } else { + ui.painter().line_segment( + [ + center + egui::vec2(-2.0, -4.0), + center + egui::vec2(2.0, 0.0), + ], + arrow_stroke, + ); + ui.painter().line_segment( + [ + center + egui::vec2(2.0, 0.0), + center + egui::vec2(-2.0, 4.0), + ], + arrow_stroke, + ); + } + if response.clicked() { + *open = !*open; + } + ui.vertical(|ui| { + ui.label(egui::RichText::new(title).size(13.0).strong()); + ui.label(egui::RichText::new(desc).size(11.5).color(theme::INK_4)); + }); + }); + let _ = header; + if *open { + ui.add_space(12.0); + contents(ui); + } + }); + ui.add_space(12.0); +} + +fn correction_chip(ui: &mut egui::Ui, label: &str, enabled: bool) -> (bool, bool) { + let fill = if enabled { + theme::SURFACE + } else { + theme::SURFACE_2 + }; + let text_color = if enabled { theme::INK } else { theme::INK_4 }; + let text_galley = ui.painter().layout_no_wrap( + label.to_owned(), + egui::FontId::proportional(12.5), + text_color, + ); + let close_size = 22.0; + let width = 12.0 + text_galley.size().x + 8.0 + close_size + 10.0; + let (rect, response) = ui.allocate_exact_size(egui::vec2(width, 32.0), egui::Sense::click()); + let painter = ui.painter(); + painter.rect_filled(rect, egui::CornerRadius::same(16), fill); + painter.rect_stroke( + rect, + egui::CornerRadius::same(16), + egui::Stroke::new(0.6, theme::LINE), + egui::StrokeKind::Inside, + ); + painter.galley( + egui::pos2( + rect.left() + 12.0, + rect.center().y - text_galley.size().y / 2.0, + ), + text_galley, + text_color, + ); + + let close_rect = egui::Rect::from_center_size( + egui::pos2(rect.right() - 10.0 - close_size / 2.0, rect.center().y), + egui::vec2(close_size, close_size), + ); + painter.circle_filled(close_rect.center(), close_size / 2.0, theme::SURFACE_2); + painter.circle_stroke( + close_rect.center(), + close_size / 2.0, + egui::Stroke::new(0.5, theme::LINE), + ); + let center = close_rect.center(); + let x_stroke = egui::Stroke::new(1.1, theme::INK_4); + painter.line_segment( + [ + center + egui::vec2(-3.0, -3.0), + center + egui::vec2(3.0, 3.0), + ], + x_stroke, + ); + painter.line_segment( + [ + center + egui::vec2(3.0, -3.0), + center + egui::vec2(-3.0, 3.0), + ], + x_stroke, + ); + + if response.clicked() { + if response + .interact_pointer_pos() + .is_some_and(|pointer| close_rect.contains(pointer)) + { + return (false, true); + } + return (true, false); + } + (false, false) +} + +fn vocab_chip(ui: &mut egui::Ui, entry: &super::view_model::VocabEntry) -> (bool, bool) { + let fill = if entry.enabled && entry.hits > 0 { + theme::BLUE_SOFT + } else if entry.enabled { + theme::SURFACE + } else { + theme::SURFACE_2 + }; + let text_color = if entry.enabled { + theme::INK + } else { + theme::INK_4 + }; + let phrase_galley = ui.painter().layout_no_wrap( + entry.phrase.clone(), + egui::FontId::proportional(13.0), + text_color, + ); + let hits_text = entry.hits.to_string(); + let hits_color = if entry.enabled && entry.hits > 0 { + theme::SURFACE + } else { + theme::INK_4 + }; + let hits_galley = + ui.painter() + .layout_no_wrap(hits_text, egui::FontId::proportional(11.0), hits_color); + let hits_size = egui::vec2((hits_galley.size().x + 12.0).max(24.0), 22.0); + let close_size = 22.0; + let width = 12.0 + phrase_galley.size().x + 8.0 + hits_size.x + 6.0 + close_size + 10.0; + let (rect, response) = ui.allocate_exact_size(egui::vec2(width, 32.0), egui::Sense::click()); + let painter = ui.painter(); + painter.rect_filled(rect, egui::CornerRadius::same(16), fill); + painter.rect_stroke( + rect, + egui::CornerRadius::same(16), + egui::Stroke::new(0.6, theme::LINE), + egui::StrokeKind::Inside, + ); + painter.galley( + egui::pos2( + rect.left() + 12.0, + rect.center().y - phrase_galley.size().y / 2.0, + ), + phrase_galley, + text_color, + ); + + let close_rect = egui::Rect::from_center_size( + egui::pos2(rect.right() - 10.0 - close_size / 2.0, rect.center().y), + egui::vec2(close_size, close_size), + ); + let hits_rect = egui::Rect::from_min_size( + egui::pos2( + close_rect.left() - 6.0 - hits_size.x, + rect.center().y - hits_size.y / 2.0, + ), + hits_size, + ); + painter.rect_filled( + hits_rect, + egui::CornerRadius::same(5), + if entry.enabled && entry.hits > 0 { + theme::BLUE + } else { + egui::Color32::from_rgba_unmultiplied(0, 0, 0, 15) + }, + ); + painter.galley( + egui::pos2( + hits_rect.center().x - hits_galley.size().x / 2.0, + hits_rect.center().y - hits_galley.size().y / 2.0, + ), + hits_galley, + hits_color, + ); + painter.circle_filled(close_rect.center(), close_size / 2.0, theme::SURFACE_2); + painter.circle_stroke( + close_rect.center(), + close_size / 2.0, + egui::Stroke::new(0.5, theme::LINE), + ); + let center = close_rect.center(); + let x_stroke = egui::Stroke::new(1.1, theme::INK_4); + painter.line_segment( + [ + center + egui::vec2(-3.0, -3.0), + center + egui::vec2(3.0, 3.0), + ], + x_stroke, + ); + painter.line_segment( + [ + center + egui::vec2(3.0, -3.0), + center + egui::vec2(-3.0, 3.0), + ], + x_stroke, + ); + + if response.clicked() { + if response + .interact_pointer_pos() + .is_some_and(|pointer| close_rect.contains(pointer)) + { + return (false, true); + } + return (true, false); + } + (false, false) +} + +// ── Style helpers ─────────────────────────────────────────────────────────── + +enum StyleCardAction { + None, + Activate, + Export, + Edit, +} + +fn style_pack_card( + ui: &mut egui::Ui, + size: egui::Vec2, + index: usize, + selected: usize, + name: &str, + description: &str, + tags: &[String], + accent: egui::Color32, + is_active: bool, + is_builtin: bool, +) -> StyleCardAction { + let active = is_active || selected == index; + let (rect, response) = ui.allocate_exact_size(size, egui::Sense::hover()); + ui.painter().rect_filled( + rect, + egui::CornerRadius::same(14), + if active || response.hovered() { + theme::BLUE_SOFT + } else { + theme::SURFACE + }, + ); + ui.painter().rect_stroke( + rect, + egui::CornerRadius::same(14), + egui::Stroke::new( + if active { 1.5 } else { 1.0 }, + if active { theme::BLUE } else { theme::LINE }, + ), + egui::StrokeKind::Inside, + ); + let inner = rect.shrink(16.0); + let mut action = StyleCardAction::None; + let mut card_ui = ui.new_child( + egui::UiBuilder::new() + .max_rect(inner) + .layout(egui::Layout::top_down(egui::Align::Min)), + ); + let ui = &mut card_ui; + ui.style_mut().interaction.selectable_labels = false; + ui.horizontal(|ui| { + ui.label( + egui::RichText::new(name) + .size(14.0) + .strong() + .color(theme::INK), + ); + ui.add_space(8.0); + if is_builtin { + egui::Frame::new() + .fill(theme::SURFACE) + .stroke(egui::Stroke::new(0.7, accent)) + .corner_radius(egui::CornerRadius::same(8)) + .inner_margin(egui::Margin::symmetric(7, 3)) + .show(ui, |ui| { + ui.label(egui::RichText::new("内置").size(10.5).color(accent)); + }); + } + if active { + ui.add_space(4.0); + egui::Frame::new() + .fill(theme::INK) + .corner_radius(egui::CornerRadius::same(8)) + .inner_margin(egui::Margin::symmetric(7, 3)) + .show(ui, |ui| { + ui.label( + egui::RichText::new("当前") + .size(10.5) + .strong() + .color(theme::SURFACE), + ); + }); + } + }); + ui.add_space(9.0); + let description_width = ui.available_width(); + ui.allocate_ui_with_layout( + egui::vec2(description_width, 48.0), + egui::Layout::top_down(egui::Align::Min), + |ui| { + ui.add( + egui::Label::new( + egui::RichText::new(truncate_text(description, 96)) + .size(11.5) + .color(theme::INK_3), + ) + .wrap(), + ); + }, + ); + ui.add_space(9.0); + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 6.0; + for (tag_index, tag) in tags.iter().enumerate() { + egui::Frame::new() + .fill(if tag_index == 0 { + theme::BLUE_SOFT + } else { + theme::SURFACE_2 + }) + .stroke(egui::Stroke::new( + 0.5, + if tag_index == 0 { accent } else { theme::LINE }, + )) + .corner_radius(egui::CornerRadius::same(8)) + .inner_margin(egui::Margin::symmetric(7, 3)) + .show(ui, |ui| { + ui.label( + egui::RichText::new(tag.as_str()) + .size(10.5) + .color(if tag_index == 0 { accent } else { theme::INK_3 }), + ); + }); + } + }); + ui.with_layout(egui::Layout::bottom_up(egui::Align::Min), |ui| { + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 6.0; + let activate = ui.add_enabled( + !active, + egui::Button::new(egui::RichText::new("激活").size(10.5)) + .fill(if active { theme::INK } else { theme::BLUE }) + .stroke(egui::Stroke::NONE) + .corner_radius(egui::CornerRadius::same(7)) + .min_size(egui::vec2(64.0, 24.0)), + ); + if activate.clicked() { + action = StyleCardAction::Activate; + } + let export = ui.add( + egui::Button::new(egui::RichText::new("导出").size(10.5)) + .fill(theme::SURFACE_2) + .stroke(egui::Stroke::new(0.7, theme::LINE)) + .corner_radius(egui::CornerRadius::same(7)) + .min_size(egui::vec2(64.0, 24.0)), + ); + if export.clicked() { + action = StyleCardAction::Export; + } + let edit = ui.add_enabled( + false, + egui::Button::new(egui::RichText::new("编辑").size(10.5)) + .fill(theme::SURFACE_2) + .stroke(egui::Stroke::new(0.7, theme::LINE)) + .corner_radius(egui::CornerRadius::same(7)) + .min_size(egui::vec2(64.0, 24.0)), + ); + if edit.clicked() { + action = StyleCardAction::Edit; + } + }); + }); + action +} + +fn new_style_pack_card(ui: &mut egui::Ui, size: egui::Vec2) -> bool { + let (rect, response) = ui.allocate_exact_size(size, egui::Sense::click()); + ui.painter().rect_filled( + rect, + egui::CornerRadius::same(14), + if response.hovered() { + theme::SURFACE_2 + } else { + theme::SURFACE + }, + ); + ui.painter().rect_stroke( + rect, + egui::CornerRadius::same(14), + egui::Stroke::new(1.0, theme::LINE), + egui::StrokeKind::Inside, + ); + let center = rect.center() - egui::vec2(0.0, 20.0); + ui.painter().circle_filled(center, 22.0, theme::SURFACE_2); + let stroke = egui::Stroke::new(1.5, theme::BLUE); + ui.painter().line_segment( + [center - egui::vec2(8.0, 0.0), center + egui::vec2(8.0, 0.0)], + stroke, + ); + ui.painter().line_segment( + [center - egui::vec2(0.0, 8.0), center + egui::vec2(0.0, 8.0)], + stroke, + ); + ui.painter().text( + rect.center() + egui::vec2(0.0, 22.0), + egui::Align2::CENTER_CENTER, + "新建风格包", + egui::FontId::proportional(14.0), + theme::INK_2, + ); + ui.painter().text( + rect.center() + egui::vec2(0.0, 45.0), + egui::Align2::CENTER_CENTER, + "从模板开始创建自己的风格", + egui::FontId::proportional(11.0), + theme::INK_4, + ); + response.clicked() +} + +pub fn style_editor_overlay( + ctx: &egui::Context, + vm: &mut FrontendViewModel, + actions: &mut Vec, +) { + if !vm.style_editor_open { + return; + } + let mut open = true; + egui::Window::new("编辑风格包") + .open(&mut open) + .collapsible(false) + .resizable(true) + .default_width(620.0) + .default_height(520.0) + .show(ctx, |ui| { + let style_name = if let Some(pack) = vm.style_packs.get(vm.style_selected) { + pack.name.clone() + } else { + "新风格包".into() + }; + ui.label(egui::RichText::new(style_name).size(18.0).strong()); + ui.label( + egui::RichText::new( + "修改提示词后保存,下一次润色将使用新的规则。双击风格卡即可打开此编辑器。", + ) + .size(11.5) + .color(theme::INK_3), + ); + ui.add_space(14.0); + ui.horizontal(|ui| { + ui.label(egui::RichText::new("风格描述").size(12.0).strong()); + ui.add( + egui::TextEdit::singleline(&mut vm.style_prompt) + .hint_text("简短描述这个风格的使用场景") + .desired_width(390.0), + ); + }); + ui.add_space(10.0); + ui.label(egui::RichText::new("润色提示词").size(12.0).strong()); + ui.add( + egui::TextEdit::multiline(&mut vm.style_prompt) + .desired_rows(14) + .desired_width(f32::INFINITY), + ); + ui.add_space(10.0); + ui.horizontal(|ui| { + if ui + .add( + egui::Button::new("保存") + .fill(theme::BLUE) + .corner_radius(egui::CornerRadius::same(7)), + ) + .clicked() + { + let prompt = vm.style_prompt.clone(); + actions.push(FrontendAction::StyleSaveEditor(prompt)); + vm.style_editor_open = false; + } + if ui.button("恢复默认").clicked() { + vm.style_notice = Some("已恢复默认提示词(演示)".into()); + } + if ui.button("取消").clicked() { + vm.style_editor_open = false; + } + }); + }); + if !open { + vm.style_editor_open = false; + } +} + +// ── Translation helpers ───────────────────────────────────────────────────── + +fn translation_card(ui: &mut egui::Ui, width: f32, contents: impl FnOnce(&mut egui::Ui)) { + egui::Frame::new() + .fill(theme::SURFACE) + .stroke(egui::Stroke::new(1.0, theme::LINE)) + .corner_radius(egui::CornerRadius::same(14)) + .inner_margin(egui::Margin::same(17)) + .show(ui, |ui| { + ui.set_width((width - 34.0).max(1.0)); + contents(ui); + }); +} diff --git a/openless-all/app/linux-egui/src/ui/frontend/settings.rs b/openless-all/app/linux-egui/src/ui/frontend/settings.rs new file mode 100644 index 000000000..c72130187 --- /dev/null +++ b/openless-all/app/linux-egui/src/ui/frontend/settings.rs @@ -0,0 +1,1064 @@ +use eframe::egui; + +use super::theme; +use super::view_model::{ + FrontendAction, FrontendViewModel, SettingsActionField, SettingsComboField, SettingsField, + SettingsSection, SettingsTextField, +}; + +const RAIL_WIDTH: f32 = 198.0; + +#[derive(Clone, Copy)] +enum SettingsIcon { + Settings, + Cloud, + Shield, + Bolt, + Info, + Help, + Document, + External, +} + +impl SettingsSection { + fn label(self) -> &'static str { + match self { + Self::General => "通用", + Self::Services => "服务", + Self::Privacy => "隐私", + Self::Advanced => "高级", + Self::About => "关于", + } + } + + fn icon(self) -> SettingsIcon { + match self { + Self::General => SettingsIcon::Settings, + Self::Services => SettingsIcon::Cloud, + Self::Privacy => SettingsIcon::Shield, + Self::Advanced => SettingsIcon::Bolt, + Self::About => SettingsIcon::Info, + } + } +} + +/// Paint the in-window settings modal. Returns true when the caller should +/// close it. Actions are pushed into the provided vec. +pub fn settings_overlay( + ctx: &egui::Context, + vm: &mut FrontendViewModel, + actions: &mut Vec, + body: egui::Rect, +) { + let width = (body.width() - 56.0).min(900.0).max(680.0); + let height = (body.height() - 48.0).min(650.0).max(440.0); + let size = egui::vec2( + width.min(body.width() - 20.0), + height.min(body.height() - 20.0), + ); + let pos = body.center() - size / 2.0; + + // Backdrop + let backdrop_layer = egui::LayerId::new( + egui::Order::Foreground, + egui::Id::new("openless-settings-backdrop"), + ); + ctx.layer_painter(backdrop_layer).rect_filled( + body, + egui::CornerRadius { + nw: 0, + ne: 0, + sw: 14, + se: 14, + }, + egui::Color32::from_black_alpha(56), + ); + // Input capture + egui::Area::new(egui::Id::new("openless-settings-backdrop-input")) + .order(egui::Order::Foreground) + .fixed_pos(body.min) + .default_size(body.size()) + .constrain(false) + .interactable(true) + .show(ctx, |ui| { + ui.set_min_size(body.size()); + ui.set_max_size(body.size()); + let _ = ui.allocate_exact_size(body.size(), egui::Sense::click()); + }); + + egui::Area::new(egui::Id::new("openless-settings-modal")) + .order(egui::Order::Tooltip) + .fixed_pos(pos) + .show(ctx, |ui| { + egui::Frame::new() + .fill(theme::SURFACE) + .stroke(egui::Stroke::new(1.0, theme::LINE)) + .corner_radius(egui::CornerRadius::same(14)) + .shadow(egui::Shadow { + offset: [0, 12], + blur: 28, + spread: 0, + color: egui::Color32::from_black_alpha(42), + }) + .show(ui, |ui| { + ui.set_min_size(size); + ui.set_max_size(size); + ui.horizontal(|ui| { + ui.allocate_ui_with_layout( + egui::vec2(RAIL_WIDTH, size.y), + egui::Layout::top_down(egui::Align::Min), + |ui| rail(ui, vm, actions), + ); + ui.separator(); + ui.allocate_ui_with_layout( + egui::vec2((size.x - RAIL_WIDTH - 1.0).max(0.0), size.y), + egui::Layout::top_down(egui::Align::Min), + |ui| { + panel(ui, vm, actions); + }, + ); + }); + }); + }); +} + +fn rail(ui: &mut egui::Ui, vm: &mut FrontendViewModel, actions: &mut Vec) { + egui::Frame::NONE + .inner_margin(egui::Margin::symmetric(12, 18)) + .show(ui, |ui| { + for section in [ + SettingsSection::General, + SettingsSection::Services, + SettingsSection::Privacy, + SettingsSection::Advanced, + SettingsSection::About, + ] { + let active = vm.settings_section == section; + let response = rail_item(ui, section.label(), section.icon(), active); + if response.clicked() { + actions.push(FrontendAction::SettingsSection(section)); + } + } + ui.add_space(14.0); + ui.separator(); + ui.add_space(7.0); + for (label, icon, message) in [ + ( + "帮助中心", + SettingsIcon::Help, + "帮助中心将在 egui 外链桥接完成后打开", + ), + ( + "发布日志", + SettingsIcon::Document, + "发布日志将在 egui 外链桥接完成后打开", + ), + ] { + let response = rail_item(ui, label, icon, false); + let row = response.rect; + draw_rail_icon( + ui, + egui::pos2(row.right() - 14.0, row.center().y), + SettingsIcon::External, + theme::INK_4, + ); + if response.clicked() { + vm.settings_notice = Some(message.into()); + } + } + }); +} + +fn rail_item(ui: &mut egui::Ui, label: &str, icon: SettingsIcon, active: bool) -> egui::Response { + let (rect, response) = + ui.allocate_exact_size(egui::vec2(ui.available_width(), 34.0), egui::Sense::click()); + if active { + ui.painter() + .rect_filled(rect, egui::CornerRadius::same(8), theme::SURFACE_2); + } + let color = if active { theme::INK } else { theme::INK_3 }; + let icon_center = egui::pos2(rect.left() + 17.0, rect.center().y); + draw_rail_icon(ui, icon_center, icon, color); + ui.painter().text( + egui::pos2(rect.left() + 34.0, rect.center().y), + egui::Align2::LEFT_CENTER, + label, + egui::FontId::proportional(13.0), + color, + ); + response +} + +fn draw_rail_icon(ui: &egui::Ui, center: egui::Pos2, icon: SettingsIcon, color: egui::Color32) { + let painter = ui.painter(); + let stroke = egui::Stroke::new(1.35, color); + let point = |x: f32, y: f32| center + egui::vec2(x, y); + match icon { + SettingsIcon::Settings => { + painter.circle_stroke(center, 4.2, stroke); + for angle in [ + 0.0, + std::f32::consts::FRAC_PI_4, + std::f32::consts::FRAC_PI_2, + 3.0 * std::f32::consts::FRAC_PI_4, + std::f32::consts::PI, + 5.0 * std::f32::consts::FRAC_PI_4, + 3.0 * std::f32::consts::FRAC_PI_2, + 7.0 * std::f32::consts::FRAC_PI_4, + ] { + let direction = egui::vec2(angle.cos(), angle.sin()); + painter.line_segment([center + direction * 5.0, center + direction * 7.0], stroke); + } + } + SettingsIcon::Cloud => { + painter.circle_stroke(point(-2.6, 0.0), 4.2, stroke); + painter.circle_stroke(point(2.7, -2.1), 4.0, stroke); + painter.circle_stroke(point(5.5, 1.0), 3.4, stroke); + painter.line_segment([point(-6.0, 4.0), point(5.7, 4.0)], stroke); + painter.line_segment([point(-6.0, 4.0), point(-6.0, 2.0)], stroke); + painter.line_segment([point(5.7, 4.0), point(6.8, 2.0)], stroke); + } + SettingsIcon::Shield => { + painter.add(egui::Shape::line( + [ + point(0.0, 8.0), + point(6.0, 5.0), + point(6.0, -4.0), + point(0.0, -7.0), + point(-6.0, -4.0), + point(-6.0, 5.0), + point(0.0, 8.0), + ] + .to_vec(), + stroke, + )); + } + SettingsIcon::Bolt => { + painter.add(egui::Shape::line( + [ + point(1.0, -8.0), + point(-5.0, 1.0), + point(1.0, 1.0), + point(-1.0, 8.0), + point(6.0, -1.0), + point(1.0, -1.0), + point(1.0, -8.0), + ] + .to_vec(), + stroke, + )); + } + SettingsIcon::Info => { + painter.circle_stroke(center, 8.0, stroke); + painter.line_segment([point(0.0, -1.0), point(0.0, 5.0)], stroke); + painter.circle_filled(point(0.0, -4.0), 0.8, color); + } + SettingsIcon::Help => { + painter.circle_stroke(center, 8.0, stroke); + painter.add(egui::Shape::line( + [ + point(-2.2, -2.3), + point(-1.2, -4.0), + point(1.2, -4.0), + point(2.2, -2.2), + point(0.4, 0.0), + point(0.4, 2.0), + ] + .to_vec(), + stroke, + )); + painter.circle_filled(point(0.4, 5.0), 0.75, color); + } + SettingsIcon::Document => { + painter.rect_stroke( + egui::Rect::from_center_size( + center + egui::vec2(-1.0, 0.0), + egui::vec2(12.0, 16.0), + ), + egui::CornerRadius::same(1), + stroke, + egui::StrokeKind::Inside, + ); + painter.add(egui::Shape::line( + [point(1.0, -8.0), point(1.0, -3.0), point(6.0, -3.0)].to_vec(), + stroke, + )); + } + SettingsIcon::External => { + painter.add(egui::Shape::line( + vec![ + point(-5.0, 4.0), + point(-5.0, 7.0), + point(4.0, 7.0), + point(4.0, -2.0), + point(1.0, -2.0), + ], + stroke, + )); + painter.line_segment([point(-1.0, 3.0), point(7.0, -5.0)], stroke); + painter.add(egui::Shape::line( + [point(3.0, -5.0), point(7.0, -5.0), point(7.0, -1.0)].to_vec(), + stroke, + )); + } + } +} + +fn panel(ui: &mut egui::Ui, vm: &mut FrontendViewModel, actions: &mut Vec) { + egui::Frame::NONE + .inner_margin(egui::Margin::symmetric(24, 16)) + .show(ui, |ui| { + { + let style = ui.style_mut(); + style.visuals.menu_corner_radius = egui::CornerRadius::same(10); + for widget in [ + &mut style.visuals.widgets.inactive, + &mut style.visuals.widgets.hovered, + &mut style.visuals.widgets.active, + &mut style.visuals.widgets.open, + ] { + widget.corner_radius = egui::CornerRadius::same(8); + widget.bg_stroke = egui::Stroke::new(1.0, theme::LINE); + } + } + ui.horizontal(|ui| { + ui.label( + egui::RichText::new(vm.settings_section.label()) + .size(22.0) + .strong() + .color(theme::INK), + ); + ui.add_space(ui.available_width() - 38.0); + if ui + .add( + egui::Button::new(egui::RichText::new("×").size(22.0).color(theme::INK_3)) + .fill(theme::SURFACE_2) + .stroke(egui::Stroke::new(0.7, theme::LINE)) + .corner_radius(egui::CornerRadius::same(8)) + .min_size(egui::vec2(30.0, 30.0)), + ) + .clicked() + { + actions.push(FrontendAction::CloseSettings); + } + }); + if let Some(notice) = &vm.settings_notice { + ui.add_space(4.0); + ui.label(egui::RichText::new(notice).size(11.0).color(theme::BLUE)); + } + ui.add_space(8.0); + egui::ScrollArea::vertical() + .id_salt("openless-settings-content") + .auto_shrink([false, false]) + .show(ui, |ui| match vm.settings_section { + SettingsSection::General => general(ui, vm, actions), + SettingsSection::Services => services(ui, vm, actions), + SettingsSection::Privacy => privacy(ui, vm, actions), + SettingsSection::Advanced => advanced(ui, vm, actions), + SettingsSection::About => about(ui, vm, actions), + }); + }); +} + +fn general(ui: &mut egui::Ui, vm: &mut FrontendViewModel, actions: &mut Vec) { + card( + ui, + "录音与输入", + "全局录音的快捷键与触发方式。", + |ui| { + text_row(ui, "录音快捷键", "右 Option"); + combo_bool_row( + ui, + "录音方式", + vm.settings.realtime_mode, + &["切换式", "按住说话"], + || { + actions.push(FrontendAction::SettingsToggle(SettingsField::RealtimeMode)); + }, + ); + combo_index_row( + ui, + "麦克风", + vm.settings.provider, + &["系统默认", "内置麦克风", "外接麦克风"], + |val| { + actions.push(FrontendAction::SettingsCombo( + SettingsComboField::Provider, + val, + )); + }, + ); + toggle_row( + ui, + "自动插入光标位置", + vm.settings.recording_enabled, + || { + actions.push(FrontendAction::SettingsToggle( + SettingsField::RecordingEnabled, + )); + }, + ); + toggle_row(ui, "流式插入", vm.settings.streaming_insert, || { + actions.push(FrontendAction::SettingsToggle( + SettingsField::StreamingInsert, + )); + }); + toggle_row(ui, "录音时静音", vm.settings.selection_voice, || { + actions.push(FrontendAction::SettingsToggle( + SettingsField::SelectionVoice, + )); + }); + }, + ); + card( + ui, + "插入与剪贴板", + "识别结果如何回到当前光标位置。", + |ui| { + toggle_row(ui, "恢复剪贴板", vm.settings.restore_clipboard, || { + actions.push(FrontendAction::SettingsToggle( + SettingsField::RestoreClipboard, + )); + }); + text_row(ui, "模拟粘贴快捷键", "Ctrl V"); + toggle_row( + ui, + "流式结果保存剪贴板", + vm.settings.streaming_insert, + || { + actions.push(FrontendAction::SettingsToggle( + SettingsField::StreamingInsert, + )); + }, + ); + }, + ); + card( + ui, + "布局", + "让浮层和内容更适合你的工作方式。", + |ui| { + toggle_row(ui, "堆叠设置行", vm.settings.stacked_layout, || { + actions.push(FrontendAction::SettingsToggle(SettingsField::StackedLayout)); + }); + toggle_row(ui, "紧凑布局", vm.settings.conservative_layout, || { + actions.push(FrontendAction::SettingsToggle( + SettingsField::ConservativeLayout, + )); + }); + }, + ); + card( + ui, + "远程输入", + "通过局域网接收来自其他设备的输入。", + |ui| { + toggle_row(ui, "启用远程输入", vm.settings.remote_input, || { + actions.push(FrontendAction::SettingsToggle(SettingsField::RemoteInput)); + }); + text_row(ui, "监听端口", &vm.settings.remote_port); + combo_index_row( + ui, + "默认模式", + vm.settings.provider, + &["润色", "原文", "翻译"], + |val| { + actions.push(FrontendAction::SettingsCombo( + SettingsComboField::Provider, + val, + )); + }, + ); + }, + ); + card( + ui, + "快捷键", + "开始/停止、翻译、问答和风格切换。", + |ui| { + text_row(ui, "翻译", "⌥ T"); + text_row(ui, "划词追问", "⌥ Q"); + text_row(ui, "切换风格", "⌥ S"); + text_row(ui, "唤起 OpenLess", "⌥ Space"); + action_row( + ui, + "风格直达", + "添加快捷键", + vm.settings_notice.clone(), + actions, + ); + }, + ); + card(ui, "主题", "外观与概览页显示选项。", |ui| { + combo_index_row( + ui, + "主题", + vm.settings.theme, + &["跟随系统", "浅色", "深色"], + |val| { + actions.push(FrontendAction::SettingsCombo( + SettingsComboField::Theme, + val, + )); + }, + ); + toggle_row( + ui, + "显示活动热力图", + vm.settings.activity_heatmap, + || { + actions.push(FrontendAction::SettingsToggle( + SettingsField::ActivityHeatmap, + )); + }, + ); + }); + card( + ui, + "界面语言", + "选择 OpenLess 使用的界面语言。", + |ui| { + combo_index_row( + ui, + "语言", + vm.settings.language, + &[ + "跟随系统", + "简体中文", + "繁体中文", + "English", + "日本語", + "한국어", + ], + |val| { + actions.push(FrontendAction::SettingsCombo( + SettingsComboField::Language, + val, + )); + }, + ); + }, + ); + card(ui, "启动", "控制 OpenLess 启动时的行为。", |ui| { + toggle_row( + ui, + "启动时最小化", + vm.settings.start_minimized, + || { + actions.push(FrontendAction::SettingsToggle( + SettingsField::StartMinimized, + )); + }, + ); + }); +} + +fn services(ui: &mut egui::Ui, vm: &mut FrontendViewModel, actions: &mut Vec) { + card( + ui, + "AI 提供商", + "配置语音识别、润色与翻译所使用的服务。", + |ui| { + combo_index_row( + ui, + "当前提供商", + vm.settings.provider, + &["OpenAI 兼容", "百炼", "SiliconFlow", "本地服务"], + |val| { + actions.push(FrontendAction::SettingsCombo( + SettingsComboField::Provider, + val, + )); + }, + ); + let api_key = vm.settings.api_key.clone(); + text_edit_row(ui, "API Key", &mut vm.settings.api_key, "sk-…", || { + actions.push(FrontendAction::SettingsText( + SettingsTextField::ApiKey, + api_key, + )); + }); + let endpoint = vm.settings.endpoint.clone(); + text_edit_row( + ui, + "服务地址", + &mut vm.settings.endpoint, + "https://…", + || { + actions.push(FrontendAction::SettingsText( + SettingsTextField::Endpoint, + endpoint, + )); + }, + ); + let model = vm.settings.model.clone(); + text_edit_row(ui, "模型", &mut vm.settings.model, "模型名称", || { + actions.push(FrontendAction::SettingsText( + SettingsTextField::Model, + model, + )); + }); + action_row( + ui, + "连接测试", + "测试配置", + Some("配置仅在当前运行期间保存".into()), + actions, + ); + }, + ); + card( + ui, + "网络", + "网络请求的代理和连接选项。", + |ui| { + toggle_row(ui, "使用系统代理", vm.settings.system_proxy, || { + actions.push(FrontendAction::SettingsToggle(SettingsField::SystemProxy)); + }); + toggle_row(ui, "失败时自动重试", vm.settings.auto_update, || { + actions.push(FrontendAction::SettingsToggle(SettingsField::AutoUpdate)); + }); + }, + ); + card( + ui, + "扩展市场", + "浏览和安装风格包及输入扩展。", + |ui| { + toggle_row( + ui, + "启用扩展市场", + vm.settings.marketplace_enabled, + || { + actions.push(FrontendAction::SettingsToggle( + SettingsField::MarketplaceEnabled, + )); + }, + ); + action_row(ui, "已安装扩展", "管理扩展", None, actions); + }, + ); +} + +fn privacy(ui: &mut egui::Ui, vm: &mut FrontendViewModel, actions: &mut Vec) { + egui::Frame::new() + .fill(theme::BLUE_SOFT) + .corner_radius(egui::CornerRadius::same(10)) + .inner_margin(egui::Margin::symmetric(12, 10)) + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.label(egui::RichText::new("本地优先").strong().color(theme::BLUE)); + ui.label( + egui::RichText::new( + "设置和历史记录默认保存在本机,只有请求服务时才会发送内容。", + ) + .size(11.5) + .color(theme::INK_3), + ); + }); + }); + card( + ui, + "权限", + "查看 OpenLess 使用麦克风、辅助功能和网络的权限。", + |ui| { + status_row(ui, "麦克风", "已授权", theme::OK); + status_row(ui, "辅助功能", "待检查", theme::INK_4); + status_row(ui, "键盘输入", "待检查", theme::INK_4); + action_row(ui, "权限管理", "打开系统设置", None, actions); + }, + ); + card( + ui, + "数据存储", + "控制历史记录、上下文和调试音频的保留方式。", + |ui| { + toggle_row( + ui, + "保存历史记录", + vm.settings.remember_history, + || { + actions.push(FrontendAction::SettingsToggle( + SettingsField::RememberHistory, + )); + }, + ); + combo_index_row( + ui, + "历史记录保留", + vm.settings.retention, + &["不限制", "最近 30 天", "最近 7 天", "不保存"], + |val| { + actions.push(FrontendAction::SettingsCombo( + SettingsComboField::Retention, + val, + )); + }, + ); + text_row(ui, "历史记录上限", "500 条"); + action_row( + ui, + "数据管理", + "清空历史记录", + Some("清空操作将在数据桥接完成后执行".into()), + actions, + ); + }, + ); +} + +fn advanced(ui: &mut egui::Ui, vm: &mut FrontendViewModel, actions: &mut Vec) { + card( + ui, + "Less Computer", + "实验性的编码代理入口,仅 macOS 提供完整能力。", + |ui| { + toggle_row( + ui, + "启用 Less Computer", + vm.settings.less_computer, + || { + actions.push(FrontendAction::SettingsToggle(SettingsField::LessComputer)); + }, + ); + combo_index_row( + ui, + "权限模式", + vm.settings.retention, + &["接受编辑", "计划模式", "默认", "绕过权限"], + |val| { + actions.push(FrontendAction::SettingsCombo( + SettingsComboField::Retention, + val, + )); + }, + ); + text_row(ui, "工作目录", "当前项目"); + }, + ); + card( + ui, + "Claude 控制台", + "检测 Claude CLI、MCP 和 computer use 状态。", + |ui| { + action_row( + ui, + "检测状态", + "重新检测", + Some("未连接到 Claude CLI(占位)".into()), + actions, + ); + action_row( + ui, + "控制台", + if vm.settings.claude_expanded { + "收起控制台" + } else { + "展开控制台" + }, + None, + actions, + ); + }, + ); + card( + ui, + "多模态管线", + "实验性的图片和音频上下文处理。", + |ui| { + toggle_row(ui, "启用多模态处理", vm.settings.multimodal, || { + actions.push(FrontendAction::SettingsToggle(SettingsField::Multimodal)); + }); + text_row(ui, "处理方式", "跟随当前服务"); + }, + ); + card( + ui, + "调试工具", + "导出诊断信息,帮助定位输入和插入问题。", + |ui| { + toggle_row( + ui, + "为调试保存录音", + vm.settings.record_audio, + || { + actions.push(FrontendAction::SettingsToggle(SettingsField::RecordAudio)); + }, + ); + toggle_row( + ui, + "记录光标探针", + vm.settings.conservative_layout, + || { + actions.push(FrontendAction::SettingsToggle( + SettingsField::ConservativeLayout, + )); + }, + ); + action_row(ui, "诊断信息", "导出错误日志", None, actions); + }, + ); +} + +fn about(ui: &mut egui::Ui, vm: &mut FrontendViewModel, actions: &mut Vec) { + card( + ui, + "OpenLess", + "版本信息、更新渠道与项目链接。", + |ui| { + ui.horizontal(|ui| { + ui.label(egui::RichText::new("OpenLess").size(17.0).strong()); + ui.label( + egui::RichText::new(format!("{}-Beta", vm.version)) + .size(12.0) + .color(theme::INK_3), + ); + }); + action_row( + ui, + "正式版更新", + "检查更新", + Some("当前已是最新版本(占位)".into()), + actions, + ); + toggle_row(ui, "加入 Beta 渠道", vm.settings.beta_channel, || { + actions.push(FrontendAction::SettingsToggle(SettingsField::BetaChannel)); + }); + toggle_row(ui, "自动检查更新", vm.settings.auto_update, || { + actions.push(FrontendAction::SettingsToggle(SettingsField::AutoUpdate)); + }); + }, + ); + card( + ui, + "文档与反馈", + "获取帮助、查看源码或提交问题。", + |ui| { + for (label, action) in [ + ("GitHub", "打开 GitHub"), + ("文档", "打开帮助中心"), + ("发行说明", "打开发行说明"), + ("反馈", "打开问题反馈"), + ] { + action_row(ui, label, action, None, actions); + } + text_row(ui, "QQ群", "1078960553"); + action_row(ui, "QQ群", "复制群号", Some("复制操作占位".into()), actions); + }, + ); +} + +// ── Card & row helpers ────────────────────────────────────────────────────── + +fn card(ui: &mut egui::Ui, title: &str, description: &str, contents: impl FnOnce(&mut egui::Ui)) { + egui::Frame::new() + .fill(theme::SURFACE) + .stroke(egui::Stroke::new(1.0, theme::LINE)) + .corner_radius(egui::CornerRadius::same(10)) + .inner_margin(egui::Margin::symmetric(14, 12)) + .show(ui, |ui| { + ui.label( + egui::RichText::new(title) + .size(14.0) + .strong() + .color(theme::INK), + ) + .on_hover_text(description); + ui.add_space(4.0); + contents(ui); + }); + ui.add_space(10.0); +} + +fn toggle_row(ui: &mut egui::Ui, label: &str, value: bool, on_toggle: impl FnOnce()) { + row(ui, label, |ui| { + let text = if value { "开" } else { "关" }; + if ui + .add( + egui::Button::new(egui::RichText::new(text).size(11.5)) + .fill(if value { + theme::BLUE_SOFT + } else { + theme::SURFACE_2 + }) + .stroke(egui::Stroke::new(0.8, theme::LINE)) + .corner_radius(egui::CornerRadius::same(8)) + .min_size(egui::vec2(48.0, 26.0)), + ) + .clicked() + { + on_toggle(); + } + }); +} + +fn combo_index_row( + ui: &mut egui::Ui, + label: &str, + value: usize, + options: &[&str], + on_change: impl FnOnce(usize), +) { + row(ui, label, |ui| { + let mut selected = value; + egui::ComboBox::from_id_salt(("settings", label)) + .selected_text(options.get(value).copied().unwrap_or("选择")) + .show_ui(ui, |ui| { + for (index, option) in options.iter().enumerate() { + let response = ui.selectable_label(selected == index, *option); + if response.clicked() { + selected = index; + ui.close(); + } + } + }); + if selected != value { + on_change(selected); + } + }); +} + +fn combo_bool_row( + ui: &mut egui::Ui, + label: &str, + value: bool, + options: &[&str], + on_change: impl FnOnce(), +) { + row(ui, label, |ui| { + let selected = if value { + options.first() + } else { + options.get(1) + }; + let mut new_value = value; + egui::ComboBox::from_id_salt(("settings", label)) + .selected_text(selected.copied().unwrap_or("选择")) + .show_ui(ui, |ui| { + for (index, option) in options.iter().enumerate() { + let response = ui.selectable_label((index == 0) == value, *option); + if response.clicked() { + new_value = index == 0; + ui.close(); + } + } + }); + if new_value != value { + on_change(); + } + }); +} + +fn text_row(ui: &mut egui::Ui, label: &str, value: &str) { + row(ui, label, |ui| { + ui.label(egui::RichText::new(value).size(12.0).color(theme::INK_2)); + }); +} + +fn text_edit_row( + ui: &mut egui::Ui, + label: &str, + value: &mut String, + hint: &str, + _on_change: impl FnOnce(), +) { + row(ui, label, |ui| { + ui.add( + egui::TextEdit::singleline(value) + .hint_text(hint) + .desired_width(ui.available_width().min(300.0)), + ); + }); +} + +fn status_row(ui: &mut egui::Ui, label: &str, status: &str, color: egui::Color32) { + row(ui, label, |ui| { + ui.label(egui::RichText::new(status).size(12.0).color(color)); + }); +} + +fn action_row( + ui: &mut egui::Ui, + label: &str, + action: &str, + _notice: Option, + actions: &mut Vec, +) { + row(ui, label, |ui| { + if ui + .add( + egui::Button::new(egui::RichText::new(action).size(11.5)) + .fill(theme::SURFACE_2) + .stroke(egui::Stroke::new(0.8, theme::LINE)) + .corner_radius(egui::CornerRadius::same(8)) + .min_size(egui::vec2(0.0, 26.0)), + ) + .clicked() + { + // Map common action labels to specific action fields + match action { + "测试配置" => actions.push(FrontendAction::SettingsAction( + SettingsActionField::ConnectionTest, + )), + "选择目录" => actions.push(FrontendAction::SettingsAction( + SettingsActionField::ModelManagement, + )), + "管理扩展" => actions.push(FrontendAction::SettingsAction( + SettingsActionField::ExtensionManagement, + )), + "打开系统设置" => actions.push(FrontendAction::SettingsAction( + SettingsActionField::Permissions, + )), + "清空历史记录" => actions.push(FrontendAction::SettingsAction( + SettingsActionField::ClearHistory, + )), + "重新检测" => actions.push(FrontendAction::SettingsAction( + SettingsActionField::ClaudeDetect, + )), + "展开控制台" | "收起控制台" => actions.push( + FrontendAction::SettingsAction(SettingsActionField::ClaudeConsole), + ), + "执行" => actions.push(FrontendAction::SettingsAction( + SettingsActionField::ClaudeRunTest, + )), + "导出错误日志" => actions.push(FrontendAction::SettingsAction( + SettingsActionField::ExportDiagnostics, + )), + "检查更新" => actions.push(FrontendAction::SettingsAction( + SettingsActionField::CheckUpdate, + )), + "打开 GitHub" => actions.push(FrontendAction::SettingsAction( + SettingsActionField::OpenGitHub, + )), + "打开帮助中心" => actions.push(FrontendAction::SettingsAction( + SettingsActionField::OpenHelp, + )), + "打开发行说明" => actions.push(FrontendAction::SettingsAction( + SettingsActionField::OpenReleaseNotes, + )), + "打开问题反馈" => actions.push(FrontendAction::SettingsAction( + SettingsActionField::OpenFeedback, + )), + "复制群号" => { + actions.push(FrontendAction::SettingsAction(SettingsActionField::CopyQQ)) + } + _ => {} + } + } + }); +} + +fn row(ui: &mut egui::Ui, label: &str, control: impl FnOnce(&mut egui::Ui)) { + ui.horizontal(|ui| { + ui.set_min_height(32.0); + ui.allocate_ui_with_layout( + egui::vec2(176.0, 32.0), + egui::Layout::left_to_right(egui::Align::Center), + |ui| { + ui.label(egui::RichText::new(label).size(12.0).color(theme::INK_2)); + }, + ); + control(ui); + }); + ui.separator(); +} diff --git a/openless-all/app/linux-egui/src/ui/frontend/view_model.rs b/openless-all/app/linux-egui/src/ui/frontend/view_model.rs new file mode 100644 index 000000000..4d7eb61cb --- /dev/null +++ b/openless-all/app/linux-egui/src/ui/frontend/view_model.rs @@ -0,0 +1,515 @@ +// ── Page / Tab ────────────────────────────────────────────────────────────── + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum Page { + #[default] + Overview, + History, + Vocab, + Style, + Marketplace, + SelectionAsk, + Translation, + Settings, +} + +// ── FrontendAction ────────────────────────────────────────────────────────── + +/// Every user interaction the frontend can produce. The host +/// (`OpenLessEguiApp`) drains these actions and dispatches them to existing +/// Core / backend methods without duplicating the Core state machine. +#[derive(Clone, Debug)] +pub enum FrontendAction { + /// Navigate to a different page. + Navigate(Page), + /// Open / close the in-window settings overlay. + ToggleSettings, + /// Close the settings overlay (from the × button). + CloseSettings, + /// Marketplace search query changed. + MarketplaceSearch(String), + /// Marketplace sort mode changed. + MarketplaceSort(MarketplaceSort), + /// Marketplace refresh requested. + MarketplaceRefresh, + /// Marketplace "my packs" requested. + MarketplaceMyPacks, + /// Open marketplace pack detail. + MarketplaceDetail(usize), + /// Close marketplace detail modal. + MarketplaceCloseDetail, + /// Download marketplace pack ZIP. + MarketplaceDownload(usize), + /// Install marketplace pack. + MarketplaceInstall(usize), + /// Toggle marketplace pack like. + MarketplaceToggleLike(usize), + /// History search query changed. + HistorySearch(String), + /// History filter changed. + HistoryFilter(usize), + /// Select a history entry. + HistorySelect(usize), + /// Clear all history. + HistoryClear, + /// Refresh history list. + HistoryRefresh, + /// Play/pause history audio. + HistoryTogglePlay, + /// Repolish a history entry. + HistoryRepolish, + /// Delete a history entry. + HistoryDelete(usize), + /// Export history recording. + HistoryExport(usize), + /// Vocab entry added. + VocabAddPhrase(String), + /// Vocab entry removed. + VocabRemovePhrase(usize), + /// Vocab entry toggled enabled/disabled. + VocabTogglePhrase(usize), + /// Correction rule added. + VocabAddRule { + pattern: String, + replacement: String, + }, + /// Correction rule removed. + VocabRemoveRule(usize), + /// Correction rule toggled. + VocabToggleRule(usize), + /// Vocab preset applied. + VocabApplyPreset(usize), + /// Vocab preset created. + VocabCreatePreset { + name: String, + phrases: String, + }, + /// Style pack activated. + StyleActivate(usize), + /// Style pack exported. + StyleExport(usize), + /// Style pack editor opened. + StyleEdit(usize), + /// Style editor prompt saved. + StyleSaveEditor(String), + /// Style editor closed. + StyleCloseEditor, + /// New style pack creation requested. + StyleNewPack, + /// Import style ZIP. + StyleImport, + /// Selection ask history toggle. + SelectionAskToggleHistory, + /// Translation working language toggled. + TranslationToggleLanguage(String), + /// Translation target language changed. + TranslationSetTarget(String), + /// Settings toggle changed. + SettingsToggle(SettingsField), + /// Settings combo index changed. + SettingsCombo(SettingsComboField, usize), + /// Settings text field changed. + SettingsText(SettingsTextField, String), + /// Settings action button clicked. + SettingsAction(SettingsActionField), + /// Settings section changed. + SettingsSection(SettingsSection), + /// Settings notice message. + SettingsNotice(String), + /// Window close requested. + WindowClose, + /// Window maximize/minimize toggle. + WindowMaximize, + /// Window minimize. + WindowMinimize, + /// Sidebar group toggle. + SidebarToggleStyle, + SidebarToggleTools, +} + +// ── Marketplace types ─────────────────────────────────────────────────────── + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum MarketplaceSort { + #[default] + Popular, + New, + Liked, +} + +#[derive(Clone, Debug)] +pub struct MarketplacePack { + pub name: String, + pub version: String, + pub description: String, + pub mode: String, + pub author: String, + pub tags: Vec, + pub likes: u32, + pub downloads: u32, + pub is_new: bool, +} + +// ── Settings types ────────────────────────────────────────────────────────── + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum SettingsSection { + General, + Services, + Privacy, + Advanced, + About, +} + +#[derive(Clone, Copy, Debug)] +pub enum SettingsField { + RecordingEnabled, + RealtimeMode, + StreamingInsert, + RestoreClipboard, + StartMinimized, + AutoUpdate, + RemoteInput, + SelectionAssistant, + SelectionVoice, + StackedLayout, + ConservativeLayout, + ActivityHeatmap, + SystemProxy, + LocalModel, + MarketplaceEnabled, + RememberHistory, + RecordAudio, + LessComputer, + Multimodal, + BetaChannel, +} + +#[derive(Clone, Copy, Debug)] +pub enum SettingsComboField { + Provider, + Language, + Theme, + Retention, + Microphone, + RecordingMode, +} + +#[derive(Clone, Debug)] +pub enum SettingsTextField { + ApiKey, + Endpoint, + Model, + RemotePort, + ClaudePrompt, +} + +#[derive(Clone, Copy, Debug)] +pub enum SettingsActionField { + ConnectionTest, + ModelManagement, + ExtensionManagement, + Permissions, + ClearHistory, + ClaudeDetect, + ClaudeConsole, + ClaudeRunTest, + ExportDiagnostics, + CheckUpdate, + OpenGitHub, + OpenHelp, + OpenReleaseNotes, + OpenFeedback, + CopyQQ, +} + +// ── Vocab types ───────────────────────────────────────────────────────────── + +#[derive(Clone, Debug)] +pub struct VocabEntry { + pub phrase: String, + pub hits: usize, + pub enabled: bool, + pub learned: bool, +} + +#[derive(Clone, Debug)] +pub struct CorrectionRule { + pub pattern: String, + pub replacement: String, + pub enabled: bool, + pub learned: bool, +} + +#[derive(Clone, Debug)] +pub struct SavedVocabPreset { + pub name: String, + pub phrases: String, +} + +// ── History types ─────────────────────────────────────────────────────────── + +#[derive(Clone, Debug)] +pub struct HistoryEntry { + pub time: String, + pub text: String, + pub duration: String, + pub tag: String, +} + +// ── Style types ───────────────────────────────────────────────────────────── + +#[derive(Clone, Debug)] +pub struct StylePack { + pub name: String, + pub description: String, + pub tags: Vec, + pub accent: egui::Color32, + pub is_builtin: bool, + pub is_active: bool, +} + +// ── Overview types ────────────────────────────────────────────────────────── + +#[derive(Clone, Debug, Default)] +pub struct OverviewSummary { + pub asr_provider: String, + pub llm_provider: String, + pub asr_configured: bool, + pub llm_configured: bool, + pub chars_today: u64, + pub segments_today: usize, + pub duration_ms_today: u64, + pub avg_latency_ms: u64, + pub history_total: usize, + pub recent: Vec, + pub last_7_segments: u64, + pub last_30_segments: u64, + pub heatmap_weeks: Vec<[u32; 7]>, + pub heatmap_days: u32, + pub activity_days_total: usize, +} + +#[derive(Clone, Debug, Default)] +pub struct OverviewRecentEntry { + pub created_at: String, + pub final_text: String, + pub duration_ms: Option, +} + +// ── FrontendViewModel ─────────────────────────────────────────────────────── + +/// Pure display state for the egui frontend. Contains no mock data — every +/// field is populated by the host (`OpenLessEguiApp`) from Core / backend +/// sources. Unwired fields show empty / Loading / Unsupported states. +#[derive(Clone, Debug)] +pub struct FrontendViewModel { + pub active_page: Page, + pub style_open: bool, + pub tools_open: bool, + pub settings_open: bool, + + // Overview + pub overview_loading: bool, + pub overview_error: Option, + pub overview: Option, + + // History + pub history_query: String, + pub history_filter: usize, + pub history_selected: usize, + pub history_entries: Vec, + pub history_cleared: bool, + pub history_repolished: bool, + pub history_audio_playing: bool, + pub history_style_picker_open: bool, + + // Vocab + pub vocab_entries: Vec, + pub vocab_rules: Vec, + pub vocab_input: String, + pub vocab_pattern: String, + pub vocab_replacement: String, + pub vocab_preset_name: String, + pub vocab_preset_phrases: String, + pub vocab_selected_presets: Vec, + pub vocab_editing_preset: Option, + pub vocab_saved_presets: Vec, + pub vocab_presets_open: bool, + pub vocab_corrections_open: bool, + pub vocab_entries_open: bool, + pub vocab_error: Option, + pub vocab_unsupported: bool, + + // Style + pub style_packs: Vec, + pub style_selected: usize, + pub style_selection_workflow: bool, + pub style_editor_open: bool, + pub style_prompt: String, + pub style_notice: Option, + pub style_unsupported: bool, + + // Marketplace + pub marketplace_query: String, + pub marketplace_sort: MarketplaceSort, + pub marketplace_packs: Vec, + pub marketplace_selected: Option, + pub marketplace_liked: Vec, + pub marketplace_notice: Option, + pub marketplace_loading: bool, + pub marketplace_unsupported: bool, + + // Settings + pub settings_section: SettingsSection, + pub settings_notice: Option, + pub settings: SettingsFields, + + // Selection ask + pub qa_save_history: bool, + pub selection_unsupported: bool, + + // Translation + pub translation_working_languages: Vec, + pub translation_target_language: String, + pub translation_unsupported: bool, + + // Status bar + pub version: String, + pub status: String, +} + +impl Default for FrontendViewModel { + fn default() -> Self { + Self { + active_page: Page::Overview, + style_open: true, + tools_open: false, + settings_open: false, + overview_loading: true, + overview_error: None, + overview: None, + history_query: String::new(), + history_filter: 0, + history_selected: 0, + history_entries: Vec::new(), + history_cleared: false, + history_repolished: false, + history_audio_playing: false, + history_style_picker_open: false, + vocab_entries: Vec::new(), + vocab_rules: Vec::new(), + vocab_input: String::new(), + vocab_pattern: String::new(), + vocab_replacement: String::new(), + vocab_preset_name: String::new(), + vocab_preset_phrases: String::new(), + vocab_selected_presets: Vec::new(), + vocab_editing_preset: None, + vocab_saved_presets: Vec::new(), + vocab_presets_open: false, + vocab_corrections_open: false, + vocab_entries_open: true, + vocab_error: None, + vocab_unsupported: true, + style_packs: Vec::new(), + style_selected: 0, + style_selection_workflow: false, + style_editor_open: false, + style_prompt: String::new(), + style_notice: None, + style_unsupported: true, + marketplace_query: String::new(), + marketplace_sort: MarketplaceSort::Popular, + marketplace_packs: Vec::new(), + marketplace_selected: None, + marketplace_liked: Vec::new(), + marketplace_notice: None, + marketplace_loading: true, + marketplace_unsupported: true, + settings_section: SettingsSection::General, + settings_notice: None, + settings: SettingsFields::default(), + qa_save_history: false, + selection_unsupported: true, + translation_working_languages: Vec::new(), + translation_target_language: String::new(), + translation_unsupported: true, + version: env!("CARGO_PKG_VERSION").to_string(), + status: String::new(), + } + } +} + +/// Mirror of the egui-frontend `SettingsState` fields, but with no default +/// mock data. All values come from the host. +#[derive(Clone, Debug)] +pub struct SettingsFields { + pub recording_enabled: bool, + pub realtime_mode: bool, + pub streaming_insert: bool, + pub restore_clipboard: bool, + pub start_minimized: bool, + pub auto_update: bool, + pub remote_input: bool, + pub selection_assistant: bool, + pub selection_voice: bool, + pub stacked_layout: bool, + pub conservative_layout: bool, + pub activity_heatmap: bool, + pub system_proxy: bool, + pub local_model: bool, + pub marketplace_enabled: bool, + pub remember_history: bool, + pub record_audio: bool, + pub less_computer: bool, + pub multimodal: bool, + pub beta_channel: bool, + pub claude_expanded: bool, + pub provider: usize, + pub language: usize, + pub theme: usize, + pub retention: usize, + pub api_key: String, + pub endpoint: String, + pub model: String, + pub remote_port: String, + pub claude_prompt: String, +} + +impl Default for SettingsFields { + fn default() -> Self { + Self { + recording_enabled: false, + realtime_mode: false, + streaming_insert: false, + restore_clipboard: false, + start_minimized: false, + auto_update: false, + remote_input: false, + selection_assistant: false, + selection_voice: false, + stacked_layout: false, + conservative_layout: false, + activity_heatmap: false, + system_proxy: false, + local_model: false, + marketplace_enabled: false, + remember_history: false, + record_audio: false, + less_computer: false, + multimodal: false, + beta_channel: false, + claude_expanded: false, + provider: 0, + language: 0, + theme: 0, + retention: 0, + api_key: String::new(), + endpoint: String::new(), + model: String::new(), + remote_port: String::new(), + claude_prompt: String::new(), + } + } +} diff --git a/openless-all/app/linux-egui/src/ui/mod.rs b/openless-all/app/linux-egui/src/ui/mod.rs new file mode 100644 index 000000000..8625bc5bc --- /dev/null +++ b/openless-all/app/linux-egui/src/ui/mod.rs @@ -0,0 +1,3 @@ +pub mod frontend; +pub mod shell; +pub mod theme; diff --git a/openless-all/app/linux-egui/src/ui/shell.rs b/openless-all/app/linux-egui/src/ui/shell.rs new file mode 100644 index 000000000..8ee46c0e6 --- /dev/null +++ b/openless-all/app/linux-egui/src/ui/shell.rs @@ -0,0 +1,447 @@ +use eframe::egui; + +use openless_linux_egui::{tr_l10n, Lang}; + +use super::theme; + +pub const SIDEBAR_WIDTH: f32 = 188.0; +pub const TITLEBAR_HEIGHT: f32 = 38.0; +const WINDOW_MARGIN: f32 = 6.0; +const WINDOW_RADIUS: u8 = 14; + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum Page { + #[default] + Overview, + History, + Vocabulary, + Styles, + Marketplace, + Providers, + Assistant, +} + +impl Page { + pub fn title(self, lang: Lang) -> &'static str { + let key = match self { + Self::Overview => "nav.overview", + Self::History => "nav.history", + Self::Vocabulary => "nav.vocab", + Self::Styles => "nav.styles", + Self::Marketplace => "nav.marketplace", + Self::Providers => "nav.providers", + Self::Assistant => "nav.assistant", + }; + tr_l10n(lang, key) + } + + pub fn nav_title(self, lang: Lang) -> &'static str { + if self == Self::Providers { + tr_l10n(lang, "nav.settings") + } else { + self.title(lang) + } + } +} + +#[derive(Clone, Copy)] +enum IconName { + Overview, + History, + Vocabulary, + Style, + Tools, + Settings, +} + +fn window_rect(ctx: &egui::Context) -> egui::Rect { + ctx.content_rect().shrink(WINDOW_MARGIN) +} + +fn body_rect(ctx: &egui::Context) -> egui::Rect { + let window = window_rect(ctx); + egui::Rect::from_min_max(window.min + egui::vec2(0.0, TITLEBAR_HEIGHT), window.max) +} + +fn app_icon(ctx: &egui::Context) -> egui::TextureHandle { + let id = egui::Id::new("openless-shell-app-icon"); + if let Some(texture) = ctx.data(|data| data.get_temp::(id)) { + return texture; + } + let image = image::load_from_memory(include_bytes!("../../../public/AppIcon.png")) + .expect("OpenLess AppIcon.png must be valid") + .into_rgba8(); + let color = egui::ColorImage::from_rgba_unmultiplied( + [image.width() as usize, image.height() as usize], + image.as_raw(), + ); + let texture = ctx.load_texture("openless-app-icon", color, egui::TextureOptions::LINEAR); + ctx.data_mut(|data| data.insert_temp(id, texture.clone())); + texture +} + +pub fn titlebar(ctx: &egui::Context) { + let window = window_rect(ctx); + let body = body_rect(ctx); + let painter = ctx.layer_painter(egui::LayerId::new( + egui::Order::Background, + egui::Id::new("openless-window-background"), + )); + painter.rect_filled( + window, + egui::CornerRadius::same(WINDOW_RADIUS), + theme::SURFACE, + ); + painter.rect_filled( + body, + egui::CornerRadius { + nw: 0, + ne: 0, + sw: WINDOW_RADIUS, + se: WINDOW_RADIUS, + }, + theme::CANVAS, + ); + painter.rect_stroke( + window, + egui::CornerRadius::same(WINDOW_RADIUS), + egui::Stroke::new(1.0, theme::LINE), + egui::StrokeKind::Inside, + ); + + egui::Area::new(egui::Id::new("openless-titlebar")) + .order(egui::Order::Middle) + .fixed_pos(window.min) + .show(ctx, |ui| { + ui.set_min_size(egui::vec2(window.width(), TITLEBAR_HEIGHT)); + let titlebar = egui::Rect::from_min_size( + egui::Pos2::ZERO, + egui::vec2(window.width(), TITLEBAR_HEIGHT), + ); + let drag = ui.interact( + titlebar, + ui.id().with("titlebar-drag"), + egui::Sense::click_and_drag(), + ); + if drag.drag_started() { + ctx.send_viewport_cmd(egui::ViewportCommand::StartDrag); + } + let texture = app_icon(ctx); + ui.painter().image( + texture.id(), + egui::Rect::from_center_size( + egui::pos2(16.0, TITLEBAR_HEIGHT / 2.0), + egui::vec2(18.0, 18.0), + ), + egui::Rect::from_min_max(egui::Pos2::ZERO, egui::pos2(1.0, 1.0)), + egui::Color32::WHITE, + ); + ui.painter().text( + egui::pos2(34.0, TITLEBAR_HEIGHT / 2.0 + 0.5), + egui::Align2::LEFT_CENTER, + "OpenLess", + egui::FontId::proportional(13.0), + theme::INK_2, + ); + + let button_width = 40.0; + let close = egui::Rect::from_min_max( + egui::pos2(titlebar.right() - button_width, 0.0), + titlebar.right_bottom(), + ); + let maximize = close.translate(egui::vec2(-button_width, 0.0)); + let minimize = maximize.translate(egui::vec2(-button_width, 0.0)); + let close_response = ui.interact(close, ui.id().with("close"), egui::Sense::click()); + let maximize_response = + ui.interact(maximize, ui.id().with("maximize"), egui::Sense::click()); + let minimize_response = + ui.interact(minimize, ui.id().with("minimize"), egui::Sense::click()); + if close_response.clicked() { + ctx.send_viewport_cmd(egui::ViewportCommand::Close); + } + if maximize_response.clicked() { + let maximized = ctx.input(|input| input.viewport().maximized.unwrap_or(false)); + ctx.send_viewport_cmd(egui::ViewportCommand::Maximized(!maximized)); + } + if minimize_response.clicked() { + ctx.send_viewport_cmd(egui::ViewportCommand::Minimized(true)); + } + for (rect, response) in [ + (minimize, &minimize_response), + (maximize, &maximize_response), + (close, &close_response), + ] { + if response.hovered() { + ui.painter() + .rect_filled(rect, egui::CornerRadius::same(6), theme::SURFACE_2); + } + } + let stroke = egui::Stroke::new(1.0, theme::INK_3); + ui.painter().line_segment( + [ + minimize.center() - egui::vec2(5.0, 0.0), + minimize.center() + egui::vec2(5.0, 0.0), + ], + stroke, + ); + ui.painter().rect_stroke( + maximize.shrink(14.0), + egui::CornerRadius::ZERO, + stroke, + egui::StrokeKind::Inside, + ); + ui.painter().line_segment( + [ + close.center() - egui::vec2(5.0, 5.0), + close.center() + egui::vec2(5.0, 5.0), + ], + stroke, + ); + ui.painter().line_segment( + [ + close.center() + egui::vec2(5.0, -5.0), + close.center() + egui::vec2(-5.0, 5.0), + ], + stroke, + ); + }); +} + +pub fn sidebar(ctx: &egui::Context, active: &mut Page, status: &str, lang: Lang) { + let body = body_rect(ctx); + egui::Area::new(egui::Id::new("openless-sidebar")) + .order(egui::Order::Middle) + .fixed_pos(body.min) + .show(ctx, |ui| { + ui.set_min_size(egui::vec2(SIDEBAR_WIDTH, body.height())); + ui.set_clip_rect(egui::Rect::from_min_size( + egui::Pos2::ZERO, + egui::vec2(SIDEBAR_WIDTH, body.height()), + )); + ui.painter().rect_filled(ui.max_rect(), 0.0, theme::SURFACE); + ui.painter().line_segment( + [ + egui::pos2(SIDEBAR_WIDTH, 0.0), + egui::pos2(SIDEBAR_WIDTH, body.height()), + ], + egui::Stroke::new(1.0, theme::LINE), + ); + egui::Frame::NONE + .inner_margin(egui::Margin::symmetric(10, 12)) + .show(ui, |ui| { + ui.set_width(SIDEBAR_WIDTH - 20.0); + nav(ui, active, Page::Overview, IconName::Overview, lang); + nav(ui, active, Page::History, IconName::History, lang); + nav(ui, active, Page::Vocabulary, IconName::Vocabulary, lang); + ui.add_space(5.0); + group_label(ui, tr_l10n(lang, "nav.styles"), IconName::Style); + subnav(ui, active, Page::Styles, lang); + subnav(ui, active, Page::Marketplace, lang); + ui.add_space(2.0); + group_label(ui, tr_l10n(lang, "nav.assistant"), IconName::Tools); + subnav(ui, active, Page::Assistant, lang); + + ui.with_layout(egui::Layout::bottom_up(egui::Align::Min), |ui| { + nav(ui, active, Page::Providers, IconName::Settings, lang); + ui.add_space(10.0); + ui.horizontal(|ui| { + ui.add_space(10.0); + ui.vertical(|ui| { + egui::Frame::new() + .fill(theme::BLUE_SOFT) + .corner_radius(egui::CornerRadius::same(7)) + .inner_margin(egui::Margin::symmetric(6, 2)) + .show(ui, |ui| { + ui.label( + egui::RichText::new("BETA") + .size(9.5) + .strong() + .color(theme::BLUE), + ); + }); + ui.add_space(3.0); + ui.label( + egui::RichText::new(format!( + "{} · {}", + env!("CARGO_PKG_VERSION"), + status + )) + .size(10.5) + .color(theme::INK_4), + ); + }); + }); + }); + }); + }); +} + +fn nav(ui: &mut egui::Ui, active: &mut Page, page: Page, icon: IconName, lang: Lang) { + let selected = *active == page; + let (rect, response) = + ui.allocate_exact_size(egui::vec2(SIDEBAR_WIDTH - 20.0, 32.0), egui::Sense::click()); + if selected { + ui.painter() + .rect_filled(rect, egui::CornerRadius::same(8), theme::SURFACE_2); + } + let color = if selected { theme::INK } else { theme::INK_3 }; + draw_icon(ui, rect.min + egui::vec2(20.0, 16.0), icon, color); + ui.painter().text( + rect.min + egui::vec2(38.0, 16.0), + egui::Align2::LEFT_CENTER, + page.nav_title(lang), + egui::FontId::proportional(13.0), + color, + ); + if response.clicked() { + *active = page; + } +} + +fn subnav(ui: &mut egui::Ui, active: &mut Page, page: Page, lang: Lang) { + let selected = *active == page; + let (rect, response) = + ui.allocate_exact_size(egui::vec2(SIDEBAR_WIDTH - 20.0, 30.0), egui::Sense::click()); + if selected { + ui.painter() + .rect_filled(rect, egui::CornerRadius::same(8), theme::SURFACE_2); + } + ui.painter().text( + rect.min + egui::vec2(30.0, 15.0), + egui::Align2::LEFT_CENTER, + page.nav_title(lang), + egui::FontId::proportional(12.5), + if selected { theme::INK } else { theme::INK_3 }, + ); + if response.clicked() { + *active = page; + } +} + +fn group_label(ui: &mut egui::Ui, label: &str, icon: IconName) { + let (rect, _) = + ui.allocate_exact_size(egui::vec2(SIDEBAR_WIDTH - 20.0, 32.0), egui::Sense::hover()); + draw_icon(ui, rect.min + egui::vec2(20.0, 16.0), icon, theme::INK_3); + ui.painter().text( + rect.min + egui::vec2(38.0, 16.0), + egui::Align2::LEFT_CENTER, + label, + egui::FontId::proportional(13.0), + theme::INK_3, + ); +} + +fn draw_icon(ui: &egui::Ui, center: egui::Pos2, icon: IconName, color: egui::Color32) { + let painter = ui.painter(); + let stroke = egui::Stroke::new(1.25, color); + match icon { + IconName::Overview => { + painter.line_segment( + [ + center + egui::vec2(-6.0, -6.0), + center + egui::vec2(-6.0, 6.0), + ], + stroke, + ); + painter.line_segment( + [ + center + egui::vec2(-6.0, 6.0), + center + egui::vec2(6.0, 6.0), + ], + stroke, + ); + for (x, top) in [(-2.5, 1.0), (1.5, -4.0), (5.5, -1.5)] { + painter.line_segment( + [center + egui::vec2(x, 5.0), center + egui::vec2(x, top)], + stroke, + ); + } + } + IconName::History => { + painter.circle_stroke(center, 6.0, stroke); + painter.line_segment([center, center + egui::vec2(0.0, -3.5)], stroke); + painter.line_segment([center, center + egui::vec2(3.0, 2.0)], stroke); + } + IconName::Vocabulary => { + for y in [-4.0, 0.0, 4.0] { + painter.circle_filled(center + egui::vec2(-5.0, y), 1.0, color); + painter.line_segment( + [center + egui::vec2(-2.0, y), center + egui::vec2(6.0, y)], + stroke, + ); + } + } + IconName::Style => { + painter.line_segment( + [ + center + egui::vec2(-5.0, 5.0), + center + egui::vec2(4.0, -4.0), + ], + stroke, + ); + painter.line_segment( + [ + center + egui::vec2(2.0, -5.0), + center + egui::vec2(5.0, -2.0), + ], + stroke, + ); + } + IconName::Tools => { + painter.circle_stroke(center, 5.5, stroke); + painter.line_segment( + [ + center + egui::vec2(-3.5, 3.5), + center + egui::vec2(3.5, -3.5), + ], + stroke, + ); + } + IconName::Settings => { + painter.circle_stroke(center, 5.5, stroke); + painter.circle_stroke(center, 2.0, stroke); + for angle in [ + 0.0_f32, + std::f32::consts::FRAC_PI_2, + std::f32::consts::PI, + 3.0 * std::f32::consts::FRAC_PI_2, + ] { + let direction = egui::vec2(angle.cos(), angle.sin()); + painter.line_segment([center + direction * 5.5, center + direction * 7.0], stroke); + } + } + } +} + +pub fn content_panel( + ctx: &egui::Context, + _active: Page, + _lang: Lang, + add_contents: impl FnOnce(&mut egui::Ui), +) { + let body = body_rect(ctx); + let content = egui::Rect::from_min_max( + egui::pos2(body.left() + SIDEBAR_WIDTH + 28.0, body.top()), + egui::pos2(body.right() - 2.0, body.bottom() - 8.0), + ); + egui::Area::new(egui::Id::new("openless-content")) + .order(egui::Order::Middle) + .fixed_pos(content.min) + .show(ctx, |ui| { + ui.set_min_size(content.size()); + ui.set_clip_rect(egui::Rect::from_min_size(egui::Pos2::ZERO, content.size())); + let scroll = &mut ui.style_mut().spacing.scroll; + scroll.floating = true; + scroll.bar_width = 8.0; + scroll.handle_min_length = 24.0; + scroll.bar_inner_margin = 0.0; + scroll.bar_outer_margin = 0.0; + scroll.foreground_color = false; + scroll.floating_width = 6.0; + scroll.floating_allocated_width = 0.0; + ui.add_space(22.0); + egui::ScrollArea::vertical() + .auto_shrink([false, false]) + .show(ui, add_contents); + }); +} diff --git a/openless-all/app/linux-egui/src/ui/theme.rs b/openless-all/app/linux-egui/src/ui/theme.rs new file mode 100644 index 000000000..723835ff9 --- /dev/null +++ b/openless-all/app/linux-egui/src/ui/theme.rs @@ -0,0 +1,102 @@ +use std::path::PathBuf; + +use eframe::egui; + +pub const BLUE: egui::Color32 = egui::Color32::from_rgb(37, 99, 235); +pub const BLUE_SOFT: egui::Color32 = egui::Color32::from_rgb(239, 245, 255); +pub const CANVAS: egui::Color32 = egui::Color32::from_rgb(250, 250, 250); +pub const SURFACE: egui::Color32 = egui::Color32::WHITE; +pub const SURFACE_2: egui::Color32 = egui::Color32::from_rgb(244, 244, 245); +pub const LINE: egui::Color32 = egui::Color32::from_rgb(228, 228, 231); +pub const INK: egui::Color32 = egui::Color32::from_rgb(9, 9, 11); +pub const INK_2: egui::Color32 = egui::Color32::from_rgb(63, 63, 70); +pub const INK_3: egui::Color32 = egui::Color32::from_rgb(113, 113, 122); +pub const INK_4: egui::Color32 = egui::Color32::from_rgb(161, 161, 170); +pub const OK: egui::Color32 = egui::Color32::from_rgb(22, 163, 74); + +/// Install the same Linux font fallback strategy as the redesigned prototype, +/// without shipping its large duplicate font bundle. +pub fn install(ctx: &egui::Context) { + let mut candidates: Vec<(&str, PathBuf)> = Vec::new(); + if let Some(path) = std::env::var_os("OPENLESS_IME_FONT").map(PathBuf::from) { + candidates.push(("openless-primary", path)); + } + candidates.extend([ + ( + "openless-cjk", + PathBuf::from("/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc"), + ), + ( + "openless-cjk-fallback", + PathBuf::from("/usr/share/fonts/truetype/droid/DroidSansFallbackFull.ttf"), + ), + ( + "openless-latin", + PathBuf::from("/usr/share/fonts/truetype/noto/NotoSans-Regular.ttf"), + ), + ( + "openless-arabic", + PathBuf::from("/usr/share/fonts/truetype/noto/NotoSansArabic-Regular.ttf"), + ), + ( + "openless-thai", + PathBuf::from("/usr/share/fonts/truetype/noto/NotoSansThai-Regular.ttf"), + ), + ( + "openless-devanagari", + PathBuf::from("/usr/share/fonts/truetype/noto/NotoSansDevanagari-Regular.ttf"), + ), + ]); + + let mut fonts = egui::FontDefinitions::default(); + let mut loaded = Vec::new(); + for (name, path) in candidates { + let Ok(bytes) = std::fs::read(&path) else { + continue; + }; + fonts + .font_data + .insert(name.into(), egui::FontData::from_owned(bytes).into()); + loaded.push(name); + } + for family in [egui::FontFamily::Proportional, egui::FontFamily::Monospace] { + let family_fonts = fonts.families.entry(family).or_default(); + for name in loaded.iter().rev() { + family_fonts.insert(0, (*name).into()); + } + } + ctx.set_fonts(fonts); + + apply_visuals(ctx, openless_core::shared_types::ThemeMode::System); + + let mut style = (*ctx.style()).clone(); + style.spacing.item_spacing = egui::vec2(8.0, 8.0); + style.spacing.button_padding = egui::vec2(10.0, 6.0); + ctx.set_style(style); +} + +pub fn apply_visuals(ctx: &egui::Context, mode: openless_core::shared_types::ThemeMode) { + let dark = match mode { + openless_core::shared_types::ThemeMode::System => { + ctx.system_theme() == Some(egui::Theme::Dark) + } + openless_core::shared_types::ThemeMode::Light => false, + openless_core::shared_types::ThemeMode::Dark => true, + }; + let mut visuals = if dark { + egui::Visuals::dark() + } else { + egui::Visuals::light() + }; + if !dark { + visuals.panel_fill = CANVAS; + visuals.window_fill = SURFACE; + visuals.faint_bg_color = SURFACE_2; + visuals.selection.bg_fill = BLUE_SOFT; + } + visuals.selection.stroke = egui::Stroke::new(1.0, BLUE); + visuals.widgets.inactive.corner_radius = egui::CornerRadius::same(7); + visuals.widgets.hovered.corner_radius = egui::CornerRadius::same(7); + visuals.widgets.active.corner_radius = egui::CornerRadius::same(7); + ctx.set_visuals(visuals); +} diff --git a/openless-all/app/linux-egui/src/ui_state.rs b/openless-all/app/linux-egui/src/ui_state.rs index c22ced65e..9b7ebe0c8 100644 --- a/openless-all/app/linux-egui/src/ui_state.rs +++ b/openless-all/app/linux-egui/src/ui_state.rs @@ -1,119 +1,177 @@ -//! Presentation state only: changing pages never owns or cancels a Core session. - -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] -pub(super) enum Page { - #[default] - Start, - Dictation, - Qa, - Selection, - Agent, - Services, - Models, - Remote, - History, - Settings, +//! Persistence of pure Linux-UI state (view-model preferences that are *not* +//! business truth). Business truth lives in Core's `UserPreferences`; this +//! module keeps only UI-surface state such as the chosen display language, +//! stored on disk beside Core but never inside a Core-owned document. + +use std::path::PathBuf; + +use crate::desktop::atomic_save; +use crate::i18n::{LocalePref, FOLLOW_SYSTEM}; + +const STATE_FILE: &str = "linux-ui-state.json"; + +#[derive(Debug)] +pub enum UiStateError { + Io { + operation: &'static str, + source: std::io::Error, + }, + Json(String), } -impl Page { - pub const ALL: [Self; 10] = [ - Self::Start, - Self::Dictation, - Self::Qa, - Self::Selection, - Self::Agent, - Self::Services, - Self::Models, - Self::Remote, - Self::History, - Self::Settings, - ]; - - pub fn label(self) -> &'static str { +impl std::fmt::Display for UiStateError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { - Self::Start => "开始", - Self::Dictation => "听写", - Self::Qa => "问答", - Self::Selection => "选区润色", - Self::Agent => "Less Computer", - Self::Services => "AI 服务", - Self::Models => "本地模型", - Self::Remote => "手机输入", - Self::History => "历史", - Self::Settings => "环境与设置", + Self::Io { operation, source } => write!(f, "{operation}: {source}"), + Self::Json(message) => f.write_str(message), } } } -#[derive(Default)] -pub(super) struct Navigation { - pub page: Page, - unread: [bool; Page::ALL.len()], +impl std::error::Error for UiStateError {} + +fn io_error(operation: &'static str, source: std::io::Error) -> UiStateError { + UiStateError::Io { operation, source } } -impl Navigation { - pub fn open(&mut self, page: Page) { - self.page = page; - self.unread[page as usize] = false; - } +/// The application data directory, mirroring the runtime's `backend_config` +/// derivation (XDG_DATA_HOME, falling back to `~/.local/share`). Kept here so +/// the main window *and* the separate popup window resolve the exact same +/// state file without sharing a process-local handle. +pub fn ui_state_dir() -> Option { + let home = std::env::var_os("HOME").map(PathBuf::from)?; + let base = std::env::var_os("XDG_DATA_HOME") + .map(PathBuf::from) + .unwrap_or_else(|| home.join(".local/share")); + Some(base.join("OpenLess")) +} - pub fn notify(&mut self, page: Page) { - if self.page != page { - self.unread[page as usize] = true; - } - } +/// Absolute path to the persisted Linux-UI state document. +pub fn ui_state_path() -> Option { + ui_state_dir().map(|dir| dir.join(STATE_FILE)) +} - pub fn has_update(&self, page: Page) -> bool { - self.unread[page as usize] +/// Read the persisted locale preference. A missing, empty or unreadable file +/// (plus any unrecognised value) degrades to `LocalePref::System` — following +/// the OS locale — so a corrupt state can never wedge the UI on the wrong +/// language. +pub fn load_locale_pref() -> LocalePref { + let Some(path) = ui_state_path() else { + return LocalePref::System; + }; + let raw = match std::fs::read_to_string(&path) { + Ok(raw) => raw, + Err(_) => return LocalePref::System, + }; + let value = match serde_json::from_str::(&raw) { + Ok(value) => value, + Err(_) => return LocalePref::System, + }; + match value.get("locale").and_then(serde_json::Value::as_str) { + Some(tag) => LocalePref::from_tag(tag), + None => LocalePref::System, } } +/// Persist the locale preference atomically. Returns an error only when the +/// state cannot be written at all; unknown environments (no HOME) simply leave +/// the preference unsaved and are reported as a recoverable failure. +pub fn save_locale_pref(pref: LocalePref) -> Result<(), UiStateError> { + let Some(dir) = ui_state_dir() else { + return Err(io_error( + "resolve ui-state directory", + std::io::Error::new(std::io::ErrorKind::NotFound, "HOME is unavailable"), + )); + }; + let Some(path) = ui_state_path() else { + return Err(io_error( + "resolve ui-state path", + std::io::Error::new(std::io::ErrorKind::NotFound, "HOME is unavailable"), + )); + }; + std::fs::create_dir_all(&dir).map_err(|error| io_error("create ui-state directory", error))?; + let tag = match pref { + LocalePref::System => FOLLOW_SYSTEM.to_string(), + LocalePref::Lang(lang) => lang.tag().to_string(), + }; + let document = serde_json::json!({ "locale": tag }); + let bytes = serde_json::to_vec_pretty(&document) + .map_err(|error| UiStateError::Json(error.to_string()))?; + atomic_save(&path, &bytes) + .map(|_| ()) + .map_err(|error| match error { + crate::desktop::DesktopError::InvalidInput(message) => UiStateError::Json(message), + crate::desktop::DesktopError::Io { operation, source } => { + UiStateError::Io { operation, source } + } + other => UiStateError::Json(other.to_string()), + }) +} + #[cfg(test)] mod tests { use super::*; + use crate::i18n::Lang; + use std::sync::{Mutex, OnceLock}; - #[test] - fn background_work_keeps_its_notice_until_its_own_page_is_opened() { - let mut navigation = Navigation::default(); - navigation.notify(Page::Qa); - navigation.notify(Page::Selection); - navigation.notify(Page::Agent); - navigation.open(Page::Settings); - for page in [Page::Qa, Page::Selection, Page::Agent] { - assert!(navigation.has_update(page)); + /// Env vars are process-global, so these tests must not interleave. + fn test_lock() -> &'static Mutex<()> { + static LOCK: OnceLock> = OnceLock::new(); + LOCK.get_or_init(|| Mutex::new(())) + } + + fn with_tmp_state(run: impl FnOnce(PathBuf)) { + // Recover a poisoned lock (from an earlier assertion) rather than + // failing the whole module: env vars must stay consistent per test. + let _guard = test_lock() + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let dir = + std::env::temp_dir().join(format!("openless-ui-state-test-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&dir).unwrap(); + // Point every data-dir resolver at the temp dir via XDG_DATA_HOME. + let previous = std::env::var_os("XDG_DATA_HOME"); + std::env::set_var("XDG_DATA_HOME", &dir); + run(dir.clone()); + if let Some(value) = previous { + std::env::set_var("XDG_DATA_HOME", value); + } else { + std::env::remove_var("XDG_DATA_HOME"); } - navigation.open(Page::Qa); - assert!(!navigation.has_update(Page::Qa)); - assert!(navigation.has_update(Page::Selection)); - assert!(navigation.has_update(Page::Agent)); + let _ = std::fs::remove_dir_all(dir); } #[test] - fn reading_a_page_does_not_create_an_unread_notice() { - let mut navigation = Navigation::default(); - navigation.open(Page::Agent); - navigation.notify(Page::Agent); - assert!(!navigation.has_update(Page::Agent)); - navigation.open(Page::Start); - navigation.notify(Page::Agent); - assert!(navigation.has_update(Page::Agent)); - assert_eq!(navigation.page, Page::Start); + fn absent_state_resolves_to_follow_system() { + with_tmp_state(|dir| { + // No file has been written in this fresh dir. + assert_eq!(load_locale_pref(), LocalePref::System); + std::fs::remove_dir_all(&dir).ok(); + }); } #[test] - fn every_destination_can_be_opened_without_clearing_other_destinations() { - let mut navigation = Navigation::default(); - for page in Page::ALL { - assert!(!page.label().is_empty()); - navigation.notify(page); - } - for (index, page) in Page::ALL.into_iter().enumerate() { - navigation.open(page); - assert_eq!(navigation.page, page); - assert!(!navigation.has_update(page)); - for remaining in &Page::ALL[index + 1..] { - assert!(navigation.has_update(*remaining)); - } - } + fn locale_preference_persists_and_roundtrips_across_reload() { + with_tmp_state(|_dir| { + save_locale_pref(LocalePref::Lang(Lang::ZhTw)).unwrap(); + assert_eq!(load_locale_pref(), LocalePref::Lang(Lang::ZhTw)); + save_locale_pref(LocalePref::System).unwrap(); + assert_eq!(load_locale_pref(), LocalePref::System); + save_locale_pref(LocalePref::Lang(Lang::Ko)).unwrap(); + assert_eq!(load_locale_pref(), LocalePref::Lang(Lang::Ko)); + }); + } + + #[test] + fn corrupt_state_file_degrades_to_follow_system() { + with_tmp_state(|_dir| { + let path = ui_state_path().unwrap(); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(&path, "{ not json").unwrap(); + assert_eq!(load_locale_pref(), LocalePref::System); + // A later valid write repairs the state. + save_locale_pref(LocalePref::Lang(Lang::Ja)).unwrap(); + assert_eq!(load_locale_pref(), LocalePref::Lang(Lang::Ja)); + }); } } diff --git a/openless-all/app/linux-egui/src/updater.rs b/openless-all/app/linux-egui/src/updater.rs new file mode 100644 index 000000000..5d63f6733 --- /dev/null +++ b/openless-all/app/linux-egui/src/updater.rs @@ -0,0 +1,1085 @@ +//! Verified AppImage replacement primitives. +//! +//! Network transport is HTTPS-only and every replacement is verified against +//! the same pinned minisign key used by the existing desktop updater. + +use base64::Engine as _; +use futures_util::StreamExt; +use minisign_verify::{PublicKey, Signature}; +use serde::Deserialize; +use std::fmt; +use std::fs::{self, File, OpenOptions}; +use std::io::{self, Read, Write}; +use std::path::{Path, PathBuf}; +use std::time::Duration; + +pub const MANIFEST_SCHEMA_VERSION: u32 = 1; +pub const MANIFEST_HOST: &str = "linux-egui"; +pub const DEFAULT_MAX_APPIMAGE_BYTES: u64 = 1024 * 1024 * 1024; +pub const DEFAULT_MAX_MANIFEST_BYTES: u64 = 1024 * 1024; +pub const STARTUP_CHECK_DELAY: Duration = Duration::from_secs(15); +pub const PERIODIC_CHECK_INTERVAL: Duration = Duration::from_secs(60 * 60); +pub const RELEASES_URL: &str = "https://github.com/Open-Less/openless/releases"; +pub const DIRECT_RELEASE_BASE: &str = "https://github.com/Open-Less/openless"; +pub const BETA_RELEASES_API: &str = + "https://api.github.com/repos/Open-Less/openless/releases?per_page=30"; + +/// Pinned OpenLess updater key. This is deliberately compiled into the host, +/// rather than accepted from a manifest fetched over the network. It matches +/// the existing OpenLess updater signing key used by the release workflows. +pub const PINNED_MINISIGN_PUBLIC_KEY: &str = + "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDFERUFBODAzNTY0QzMyM0YKUldRL01reFdBNmpxSGE1K0JadlpONXNWTzhJcGZCRGxjUVdIWExNNFJpeUNsSGZwazdlQThhemkK"; + +pub use openless_core::shared_types::UpdateChannel; + +pub fn manifest_urls(channel: UpdateChannel, arch: &str, beta_tag: Option<&str>) -> Vec { + let name = format!("latest-linux-egui-{arch}.json"); + match channel { + UpdateChannel::Stable => vec![format!( + "{DIRECT_RELEASE_BASE}/releases/latest/download/{name}" + )], + UpdateChannel::Beta => beta_tag + .filter(|tag| valid_release_tag(tag)) + .map(|tag| { + vec![format!( + "{DIRECT_RELEASE_BASE}/releases/download/{tag}/{name}" + )] + }) + .unwrap_or_default(), + } +} + +fn valid_release_tag(tag: &str) -> bool { + !tag.is_empty() + && tag.starts_with('v') + && !tag.contains("..") + && tag + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || b".-_".contains(&byte)) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CheckReason { + Startup, + Periodic, + Manual, +} + +/// Pure elapsed-time scheduler. The UI owns the timer and calls `poll`; no +/// updater task can block an egui frame. +#[derive(Debug, Clone)] +pub struct UpdateSchedule { + startup_due: Duration, + periodic_due: Duration, + startup_pending: bool, +} + +impl UpdateSchedule { + pub fn new(now: Duration) -> Self { + Self { + startup_due: now.saturating_add(STARTUP_CHECK_DELAY), + periodic_due: now.saturating_add(PERIODIC_CHECK_INTERVAL), + startup_pending: true, + } + } + + pub fn poll(&mut self, now: Duration, manual: bool) -> Option { + if manual { + self.periodic_due = now.saturating_add(PERIODIC_CHECK_INTERVAL); + return Some(CheckReason::Manual); + } + if self.startup_pending && now >= self.startup_due { + self.startup_pending = false; + self.periodic_due = now.saturating_add(PERIODIC_CHECK_INTERVAL); + return Some(CheckReason::Startup); + } + if now >= self.periodic_due { + self.periodic_due = now.saturating_add(PERIODIC_CHECK_INTERVAL); + return Some(CheckReason::Periodic); + } + None + } +} + +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct UpdateManifest { + pub schema_version: u32, + pub host: String, + pub arch: String, + pub version: String, + pub url: String, + pub sha256: String, + pub minisign: Option, +} + +impl UpdateManifest { + pub fn parse(json: &[u8], expected_arch: &str) -> Result { + let manifest: Self = serde_json::from_slice(json).map_err(UpdateError::ManifestJson)?; + manifest.validate(expected_arch)?; + Ok(manifest) + } + + pub fn validate(&self, expected_arch: &str) -> Result<(), UpdateError> { + if self.schema_version != MANIFEST_SCHEMA_VERSION { + return Err(UpdateError::InvalidManifest(format!( + "unsupported updater schema version {}", + self.schema_version + ))); + } + if self.host != MANIFEST_HOST { + return Err(UpdateError::InvalidManifest(format!( + "manifest is for host {:?}, not {:?}", + self.host, MANIFEST_HOST + ))); + } + if self.arch != expected_arch { + return Err(UpdateError::InvalidManifest(format!( + "manifest architecture {:?} does not match {:?}", + self.arch, expected_arch + ))); + } + if self.version.trim().is_empty() + || self.version.contains(['\0', '\n', '\r']) + || self.url.chars().any(char::is_control) + { + return Err(UpdateError::InvalidManifest( + "manifest version or URL is empty/unsafe".into(), + )); + } + if !is_github_release_url(&self.url) { + return Err(UpdateError::InvalidManifest( + "artifact URL must be a GitHub HTTPS release asset".into(), + )); + } + if self.sha256.len() != 64 || !self.sha256.bytes().all(|byte| byte.is_ascii_hexdigit()) { + return Err(UpdateError::InvalidManifest( + "manifest SHA-256 must contain exactly 64 hexadecimal digits".into(), + )); + } + if self + .minisign + .as_deref() + .is_some_and(|signature| signature.trim().is_empty() || signature.contains('\0')) + { + return Err(UpdateError::InvalidManifest( + "manifest minisign value is empty or unsafe".into(), + )); + } + Ok(()) + } + + pub fn has_new_version(&self, current: &str) -> bool { + match ( + semver::Version::parse(normalize_version(&self.version)), + semver::Version::parse(normalize_version(current)), + ) { + (Ok(remote), Ok(current)) => remote > current, + _ => normalize_version(&self.version) != normalize_version(current), + } + } +} + +fn normalize_version(version: &str) -> &str { + version.trim().strip_prefix('v').unwrap_or(version.trim()) +} + +fn is_github_release_url(url: &str) -> bool { + let Some(rest) = url.strip_prefix("https://github.com/") else { + return false; + }; + let mut segments = rest.split('/'); + let owner = segments.next().unwrap_or_default(); + let repository = segments.next().unwrap_or_default(); + let releases = segments.next().unwrap_or_default(); + let download = segments.next().unwrap_or_default(); + let tag = segments.next().unwrap_or_default(); + let asset = segments.next().unwrap_or_default(); + !owner.is_empty() + && !repository.is_empty() + && releases == "releases" + && download == "download" + && !tag.is_empty() + && !asset.is_empty() + && segments.next().is_none() +} + +#[derive(Debug)] +pub enum UpdateError { + ManifestJson(serde_json::Error), + InvalidManifest(String), + NotAppImage(String), + MissingSignature, + SignatureUnavailable(String), + SignatureRejected(String), + TooLarge { + limit: u64, + }, + ChecksumMismatch { + expected: String, + actual: String, + }, + Io { + operation: &'static str, + source: io::Error, + }, + Http(String), +} + +impl fmt::Display for UpdateError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::ManifestJson(error) => write!(f, "invalid updater manifest JSON: {error}"), + Self::InvalidManifest(message) => write!(f, "invalid updater manifest: {message}"), + Self::NotAppImage(message) => write!(f, "AppImage update unavailable: {message}"), + Self::MissingSignature => f.write_str("update manifest has no minisign signature"), + Self::SignatureUnavailable(message) => { + write!(f, "minisign verification is unavailable: {message}") + } + Self::SignatureRejected(message) => { + write!(f, "minisign verification rejected the update: {message}") + } + Self::TooLarge { limit } => write!(f, "AppImage exceeds the {limit}-byte limit"), + Self::ChecksumMismatch { expected, actual } => { + write!( + f, + "AppImage SHA-256 mismatch: expected {expected}, got {actual}" + ) + } + Self::Io { operation, source } => write!(f, "{operation}: {source}"), + Self::Http(message) => write!(f, "update request failed: {message}"), + } + } +} + +impl std::error::Error for UpdateError {} + +fn io_error(operation: &'static str, source: io::Error) -> UpdateError { + UpdateError::Io { operation, source } +} + +/// Signature verification seam. The verifier must validate the complete +/// minisign file stored as base64 in the release manifest against a pinned +/// public key. +pub trait SignatureVerifier { + fn verify_base64_minisign( + &self, + artifact: &Path, + encoded_signature: &str, + ) -> Result<(), UpdateError>; +} + +/// Honest placeholder used until `minisign-verify` and a pinned public key are +/// added to this crate. It always rejects signed updates. +#[derive(Debug, Default, Clone, Copy)] +pub struct UnavailableSignatureVerifier; + +impl SignatureVerifier for UnavailableSignatureVerifier { + fn verify_base64_minisign( + &self, + _artifact: &Path, + _encoded_signature: &str, + ) -> Result<(), UpdateError> { + Err(UpdateError::SignatureUnavailable( + "the Linux host was built without a minisign verifier".into(), + )) + } +} + +#[derive(Debug, Clone)] +pub struct PinnedMinisignVerifier { + public_key: PublicKey, +} + +impl PinnedMinisignVerifier { + pub fn new() -> Result { + let public_key_file = base64::engine::general_purpose::STANDARD + .decode(PINNED_MINISIGN_PUBLIC_KEY) + .map_err(|error| UpdateError::SignatureUnavailable(error.to_string()))?; + let public_key_file = std::str::from_utf8(&public_key_file) + .map_err(|error| UpdateError::SignatureUnavailable(error.to_string()))?; + let public_key = PublicKey::decode(public_key_file) + .map_err(|error| UpdateError::SignatureUnavailable(error.to_string()))?; + Ok(Self { public_key }) + } +} + +impl SignatureVerifier for PinnedMinisignVerifier { + fn verify_base64_minisign( + &self, + artifact: &Path, + encoded_signature: &str, + ) -> Result<(), UpdateError> { + let signature_file = base64::engine::general_purpose::STANDARD + .decode(encoded_signature.trim()) + .map_err(|error| UpdateError::SignatureRejected(error.to_string()))?; + let signature_file = std::str::from_utf8(&signature_file) + .map_err(|error| UpdateError::SignatureRejected(error.to_string()))?; + let signature = Signature::decode(signature_file) + .map_err(|error| UpdateError::SignatureRejected(error.to_string()))?; + let mut input = File::open(artifact) + .map_err(|error| io_error("open AppImage for signature verification", error))?; + let mut verifier = self + .public_key + .verify_stream(&signature) + .map_err(|error| UpdateError::SignatureRejected(error.to_string()))?; + let mut buffer = [0u8; 64 * 1024]; + loop { + let read = input + .read(&mut buffer) + .map_err(|error| io_error("read AppImage for signature verification", error))?; + if read == 0 { + break; + } + verifier.update(&buffer[..read]); + } + verifier + .finalize() + .map_err(|error| UpdateError::SignatureRejected(error.to_string())) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct DownloadProgress { + pub downloaded: u64, + pub content_length: Option, +} + +/// Fully initialized AppImage updater. Construction fails for deb/rpm, +/// development binaries, malformed pinned keys, or HTTP client failures. +#[derive(Clone)] +pub struct AppImageUpdater { + client: reqwest::Client, + target: AppImageTarget, + verifier: PinnedMinisignVerifier, + expected_arch: &'static str, +} + +#[derive(Debug, Deserialize)] +struct GithubRelease { + tag_name: String, + prerelease: bool, + draft: bool, +} + +#[derive(Clone)] +pub enum LinuxUpdateSupport { + AppImage(AppImageUpdater), + /// deb/rpm and development builds must leave package replacement to their + /// package manager and only offer the upstream releases page. + ManualOnly { + releases_url: &'static str, + }, +} + +impl LinuxUpdateSupport { + pub fn initialize(package_kind: crate::LinuxPackageKind) -> Self { + if package_kind == crate::LinuxPackageKind::AppImage { + if let Ok(updater) = AppImageUpdater::initialize() { + return Self::AppImage(updater); + } + } + Self::ManualOnly { + releases_url: RELEASES_URL, + } + } + + pub fn supports_auto_update(&self) -> bool { + matches!(self, Self::AppImage(_)) + } + + pub fn manual_download_url(&self) -> &'static str { + RELEASES_URL + } +} + +impl AppImageUpdater { + pub fn initialize() -> Result { + let target = AppImageTarget::detect()?; + let verifier = PinnedMinisignVerifier::new()?; + let client = reqwest::Client::builder() + .https_only(true) + .timeout(Duration::from_secs(30)) + .user_agent(concat!("OpenLess-Linux/", env!("CARGO_PKG_VERSION"))) + .build() + .map_err(|error| UpdateError::Http(error.to_string()))?; + Ok(Self { + client, + target, + verifier, + expected_arch: std::env::consts::ARCH, + }) + } + + pub fn target(&self) -> &AppImageTarget { + &self.target + } + + pub async fn check( + &self, + channel: UpdateChannel, + ) -> Result, UpdateError> { + let beta_tag = match channel { + UpdateChannel::Stable => None, + UpdateChannel::Beta => Some(self.latest_beta_tag().await?), + }; + let urls = manifest_urls(channel, self.expected_arch, beta_tag.as_deref()); + if urls.is_empty() { + return Err(UpdateError::InvalidManifest( + "a valid beta release tag is required".into(), + )); + } + let mut last_error = None; + for url in urls { + let result = async { + let response = self + .client + .get(&url) + .send() + .await + .map_err(|error| UpdateError::Http(error.to_string()))? + .error_for_status() + .map_err(|error| UpdateError::Http(error.to_string()))?; + let bytes = response_bytes_limited(response, DEFAULT_MAX_MANIFEST_BYTES).await?; + UpdateManifest::parse(&bytes, self.expected_arch) + } + .await; + match result { + Ok(manifest) => { + return Ok(manifest + .has_new_version(env!("CARGO_PKG_VERSION")) + .then_some(manifest)); + } + Err(error) => last_error = Some(error), + } + } + Err(last_error.unwrap_or_else(|| UpdateError::Http("no manifest URL available".into()))) + } + + async fn latest_beta_tag(&self) -> Result { + let response = self + .client + .get(BETA_RELEASES_API) + .send() + .await + .map_err(|error| UpdateError::Http(error.to_string()))? + .error_for_status() + .map_err(|error| UpdateError::Http(error.to_string()))?; + let bytes = response_bytes_limited(response, DEFAULT_MAX_MANIFEST_BYTES).await?; + let releases: Vec = serde_json::from_slice(&bytes) + .map_err(|error| UpdateError::InvalidManifest(error.to_string()))?; + releases + .into_iter() + .find(|release| { + !release.draft && release.prerelease && valid_release_tag(&release.tag_name) + }) + .map(|release| release.tag_name) + .ok_or_else(|| UpdateError::InvalidManifest("no beta release is available".into())) + } + + pub async fn download_and_install( + &self, + manifest: UpdateManifest, + mut progress: impl FnMut(DownloadProgress), + ) -> Result { + manifest.validate(self.expected_arch)?; + let response = self + .client + .get(&manifest.url) + .send() + .await + .map_err(|error| UpdateError::Http(error.to_string()))? + .error_for_status() + .map_err(|error| UpdateError::Http(error.to_string()))?; + let total = response.content_length(); + if total.is_some_and(|length| length > DEFAULT_MAX_APPIMAGE_BYTES) { + return Err(UpdateError::TooLarge { + limit: DEFAULT_MAX_APPIMAGE_BYTES, + }); + } + let parent = self.target.path().parent().ok_or_else(|| { + UpdateError::NotAppImage("current AppImage has no parent directory".into()) + })?; + let download = parent.join(format!(".openless-download-{}", uuid::Uuid::new_v4())); + let result = async { + let mut output = OpenOptions::new() + .write(true) + .create_new(true) + .open(&download) + .map_err(|error| io_error("create AppImage download", error))?; + let mut downloaded = 0u64; + let mut stream = response.bytes_stream(); + while let Some(chunk) = stream.next().await { + let chunk = chunk.map_err(|error| UpdateError::Http(error.to_string()))?; + downloaded = + downloaded + .checked_add(chunk.len() as u64) + .ok_or(UpdateError::TooLarge { + limit: DEFAULT_MAX_APPIMAGE_BYTES, + })?; + if downloaded > DEFAULT_MAX_APPIMAGE_BYTES { + return Err(UpdateError::TooLarge { + limit: DEFAULT_MAX_APPIMAGE_BYTES, + }); + } + output + .write_all(&chunk) + .map_err(|error| io_error("write AppImage download", error))?; + progress(DownloadProgress { + downloaded, + content_length: total, + }); + } + output + .sync_all() + .map_err(|error| io_error("sync AppImage download", error))?; + drop(output); + let input = File::open(&download) + .map_err(|error| io_error("open completed AppImage download", error))?; + install_verified_appimage( + &manifest, + self.expected_arch, + input, + &self.target, + &self.verifier, + ) + } + .await; + let _ = fs::remove_file(download); + result + } +} + +async fn response_bytes_limited( + response: reqwest::Response, + limit: u64, +) -> Result, UpdateError> { + if response + .content_length() + .is_some_and(|length| length > limit) + { + return Err(UpdateError::TooLarge { limit }); + } + let mut bytes = Vec::new(); + let mut stream = response.bytes_stream(); + while let Some(chunk) = stream.next().await { + let chunk = chunk.map_err(|error| UpdateError::Http(error.to_string()))?; + if (bytes.len() as u64).saturating_add(chunk.len() as u64) > limit { + return Err(UpdateError::TooLarge { limit }); + } + bytes.extend_from_slice(&chunk); + } + Ok(bytes) +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AppImageTarget { + path: PathBuf, +} + +impl AppImageTarget { + pub fn detect() -> Result { + let path = std::env::var_os("APPIMAGE") + .filter(|value| !value.is_empty()) + .map(PathBuf::from) + .ok_or_else(|| UpdateError::NotAppImage("APPIMAGE is not set".into()))?; + Self::new(path) + } + + pub fn new(path: PathBuf) -> Result { + if !path.is_absolute() { + return Err(UpdateError::NotAppImage( + "APPIMAGE must be an absolute path".into(), + )); + } + let metadata = fs::symlink_metadata(&path) + .map_err(|error| io_error("inspect current AppImage", error))?; + if metadata.file_type().is_symlink() || !metadata.is_file() { + return Err(UpdateError::NotAppImage( + "current AppImage is not a regular, non-symlink file".into(), + )); + } + Ok(Self { path }) + } + + pub fn path(&self) -> &Path { + &self.path + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct InstalledUpdate { + pub path: PathBuf, + pub version: String, + pub bytes_written: u64, + pub sha256: String, +} + +/// Streams, hashes, signature-checks, and atomically replaces an AppImage. +/// Verification happens before rename, so every pre-commit failure leaves the +/// currently installed file untouched. +pub fn install_verified_appimage( + manifest: &UpdateManifest, + expected_arch: &str, + source: impl Read, + target: &AppImageTarget, + verifier: &dyn SignatureVerifier, +) -> Result { + install_verified_appimage_with_limit( + manifest, + expected_arch, + source, + target, + verifier, + DEFAULT_MAX_APPIMAGE_BYTES, + ) +} + +pub fn install_verified_appimage_with_limit( + manifest: &UpdateManifest, + expected_arch: &str, + mut source: impl Read, + target: &AppImageTarget, + verifier: &dyn SignatureVerifier, + max_bytes: u64, +) -> Result { + manifest.validate(expected_arch)?; + let signature = manifest + .minisign + .as_deref() + .ok_or(UpdateError::MissingSignature)?; + let parent = target.path.parent().ok_or_else(|| { + UpdateError::NotAppImage("current AppImage has no parent directory".into()) + })?; + let name = target + .path + .file_name() + .ok_or_else(|| UpdateError::NotAppImage("current AppImage has no filename".into()))?; + let temp = parent.join(format!( + ".{}.update-{}", + name.to_string_lossy(), + uuid::Uuid::new_v4() + )); + let result = (|| { + let mut output = OpenOptions::new() + .write(true) + .create_new(true) + .open(&temp) + .map_err(|error| io_error("create AppImage update file", error))?; + let mut digest = Sha256::new(); + let mut written = 0u64; + let mut buffer = [0u8; 64 * 1024]; + loop { + let read = source + .read(&mut buffer) + .map_err(|error| io_error("read AppImage download", error))?; + if read == 0 { + break; + } + written = written + .checked_add(read as u64) + .ok_or(UpdateError::TooLarge { limit: max_bytes })?; + if written > max_bytes { + return Err(UpdateError::TooLarge { limit: max_bytes }); + } + output + .write_all(&buffer[..read]) + .map_err(|error| io_error("write AppImage update file", error))?; + digest.update(&buffer[..read]); + } + output + .sync_all() + .map_err(|error| io_error("sync AppImage update file", error))?; + let actual = digest.finish_hex(); + if !actual.eq_ignore_ascii_case(&manifest.sha256) { + return Err(UpdateError::ChecksumMismatch { + expected: manifest.sha256.to_ascii_lowercase(), + actual, + }); + } + verifier.verify_base64_minisign(&temp, signature)?; + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let current_mode = fs::metadata(&target.path) + .map_err(|error| io_error("read current AppImage permissions", error))? + .permissions() + .mode(); + fs::set_permissions(&temp, fs::Permissions::from_mode(current_mode)) + .map_err(|error| io_error("set AppImage update permissions", error))?; + } + commit_with_rollback(&temp, &target.path)?; + Ok(InstalledUpdate { + path: target.path.clone(), + version: manifest.version.clone(), + bytes_written: written, + sha256: actual, + }) + })(); + if result.is_err() { + let _ = fs::remove_file(&temp); + } + result +} + +/// Keep a hard-linked copy of the old inode until the replacement and its +/// directory entry are durable. This permits rollback if the commit itself +/// fails without ever exposing a partially written AppImage. +fn commit_with_rollback(temp: &Path, target: &Path) -> Result<(), UpdateError> { + let parent = target.parent().ok_or_else(|| { + UpdateError::NotAppImage("current AppImage has no parent directory".into()) + })?; + let metadata = fs::symlink_metadata(target) + .map_err(|error| io_error("inspect current AppImage before commit", error))?; + if metadata.file_type().is_symlink() || !metadata.is_file() { + return Err(UpdateError::NotAppImage( + "current AppImage changed before update commit".into(), + )); + } + let name = target + .file_name() + .ok_or_else(|| UpdateError::NotAppImage("current AppImage has no filename".into()))?; + let backup = parent.join(format!( + ".{}.rollback-{}", + name.to_string_lossy(), + uuid::Uuid::new_v4() + )); + fs::hard_link(target, &backup) + .map_err(|error| io_error("prepare AppImage rollback link", error))?; + + if let Err(error) = fs::rename(temp, target) { + let _ = fs::remove_file(&backup); + return Err(io_error("atomically replace AppImage", error)); + } + if let Err(error) = File::open(parent).and_then(|directory| directory.sync_all()) { + let _ = fs::rename(&backup, target); + let _ = File::open(parent).and_then(|directory| directory.sync_all()); + return Err(io_error("sync AppImage directory", error)); + } + if let Err(error) = fs::remove_file(&backup) { + // The rollback inode still exists, so restore it before reporting the + // cleanup failure. A failed restoration is intentionally not hidden. + return match fs::rename(&backup, target) { + Ok(()) => Err(io_error("remove AppImage rollback link", error)), + Err(rollback_error) => Err(io_error("restore AppImage rollback link", rollback_error)), + }; + } + Ok(()) +} + +// Small self-contained SHA-256 implementation. This avoids pretending the +// updater is functional while waiting for a direct `sha2` dependency. +struct Sha256 { + state: [u32; 8], + block: [u8; 64], + block_len: usize, + total_len: u64, +} + +impl Sha256 { + fn new() -> Self { + Self { + state: [ + 0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, + 0x5be0cd19, + ], + block: [0; 64], + block_len: 0, + total_len: 0, + } + } + + fn update(&mut self, mut bytes: &[u8]) { + self.total_len = self.total_len.wrapping_add(bytes.len() as u64); + if self.block_len != 0 { + let take = (64 - self.block_len).min(bytes.len()); + self.block[self.block_len..self.block_len + take].copy_from_slice(&bytes[..take]); + self.block_len += take; + bytes = &bytes[take..]; + if self.block_len == 64 { + let block = self.block; + self.compress(&block); + self.block_len = 0; + } else { + return; + } + } + while bytes.len() >= 64 { + let block: &[u8; 64] = bytes[..64].try_into().expect("slice has exact block size"); + self.compress(block); + bytes = &bytes[64..]; + } + self.block[..bytes.len()].copy_from_slice(bytes); + self.block_len = bytes.len(); + } + + fn finish_hex(mut self) -> String { + let bit_len = self.total_len.wrapping_mul(8); + self.block[self.block_len] = 0x80; + self.block_len += 1; + if self.block_len > 56 { + self.block[self.block_len..].fill(0); + let block = self.block; + self.compress(&block); + self.block_len = 0; + } + self.block[self.block_len..56].fill(0); + self.block[56..].copy_from_slice(&bit_len.to_be_bytes()); + let block = self.block; + self.compress(&block); + let mut result = String::with_capacity(64); + for word in self.state { + use fmt::Write as _; + write!(&mut result, "{word:08x}").expect("formatting into String cannot fail"); + } + result + } + + fn compress(&mut self, block: &[u8; 64]) { + const K: [u32; 64] = [ + 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, + 0xab1c5ed5, 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, + 0x9bdc06a7, 0xc19bf174, 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, + 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, + 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, + 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, 0xa2bfe8a1, 0xa81a664b, + 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, 0x19a4c116, + 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3, + 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, + 0xc67178f2, + ]; + let mut w = [0u32; 64]; + for (index, chunk) in block.chunks_exact(4).take(16).enumerate() { + w[index] = u32::from_be_bytes(chunk.try_into().expect("four-byte SHA word")); + } + for index in 16..64 { + let s0 = w[index - 15].rotate_right(7) + ^ w[index - 15].rotate_right(18) + ^ (w[index - 15] >> 3); + let s1 = w[index - 2].rotate_right(17) + ^ w[index - 2].rotate_right(19) + ^ (w[index - 2] >> 10); + w[index] = w[index - 16] + .wrapping_add(s0) + .wrapping_add(w[index - 7]) + .wrapping_add(s1); + } + let [mut a, mut b, mut c, mut d, mut e, mut f, mut g, mut h] = self.state; + for index in 0..64 { + let s1 = e.rotate_right(6) ^ e.rotate_right(11) ^ e.rotate_right(25); + let choose = (e & f) ^ ((!e) & g); + let t1 = h + .wrapping_add(s1) + .wrapping_add(choose) + .wrapping_add(K[index]) + .wrapping_add(w[index]); + let s0 = a.rotate_right(2) ^ a.rotate_right(13) ^ a.rotate_right(22); + let majority = (a & b) ^ (a & c) ^ (b & c); + let t2 = s0.wrapping_add(majority); + h = g; + g = f; + f = e; + e = d.wrapping_add(t1); + d = c; + c = b; + b = a; + a = t1.wrapping_add(t2); + } + for (state, value) in self.state.iter_mut().zip([a, b, c, d, e, f, g, h]) { + *state = state.wrapping_add(value); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::{AtomicU64, Ordering}; + + static NEXT_TEMP: AtomicU64 = AtomicU64::new(0); + + fn temp_dir(name: &str) -> PathBuf { + let path = std::env::temp_dir().join(format!( + "openless-updater-{name}-{}-{}", + std::process::id(), + NEXT_TEMP.fetch_add(1, Ordering::Relaxed) + )); + fs::create_dir_all(&path).unwrap(); + path + } + + fn manifest(body: &[u8]) -> UpdateManifest { + let mut digest = Sha256::new(); + digest.update(body); + UpdateManifest { + schema_version: 1, + host: MANIFEST_HOST.into(), + arch: "x86_64".into(), + version: "2.0.0".into(), + url: "https://github.com/Open-Less/openless/releases/download/v2.0.0/OpenLess.AppImage" + .into(), + sha256: digest.finish_hex(), + minisign: Some("dGVzdC1taW5pc2lnbg==".into()), + } + } + + struct AcceptTestSignature; + + impl SignatureVerifier for AcceptTestSignature { + fn verify_base64_minisign( + &self, + artifact: &Path, + encoded_signature: &str, + ) -> Result<(), UpdateError> { + if encoded_signature != "dGVzdC1taW5pc2lnbg==" || fs::metadata(artifact).is_err() { + return Err(UpdateError::SignatureRejected( + "test signature mismatch".into(), + )); + } + Ok(()) + } + } + + #[test] + fn sha256_matches_standard_vectors_and_streaming() { + let mut empty = Sha256::new(); + empty.update(b""); + assert_eq!( + empty.finish_hex(), + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + ); + let mut abc = Sha256::new(); + abc.update(b"a"); + abc.update(b"bc"); + assert_eq!( + abc.finish_hex(), + "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad" + ); + } + + #[test] + fn manifest_parser_enforces_host_arch_hash_and_release_url() { + let valid = serde_json::to_vec(&serde_json::json!({ + "schemaVersion": 1, + "host": "linux-egui", + "arch": "x86_64", + "version": "2.0.0", + "url": "https://github.com/Open-Less/openless/releases/download/v2.0.0/OpenLess.AppImage", + "sha256": "0".repeat(64), + "minisign": "c2ln" + })).unwrap(); + assert!(UpdateManifest::parse(&valid, "x86_64").is_ok()); + assert!(UpdateManifest::parse(&valid, "aarch64").is_err()); + let mut bad_url: serde_json::Value = serde_json::from_slice(&valid).unwrap(); + bad_url["url"] = "http://example.test/update".into(); + assert!(UpdateManifest::parse(&serde_json::to_vec(&bad_url).unwrap(), "x86_64").is_err()); + } + + #[test] + fn update_urls_use_only_upstream_https_release_assets() { + assert_eq!( + manifest_urls(UpdateChannel::Stable, "x86_64", None), + vec![format!( + "{DIRECT_RELEASE_BASE}/releases/latest/download/latest-linux-egui-x86_64.json" + )] + ); + assert!(manifest_urls(UpdateChannel::Beta, "x86_64", Some("../bad")).is_empty()); + assert_eq!( + manifest_urls(UpdateChannel::Beta, "x86_64", Some("v2.0.0-beta.1")).len(), + 1 + ); + } + + #[test] + fn pinned_release_key_decodes() { + PinnedMinisignVerifier::new().expect("repository updater key must remain valid"); + } + + #[test] + fn verified_update_atomically_replaces_appimage() { + let root = temp_dir("success"); + let path = root.join("OpenLess.AppImage"); + fs::write(&path, b"old image").unwrap(); + let target = AppImageTarget::new(path.clone()).unwrap(); + let body = b"new verified appimage"; + let installed = install_verified_appimage( + &manifest(body), + "x86_64", + body.as_slice(), + &target, + &AcceptTestSignature, + ) + .unwrap(); + assert_eq!(installed.bytes_written, body.len() as u64); + assert_eq!(fs::read(&path).unwrap(), body); + assert_eq!(fs::read_dir(&root).unwrap().count(), 1); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn checksum_signature_and_size_failures_preserve_current_appimage() { + for failure in ["checksum", "signature", "size"] { + let root = temp_dir(failure); + let path = root.join("OpenLess.AppImage"); + fs::write(&path, b"old image").unwrap(); + let target = AppImageTarget::new(path.clone()).unwrap(); + let body = b"new image"; + let mut candidate = manifest(body); + let result = match failure { + "checksum" => { + candidate.sha256 = "0".repeat(64); + install_verified_appimage( + &candidate, + "x86_64", + body.as_slice(), + &target, + &AcceptTestSignature, + ) + } + "signature" => install_verified_appimage( + &candidate, + "x86_64", + body.as_slice(), + &target, + &UnavailableSignatureVerifier, + ), + "size" => install_verified_appimage_with_limit( + &candidate, + "x86_64", + body.as_slice(), + &target, + &AcceptTestSignature, + 3, + ), + _ => unreachable!(), + }; + assert!(result.is_err()); + assert_eq!(fs::read(&path).unwrap(), b"old image"); + assert_eq!(fs::read_dir(&root).unwrap().count(), 1); + fs::remove_dir_all(root).unwrap(); + } + } + + #[test] + fn unsigned_update_is_rejected() { + let root = temp_dir("unsigned"); + let path = root.join("OpenLess.AppImage"); + fs::write(&path, b"old").unwrap(); + let target = AppImageTarget::new(path.clone()).unwrap(); + let mut candidate = manifest(b"new"); + candidate.minisign = None; + assert!(matches!( + install_verified_appimage( + &candidate, + "x86_64", + b"new".as_slice(), + &target, + &AcceptTestSignature + ), + Err(UpdateError::MissingSignature) + )); + assert_eq!(fs::read(&path).unwrap(), b"old"); + fs::remove_dir_all(root).unwrap(); + } +} diff --git a/openless-all/app/linux-egui/tests/host_contract.rs b/openless-all/app/linux-egui/tests/host_contract.rs index adcfb5e6c..909337fdd 100644 --- a/openless-all/app/linux-egui/tests/host_contract.rs +++ b/openless-all/app/linux-egui/tests/host_contract.rs @@ -283,6 +283,7 @@ async fn forwarded_launch_intents_use_core_state_and_semantic_host_actions() { struct RecordingSettingsEffects { hotkeys: Mutex>, active_asr_providers: Mutex>, + launch_at_login: Mutex>, fail_next_hotkey: std::sync::atomic::AtomicBool, } @@ -314,6 +315,11 @@ impl LinuxSettingsEffects for RecordingSettingsEffects { .push(provider_id.to_string()); Ok(()) } + + fn set_launch_at_login(&self, enabled: bool) -> Result<(), openless_linux_egui::BackendError> { + self.launch_at_login.lock().unwrap().push(enabled); + Ok(()) + } } #[test] @@ -381,6 +387,7 @@ fn linux_public_settings_contract_is_validated_transactional_and_runtime_backed( primary: "F9".to_string(), modifiers: vec!["ctrl".to_string()], }; + runtime_failure.launch_at_login = true; let error = host .update_settings_strict(runtime_failure, revision) .expect_err("Linux runtime failure must fail the settings transaction"); @@ -395,6 +402,11 @@ fn linux_public_settings_contract_is_validated_transactional_and_runtime_backed( assert_eq!(applied.len(), 3, "next apply plus previous-target restore"); assert_eq!(applied.last().unwrap().dictation, saved.dictation_hotkey); drop(applied); + assert_eq!( + effects.launch_at_login.lock().unwrap().as_slice(), + [true, false], + "a later commit failure must restore the previous launch-at-login state" + ); let mut provider_change = backend.get_preferences(); provider_change.active_asr_provider = "linux-fixture-asr".to_string(); diff --git a/openless-all/app/linux-egui/tests/localization_contract.rs b/openless-all/app/linux-egui/tests/localization_contract.rs new file mode 100644 index 000000000..f2008922f --- /dev/null +++ b/openless-all/app/linux-egui/tests/localization_contract.rs @@ -0,0 +1,146 @@ +//! Localization regression contract (Tauri-free). +//! +//! Guards against *new* raw user-visible Simplified-Chinese string literals +//! sneaking into the Linux egui source outside the translation catalog +//! (`src/i18n.rs`, the one legitimate home for zh-CN source-of-truth text). +//! +//! The `src/ui/shell.rs` module is fully localized, so it is held to a strict +//! zero-CJK rule. Elsewhere, any Simplified-Chinese literal that is not in the +//! checked-in `zh_user_visible_baseline.txt` fails the build; that baseline is +//! refreshed deliberately (see the file header) when a string is intentionally +//! translated or knowingly kept for migration. + +use std::collections::BTreeSet; +use std::path::{Path, PathBuf}; + +fn strip_comments(source: &str) -> Vec { + let bytes = source.as_bytes(); + let mut out = Vec::with_capacity(bytes.len()); + let mut i = 0; + let n = bytes.len(); + while i < n { + if bytes[i] == b'/' && i + 1 < n && bytes[i + 1] == b'*' { + // Block / doc-block comment. + i += 2; + while i + 1 < n && !(bytes[i] == b'*' && bytes[i + 1] == b'/') { + i += 1; + } + i = (i + 2).min(n); + continue; + } + if bytes[i] == b'/' && i + 1 < n && bytes[i + 1] == b'/' { + // Line / line-doc comment. + while i < n && bytes[i] != b'\n' { + i += 1; + } + continue; + } + out.push(bytes[i]); + i += 1; + } + out +} + +/// Collect the inner text of every `"..."` string literal (handling `\"` and +/// `\\` escapes) whose content contains any CJK Unified Ideograph. +fn cjk_string_literals(source: &str) -> BTreeSet { + let cleaned = strip_comments(source); + let n = cleaned.len(); + let mut found = BTreeSet::new(); + let mut i = 0; + while i < n { + if cleaned[i] != b'"' { + i += 1; + continue; + } + // Inside a string literal: read until an unescaped closing quote. + let mut inner = Vec::new(); + let mut j = i + 1; + while j < n { + if cleaned[j] == b'\\' && j + 1 < n { + inner.push(cleaned[j]); + inner.push(cleaned[j + 1]); + j += 2; + continue; + } + if cleaned[j] == b'"' { + break; + } + inner.push(cleaned[j]); + j += 1; + } + if let Ok(text) = String::from_utf8(inner) { + if text.chars().any(|c| ('\u{4e00}'..='\u{9fff}').contains(&c)) { + found.insert(text); + } + } + i = j + 1; // resume after the closing quote + } + found +} + +fn source_files_under(root: &Path) -> Vec { + let mut files = Vec::new(); + for entry in std::fs::read_dir(root).unwrap() { + let entry = entry.unwrap(); + let path = entry.path(); + if path.is_dir() { + files.extend(source_files_under(&path)); + } else if path.extension().is_some_and(|ext| ext == "rs") { + files.push(path); + } + } + files +} + +fn crate_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).to_path_buf() +} + +#[test] +fn shell_module_is_fully_localized_with_no_raw_simplified_chinese() { + let shell = std::fs::read_to_string(crate_root().join("src/ui/shell.rs")).unwrap(); + let cleaned = String::from_utf8(strip_comments(&shell)).unwrap(); + let has_cjk = cleaned + .chars() + .any(|c| ('\u{4e00}'..='\u{9fff}').contains(&c)); + assert!( + !has_cjk, + "src/ui/shell.rs must be fully localized (no raw Simplified-Chinese text)" + ); +} + +#[test] +fn no_new_raw_simplified_chinese_user_visible_literals_outside_catalog_or_baseline() { + let root = crate_root().join("src"); + // The translation catalog is the one sanctioned home for zh-CN text. + let mut current: BTreeSet = BTreeSet::new(); + for file in source_files_under(&root) { + if file.strip_prefix(&root).unwrap().starts_with("i18n.rs") { + continue; + } + let source = std::fs::read_to_string(&file).unwrap(); + current.extend(cjk_string_literals(&source)); + } + + let baseline_path = crate_root().join("tests/zh_user_visible_baseline.txt"); + let baseline_raw = std::fs::read_to_string(&baseline_path).unwrap(); + let baseline: BTreeSet = baseline_raw + .lines() + .map(str::to_string) + .filter(|line| !line.trim().is_empty()) + .collect(); + + let added: Vec<&String> = current.difference(&baseline).collect(); + assert!( + added.is_empty(), + "New raw Simplified-Chinese user-visible literal(s) were added to the Linux egui UI \ + outside the localization catalog. Translate them in src/i18n.rs and, if one is \ + deliberately dynamic/error text, refresh the baseline file. Found:\n{}", + added + .iter() + .map(|s| format!(" - {s:?}")) + .collect::>() + .join("\n") + ); +} diff --git a/openless-all/app/linux-egui/tests/zh_user_visible_baseline.txt b/openless-all/app/linux-egui/tests/zh_user_visible_baseline.txt new file mode 100644 index 000000000..00dce3fce --- /dev/null +++ b/openless-all/app/linux-egui/tests/zh_user_visible_baseline.txt @@ -0,0 +1,265 @@ +# Localization strict allowlist (do NOT grow this silently). +# +# Every Simplified-Chinese string literal that lives outside src/i18n.rs must be +# listed here AND be one of the genuinely non-UI / protocol / test literals below. +# If a string is user-visible UI chrome or a control label, translate it in +# src/i18n.rs instead of adding it here. + 自动 +# 角色\n你是 OpenLess 的{}助手。\n\n# 任务\n把输入整理成自然、清晰、可直接使用的文字。\n\n# 输出\n只输出最终文本,不添加解释。\n +# 角色\n你是 OpenLess 的润色助手。\n\n# 任务\n把输入整理成自然、清晰、可直接使用的文字。\n\n# 输出\n只输出最终文本,不添加解释。\n +10月 +11月 +12月 +1月 +2月 +3月 +4月 +5月 +6月 +7月 +8月 +9月 +AI 提供商 +ASR 服务 +ASR 语音 +LLM 服务 +LLM 模型 +OpenAI 兼容 +OpenLess fcitx5 插件 +session-一 +session-二 +{} 个风格包 +{} 天 · {} 天活跃 +{} 段 +↻ 刷新 +▣ 导入 ZIP +● 已配置 +下载 ZIP +不启用(Shift 按下不触发翻译) +主题 +产品与平台 +今天第一句 +今日字数 +今日总时长 +今日概览 +今日第一句 has 5 chars +从模板开始创建自己的风格 +他说:\"你好\" C:\\\\tmp\\\\文件 +使用方法 +保存 +保存历史 +修改提示词后保存,下一次润色将使用新的规则。双击风格卡即可打开此编辑器。 +停止播放 +全局录音的快捷键与触发方式。 +全部 +全部删除 +共 {} 条记录 +关于 +内置 +内置麦克风 +再次按右 Option 停止录音。 +切换式 +切换风格 +划词追问 +创建预设 +删除 +刷新 +前天 +历史 +历史记录 +历史记录暂未接线 +原文 +原文 \\\\ source +原文,例如:{num}粒 +原样保留 +发布日志 +发布日志将在 egui 外链桥接完成后打开 +取消 +可继续按 右Ctrl 多轮追问。 +右 Option +启动 +启动失败: {error} +启动时最小化 +启用远程输入 +周一 +周三 +周二 +周五 +周六 +周四 +周日 +唤起 OpenLess +在任意 app 选中文字。 +堆叠设置行 +复制 +复制失败: {error} +外接麦克风 +外观与概览页显示选项。 +多 +失败 +安装到本地 +导出 +导出录音 +将识别结果中的常见错误自动替换为正确写法。 +少 +工作语言 +工具 +已切换到「{}」 +已切换到「原文」 +已发送 +已启用 +已复制 +已恢复默认提示词(演示) +已插入 +市场后端桥接将在后续阶段完成 +布局 +帮助中心 +帮助中心将在 egui 外链桥接完成后打开 +平均段落 +年度活动 +应用 +开发工具 +开始/停止、翻译、问答和风格切换。 +当前 +当前提供商 +录音 {} +录音与输入 +录音快捷键 +录音方式 +录音时静音 +录音过程中按翻译快捷键切换到翻译模式。 +快捷键 +恢复剪贴板 +恢复默认 +我的发布 +我赞过的 +技术术语 +按 Esc 关闭浮窗并清空历史。 +按 ⌘⇧; 打开浮窗。 +按 右Ctrl 录音,再按一次提交。 +按住说话 +按右 Option 开始录音。 +探索社区风格包 +控制 OpenLess 启动时的行为。 +插入 +插入与剪贴板 +搜索转写内容… +搜索风格包 +播放录音 +数据桥接将在后续阶段完成 +新建风格包 +新预设 +新风格包 +日本語 +昨天 +是 +显示活动热力图 +暂无历史记录 +暂无数据 +暂无纠错规则 +暂无记录 +暂时没有找到风格包 +替换为 +最新 +最近记录 +服务 +服务地址 +未启用 +未找到 OpenLess fcitx5 插件;请重新安装当前软件包 +未请求 +未配置 +本地占位预览\n将原始表达保留在上下文中,优化语气、结构和可读性。\n这段内容会由真实风格包提示词替换。 +本地服务 +本地风格包 +本机保存的识别记录。 +本机存档 +松开按键后,译文会自动插入当前应用。 +概览 +模拟粘贴快捷键 +正在加载概览数据… +正在加载风格市场… +正在播放录音… +正式表达 +此页面暂未接线 +流式插入 +流式结果保存剪贴板 +浅色 +浏览和切换风格包。 +润色 +润色提示词 +润色模式 +润色结果 +深色 +添加 +添加快捷键 +添加需要优先识别的自定义词汇。 +清晰结构 +清空 +激活 +热门 +版本 {} +界面语言 +百炼 +监听端口 +目标语言与唯一工作语言相同,按翻译快捷键不会触发翻译。 +简体中文 +简短描述这个风格的使用场景 +系统默认 +紧凑布局 +累计记录 +繁体中文 +纠错规则 +编辑 +编辑 {} +编辑风格包 +翻译 +翻译模式会在胶囊顶部显示状态。 +翻译目标语言 +翻译风格 +自动插入光标位置 +自动收集 ({learned}) +自动继承“风格”页当前激活的风格包。 +自定义热词,提升专有名词识别率 +英文写作 +让浮层和内容更适合你的工作方式。 +设置 +识别 +识别结果如何回到当前光标位置。 +词汇 +词汇表 +词汇,用逗号或换行分隔 +试试其他关键词或筛选条件 +语言 +语音润色 +请选择一条记录 +跟随系统 +轻度润色 +输入词汇,按回车添加 +近 30 天 +近 7 天 +近期活动 +远程输入 +选区润色 +选择 OpenLess 使用的界面语言。 +选择一组常用词汇快速添加。 +选择润色风格,让每次输出都保持一致 +通用 +通过局域网接收来自其他设备的输入。 +配置语音识别、润色与翻译所使用的服务。 +隐私 +静音:否 +静音:是 +预设 +预设名称 +风格 +风格包列表已刷新 +风格市场 +风格市场暂未接线 +风格描述 +风格直达 +高级 +麦克风 +默认模式 +(无文字) +(语音问题) ++ 添加 diff --git a/openless-all/app/scripts/linux-egui-release-contract.test.mjs b/openless-all/app/scripts/linux-egui-release-contract.test.mjs new file mode 100644 index 000000000..d9d5a5293 --- /dev/null +++ b/openless-all/app/scripts/linux-egui-release-contract.test.mjs @@ -0,0 +1,106 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const repoRoot = fileURLToPath(new URL('../../..', import.meta.url)); +const releaseWorkflow = await readFile( + join(repoRoot, '.github/workflows/release-linux-egui.yml'), + 'utf8', +); +const ciWorkflow = await readFile( + join(repoRoot, '.github/workflows/ci.yml'), + 'utf8', +); +const packageScript = await readFile( + join(repoRoot, 'openless-all/app/scripts/package-linux-egui.sh'), + 'utf8', +); +const dependencyGate = await readFile( + join(repoRoot, 'openless-all/app/scripts/check-core-deps.ps1'), + 'utf8', +); + +// 1. CI must exercise the Tauri-free Linux host: build, test, clippy and a +// dependency contract on openless-linux-egui with no WebKit/GTK native deps. +assert.ok(ciWorkflow.includes('cargo test --locked -p openless-linux-egui --all-targets'), + 'CI must test the Linux egui host'); +assert.ok(ciWorkflow.includes('cargo check --locked -p openless-linux-egui --all-targets'), + 'CI must check the Linux egui host'); +assert.ok(ciWorkflow.includes('./scripts/check-core-deps.ps1 openless-linux-egui'), + 'CI must run the cargo-tree dependency gate for the Linux egui host'); +assert.ok(!/libgtk-3|webkit2gtk|libwebkit/.test(ciWorkflow), + 'CI apt list must not pull WebKitGTK/GTK native dependencies'); + +// 2. The Linux dependency gate must forbid Tauri/Wry/WebKit at the cargo-tree +// level while still allowing egui/eframe for the host itself. +assert.ok(dependencyGate.includes('cargo tree --locked'), + 'dependency gate must run cargo tree'); +assert.ok(dependencyGate.includes('openless-linux-egui'), + 'dependency gate must accept the Linux egui host package name'); +assert.ok(/tauri\|wry\|webkit2gtk/.test(dependencyGate), + 'dependency gate must forbid tauri/wry/webkit2gtk'); + +// 3. Release must build x86_64 deb and rpm only; AppImage is intentionally +// retired from the Linux distribution channel. +for (const [format, tool] of [['deb', 'dpkg-deb --build'], ['rpm', 'rpmbuild --define']]) { + assert.ok(packageScript.includes(tool), `package script must build ${format} via ${tool}`); +} +assert.ok(releaseWorkflow.includes("test \"$(find \"$OUTPUT\" -maxdepth 1 -name '*.deb' | wc -l)\" -eq 1"), + 'release must gate exactly one deb'); +assert.ok(releaseWorkflow.includes("test \"$(find \"$OUTPUT\" -maxdepth 1 -name '*.rpm' | wc -l)\" -eq 1"), + 'release must gate exactly one rpm'); +assert.ok(!/appimagetool|AppImage|APPIMAGE/i.test(packageScript), + 'Linux package script must not build or stage AppImage'); + +// 4. deb/rpm must carry the host and fcitx5 addon. Qwen ASR is +// deliberately excluded from Linux packages and must never enter this flow. +assert.ok(releaseWorkflow.includes("! ldd target/release/openless-linux-egui | grep -q 'not found'"), + 'release must run an ldd gate on the host binary'); +assert.ok(/! ldd .*grep -Eqi 'webkit\|wry\|tauri'/.test(releaseWorkflow), + 'release must assert the host binary does not link WebKit/Wry/Tauri'); +assert.ok(releaseWorkflow.includes("dpkg-deb -c \"$OUTPUT\"/*.deb | grep -q 'usr/bin/openless'"), + 'deb must ship the host binary'); +assert.ok(releaseWorkflow.includes("dpkg-deb -c \"$OUTPUT\"/*.deb | grep -q 'fcitx5/libopenless.so'"), + 'deb must ship the fcitx5 addon'); +assert.ok(releaseWorkflow.includes("rpm -qlp \"$OUTPUT\"/*.rpm | grep -q '/usr/bin/openless'"), + 'rpm must ship the host binary'); +assert.ok(releaseWorkflow.includes("rpm -qlp \"$OUTPUT\"/*.rpm | grep -q '/usr/lib64/fcitx5/libopenless.so'"), + 'rpm must ship the fcitx5 addon'); +assert.ok(!/qwen-asr|qwen_asr/i.test(releaseWorkflow), + 'Linux release must neither fetch nor compile nor package Qwen ASR'); +assert.ok(!/qwen-asr|qwen_asr/i.test(packageScript), + 'Linux packaging must not stage Qwen ASR'); + +// The packaging script must carry the fcitx plugin into both package formats. +assert.ok(packageScript.includes('x86_64-linux-gnu/fcitx5/libopenless.so'), 'deb fcitx addon path'); +assert.ok(packageScript.includes('/usr/lib64/fcitx5/libopenless.so'), 'rpm fcitx addon path'); +// 5. ldd + cargo-tree verification gates are required for release and CI. + +// 6. Release must emit and verify checksums for both package artifacts. +assert.ok(releaseWorkflow.includes('sha256sum'), 'release must compute sha256 checksums'); +assert.ok(releaseWorkflow.includes('> SHA256SUMS'), 'release must emit a SHA256SUMS artifact'); +assert.ok(releaseWorkflow.includes('sha256sum -c SHA256SUMS'), 'release must verify SHA256SUMS'); +assert.ok(releaseWorkflow.includes('sha256sum ./*.deb ./*.rpm'), + 'SHA256SUMS must cover deb and rpm only'); +assert.ok(!/appimagetool|APPIMAGE_|LINUX_EGUI_MINISIGN|latest-linux-egui/i.test(releaseWorkflow), + 'Linux release must not retain an AppImage updater or signing path'); + +// 8. Legacy "-tauri" release tags are accepted only for compatibility: the +// suffix is stripped when present and the flow still resolves its own version +// from cargo metadata when no release tag is supplied, so it never depends on +// the Tauri release pipeline or its tag scheme. +assert.ok(releaseWorkflow.includes('VERSION=${RELEASE_TAG#v}'), 'version must strip a leading v'); +assert.ok(releaseWorkflow.includes('VERSION=${VERSION%-tauri}'), 'legacy -tauri tag suffix must be tolerated'); +assert.ok(releaseWorkflow.includes('RELEASE_TAG:-}'), 'flow must run without a release tag'); +assert.ok(releaseWorkflow.includes("require('./package.json').version"), + 'flow must resolve its version from the main app package when no tag is provided'); +assert.ok(!releaseWorkflow.includes('-tauri required') && !/case "\$RELEASE_TAG"[\s\S]*\*-tauri/.test(releaseWorkflow), + 'flow must not mandate a -tauri release tag'); + +// 9. The release flow never touches the Tauri host or its src-tauri tree. +assert.ok(!/src-tauri/.test(packageScript), 'package script must not reference src-tauri'); +assert.ok(!/src-tauri/.test(releaseWorkflow), 'release workflow must not reference src-tauri'); +assert.ok(!/\btauri\b|\bwry\b/.test(packageScript), 'package script must not invoke Tauri tooling'); + +console.log('linux-egui-release-contract.test.mjs passed'); diff --git a/openless-all/app/scripts/linux-egui-tauri-free-contract.test.mjs b/openless-all/app/scripts/linux-egui-tauri-free-contract.test.mjs new file mode 100644 index 000000000..edbc6d867 --- /dev/null +++ b/openless-all/app/scripts/linux-egui-tauri-free-contract.test.mjs @@ -0,0 +1,50 @@ +import { readFile, readdir } from 'node:fs/promises'; +import { extname, join } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +const appRoot = new URL('..', import.meta.url); +const roots = [ + new URL('../linux-egui', import.meta.url), + new URL('../scripts/package-linux-egui.sh', import.meta.url), + new URL('../../../.github/workflows/release-linux-egui.yml', import.meta.url), +]; +const sourceExtensions = new Set(['.rs', '.toml', '.sh', '.yml', '.yaml']); + +async function collect(url) { + const path = fileURLToPath(url); + if (extname(path)) return [path]; + const entries = await readdir(path, { withFileTypes: true }); + const files = []; + for (const entry of entries) { + if (entry.name === 'target') continue; + const child = join(path, entry.name); + if (entry.isDirectory()) files.push(...await collect(pathToFileURL(`${child}/`))); + else if (sourceExtensions.has(extname(entry.name))) files.push(child); + } + return files; +} + +const files = (await Promise.all(roots.map(collect))).flat(); +const violations = []; +for (const file of files) { + const source = await readFile(file, 'utf8'); + const normalizedFile = file.replaceAll('\\', '/'); + // The Remote Input TLS identity implementation remains one shared source file + // while its platform-neutral extraction is tracked separately. It is included + // directly by the Linux host and does not introduce a Cargo dependency on the + // Tauri crate, runtime, WebKit, or Qwen ASR. + const allowsSharedTlsIdentity = normalizedFile.endsWith('/linux-egui/src/remote_input.rs'); + if (!allowsSharedTlsIdentity && source.includes('src-tauri')) violations.push(`${file}: references src-tauri`); + if (normalizedFile.endsWith('Cargo.toml') && /^\s*(tauri|wry|webkit\w*)\s*=/mi.test(source)) { + violations.push(`${file}: declares a Tauri/Wry/WebKit dependency`); + } + if (/\b(Mock|Fake)(Backend|Provider|Repository)\b/.test(source)) { + violations.push(`${file}: production source names a mock backend/provider/repository`); + } +} + +if (violations.length) { + throw new Error(`Linux egui Tauri-free contract failed:\n${violations.join('\n')}`); +} + +console.log(`linux-egui-tauri-free-contract.test.mjs passed (${files.length} files)`); diff --git a/openless-all/app/scripts/package-linux-egui.sh b/openless-all/app/scripts/package-linux-egui.sh index adea7b099..8d2d86911 100644 --- a/openless-all/app/scripts/package-linux-egui.sh +++ b/openless-all/app/scripts/package-linux-egui.sh @@ -7,23 +7,44 @@ ARCH=${OPENLESS_LINUX_ARCH:-x86_64} TARGET_DIR=${CARGO_TARGET_DIR:-"$APP_ROOT/target"} BINARY="$TARGET_DIR/release/openless-linux-egui" PLUGIN_ROOT="$APP_ROOT/../scripts/linux-fcitx5-plugin/build" -QWEN_RUNTIME="$APP_ROOT/src-tauri/vendor/qwen-asr/qwen_asr" PACKAGING="$APP_ROOT/linux-egui/packaging" OUTPUT="$TARGET_DIR/linux-egui-packages" -ICON="$APP_ROOT/src-tauri/icons/128x128@2x.png" +ICON="$APP_ROOT/public/AppIcon.png" test -x "$BINARY" test -s "$PLUGIN_ROOT/libopenless.so" test -s "$PLUGIN_ROOT/openless.conf" -test -x "$QWEN_RUNTIME" test -s "$PACKAGING/openless.desktop" test -s "$PACKAGING/top.openless.OpenLess.metainfo.xml" test -s "$ICON" -command -v fpm > /dev/null -command -v appimagetool > /dev/null +command -v dpkg-deb >/dev/null +command -v rpmbuild >/dev/null mkdir -p "$OUTPUT" +POST_INSTALL="$TARGET_DIR/openless-fcitx5-postinst" +cat > "$POST_INSTALL" <<'EOF' +#!/usr/bin/env bash +set +e +# Package installation runs as root, while fcitx5 belongs to the logged-in +# desktop user. Reconnect only to existing user DBus sessions; never start a +# daemon or fail the package transaction when no graphical session is active. +for bus in /run/user/[0-9]*/bus; do + [ -S "$bus" ] || continue + runtime_dir=${bus%/bus} + uid=${runtime_dir##*/} + [ "$uid" != "0" ] || continue + user=$(getent passwd "$uid" | cut -d: -f1) + [ -n "$user" ] || continue + runuser -u "$user" -- env \ + XDG_RUNTIME_DIR="$runtime_dir" \ + DBUS_SESSION_BUS_ADDRESS="unix:path=$bus" \ + timeout 5s fcitx5 -r >/dev/null 2>&1 || true +done +exit 0 +EOF +chmod 0755 "$POST_INSTALL" + stage_common() { local root=$1 install -Dm755 "$BINARY" "$root/usr/bin/openless" @@ -32,8 +53,6 @@ stage_common() { install -Dm644 "$PACKAGING/top.openless.OpenLess.metainfo.xml" \ "$root/usr/share/metainfo/top.openless.OpenLess.metainfo.xml" install -Dm644 "$ICON" "$root/usr/share/icons/hicolor/256x256/apps/openless.png" - install -Dm755 "$QWEN_RUNTIME" \ - "$root/usr/lib/openless/resources/qwen-asr/qwen_asr" } DEB_ROOT="$TARGET_DIR/linux-egui-deb-root" @@ -43,13 +62,21 @@ install -Dm755 "$PLUGIN_ROOT/libopenless.so" \ "$DEB_ROOT/usr/lib/x86_64-linux-gnu/fcitx5/libopenless.so" install -Dm644 "$PLUGIN_ROOT/openless.conf" \ "$DEB_ROOT/usr/share/fcitx5/addon/openless.conf" -fpm -s dir -t deb -C "$DEB_ROOT" \ - -n openless -v "$VERSION" -a amd64 \ - --description "OpenLess Linux egui host" \ - --license AGPL-3.0-only \ - --url https://github.com/Open-Less/openless \ - -d fcitx5 -d fcitx5-module-dbus -d libdbus-1-3 -d libasound2 -d libopenblas0-pthread \ - -p "$OUTPUT/OpenLess-Linux-egui-${VERSION}-${ARCH}.deb" . +install -d "$DEB_ROOT/DEBIAN" +cat > "$DEB_ROOT/DEBIAN/control" <" && $3 ~ /^\// { print $3 }') -for binary in "$QWEN_APPDIR"/*; do - patchelf --set-rpath '$ORIGIN' "$binary" +RPM_TOP="$TARGET_DIR/rpmbuild" +RPM_VERSION=${VERSION,,} +RPM_VERSION=${RPM_VERSION//-/.} +rm -rf "$RPM_TOP" +mkdir -p "$RPM_TOP"/{BUILD,BUILDROOT,RPMS,SOURCES,SPECS,SRPMS,rpmdb,tmp} +tar -C "$RPM_ROOT" --transform="s,^\./,openless-$RPM_VERSION/," \ + -czf "$RPM_TOP/SOURCES/openless-$RPM_VERSION.tar.gz" . +cat > "$RPM_TOP/SPECS/openless.spec" </dev/null 2>&1 || true done -ln -s usr/bin/openless "$APPDIR/AppRun" -cp "$PACKAGING/openless.desktop" "$APPDIR/openless.desktop" -cp "$ICON" "$APPDIR/openless.png" -ln -s openless.png "$APPDIR/.DirIcon" -ARCH="$ARCH" appimagetool "$APPDIR" \ - "$OUTPUT/OpenLess-Linux-egui-${VERSION}-${ARCH}.AppImage" +exit 0 +EOF +rpmbuild --define "_topdir $RPM_TOP" --define "_dbpath $RPM_TOP/rpmdb" \ + --define "_tmppath $RPM_TOP/tmp" -bb "$RPM_TOP/SPECS/openless.spec" +mv "$RPM_TOP/RPMS/x86_64/openless-$RPM_VERSION-1.x86_64.rpm" \ + "$OUTPUT/OpenLess-Linux-egui-${VERSION}-${ARCH}.rpm" find "$OUTPUT" -maxdepth 1 -type f -printf '%f\n' | sort diff --git a/openless-all/app/scripts/remote-input-audio-queue.test.mjs b/openless-all/app/scripts/remote-input-audio-queue.test.mjs index 5a155456f..c74bd8a5d 100644 --- a/openless-all/app/scripts/remote-input-audio-queue.test.mjs +++ b/openless-all/app/scripts/remote-input-audio-queue.test.mjs @@ -3,11 +3,11 @@ import { readFile } from 'node:fs/promises'; import { runInNewContext } from 'node:vm'; const source = await readFile( - new URL('../src-tauri/src/remote_server/assets/app.js', import.meta.url), + new URL('../assets/remote-input/app.js', import.meta.url), 'utf8', ); const html = await readFile( - new URL('../src-tauri/src/remote_server/assets/index.html', import.meta.url), + new URL('../assets/remote-input/index.html', import.meta.url), 'utf8', ); diff --git a/openless-all/app/scripts/remote-input-locales.test.mjs b/openless-all/app/scripts/remote-input-locales.test.mjs index 640d29f0c..cb3e87467 100644 --- a/openless-all/app/scripts/remote-input-locales.test.mjs +++ b/openless-all/app/scripts/remote-input-locales.test.mjs @@ -3,7 +3,7 @@ import { readFileSync } from 'node:fs'; import vm from 'node:vm'; const source = readFileSync( - new URL('../src-tauri/src/remote_server/assets/app.js', import.meta.url), + new URL('../assets/remote-input/app.js', import.meta.url), 'utf8', ); const prefix = source.slice(0, source.indexOf(' // 极简插值:')); diff --git a/openless-all/app/src-tauri/build.rs b/openless-all/app/src-tauri/build.rs index 0ccccc807..1cc3c5549 100644 --- a/openless-all/app/src-tauri/build.rs +++ b/openless-all/app/src-tauri/build.rs @@ -114,7 +114,7 @@ int openless_common_controls_v6_manifest_dependency_anchor = 0; /// `-march=native` 这里**不**用——分发二进制要可移植,cc crate 在 release 下 /// 默认带 `-O2`,加上 `-O3` 提一档;NEON/AVX 在源码里有 `#ifdef` 自动分派。 fn build_qwen_asr(target_os: &str) { - const VENDOR: &str = "vendor/qwen-asr"; + const VENDOR: &str = "../vendor/qwen-asr"; const SOURCES: &[&str] = &[ "qwen_asr.c", "qwen_asr_kernels.c", diff --git a/openless-all/app/src-tauri/src/asr/local/test_run.rs b/openless-all/app/src-tauri/src/asr/local/test_run.rs index 64551de77..877a93445 100644 --- a/openless-all/app/src-tauri/src/asr/local/test_run.rs +++ b/openless-all/app/src-tauri/src/asr/local/test_run.rs @@ -20,10 +20,10 @@ use serde::Serialize; use super::models::ModelId; -/// 内嵌测试音频。原始文件 `vendor/qwen-asr/samples/test_speech.wav` +/// 内嵌测试音频。原始文件 `../vendor/qwen-asr/samples/test_speech.wav` /// 内容:"Hello. This is a test of the Voxtrail speech-to-text system." #[cfg(any(target_os = "macos", target_os = "linux"))] -const TEST_WAV: &[u8] = include_bytes!("../../../vendor/qwen-asr/samples/test_speech.wav"); +const TEST_WAV: &[u8] = include_bytes!("../../../../vendor/qwen-asr/samples/test_speech.wav"); /// 测试结果给前端展示。 #[derive(Debug, Serialize)] diff --git a/openless-all/app/src-tauri/src/commands/settings.rs b/openless-all/app/src-tauri/src/commands/settings.rs index cdb1559a8..f6e9f2efa 100644 --- a/openless-all/app/src-tauri/src/commands/settings.rs +++ b/openless-all/app/src-tauri/src/commands/settings.rs @@ -124,6 +124,9 @@ impl openless_core::SettingsRuntime for TauriSettingsRuntime<'_> { .map_err(Self::platform_error) }) .unwrap_or(Ok(())), + // Desktop launch-at-login is owned by tauri-plugin-autostart; + // Core currently does not stage this preference on Tauri. + openless_core::SettingsEffectKind::LaunchAtLogin => Ok(()), openless_core::SettingsEffectKind::WindowsKeyboard => plan .windows_keyboard .as_ref() diff --git a/openless-all/app/src-tauri/src/remote_server/mod.rs b/openless-all/app/src-tauri/src/remote_server/mod.rs index bb0ef1ef3..91b9e16ac 100644 --- a/openless-all/app/src-tauri/src/remote_server/mod.rs +++ b/openless-all/app/src-tauri/src/remote_server/mod.rs @@ -28,12 +28,12 @@ use tokio::net::TcpListener; use tokio_rustls::TlsAcceptor; mod assets { - pub const INDEX_HTML: &str = include_str!("assets/index.html"); - pub const APP_JS: &str = include_str!("assets/app.js"); - pub const STYLE_CSS: &str = include_str!("assets/style.css"); - pub const ICON_PNG: &[u8] = include_bytes!("assets/icon.png"); - pub const MIC_PNG: &[u8] = include_bytes!("assets/mic.png"); - pub const DONE_PNG: &[u8] = include_bytes!("assets/done.png"); + pub const INDEX_HTML: &str = include_str!("../../../assets/remote-input/index.html"); + pub const APP_JS: &str = include_str!("../../../assets/remote-input/app.js"); + pub const STYLE_CSS: &str = include_str!("../../../assets/remote-input/style.css"); + pub const ICON_PNG: &[u8] = include_bytes!("../../../assets/remote-input/icon.png"); + pub const MIC_PNG: &[u8] = include_bytes!("../../../assets/remote-input/mic.png"); + pub const DONE_PNG: &[u8] = include_bytes!("../../../assets/remote-input/done.png"); } const HEADER_HTML: &str = "text/html; charset=utf-8"; diff --git a/openless-all/app/src-tauri/src/remote_server/tls_identity.rs b/openless-all/app/src-tauri/src/remote_server/tls_identity.rs index d72b52a2b..d8b9f8358 100644 --- a/openless-all/app/src-tauri/src/remote_server/tls_identity.rs +++ b/openless-all/app/src-tauri/src/remote_server/tls_identity.rs @@ -333,7 +333,9 @@ mod tests { // 两张证书的名称完全相同,描述文件的名称和标识也可以照抄。 assert_eq!( parse_cert(&original.trust_cert).unwrap().distinguished_name, - parse_cert(&replacement.trust_cert).unwrap().distinguished_name + parse_cert(&replacement.trust_cert) + .unwrap() + .distinguished_name ); let encode = |bytes: &[u8]| base64::engine::general_purpose::STANDARD.encode(bytes); let substituted = mobileconfig(&original.trust_cert).replace( @@ -361,7 +363,11 @@ mod tests { fingerprint_sha256(&actual_certificate), replacement.ca_fingerprint_sha256 ); - assert!(!verify_server(&replacement, &original.trust_cert, "localhost")); + assert!(!verify_server( + &replacement, + &original.trust_cert, + "localhost" + )); assert_ne!( original.ca_fingerprint_sha256, fingerprint_sha256(&stored(original_dir.path()).leaf_cert) diff --git a/openless-all/app/src/lib/vocabPresets.ts b/openless-all/app/src/lib/vocabPresets.ts index 50c67109e..1c674f352 100644 --- a/openless-all/app/src/lib/vocabPresets.ts +++ b/openless-all/app/src/lib/vocabPresets.ts @@ -1,4 +1,4 @@ -import defaultPresetsJson from './vocab-presets.json'; +import defaultPresetsJson from '../../assets/vocab-presets.json'; import { listVocabPresets, saveVocabPresets } from './ipc'; import type { VocabPreset, VocabPresetStore } from './types'; diff --git a/openless-all/app/src-tauri/vendor/qwen-asr b/openless-all/app/vendor/qwen-asr similarity index 100% rename from openless-all/app/src-tauri/vendor/qwen-asr rename to openless-all/app/vendor/qwen-asr diff --git a/openless-all/scripts/linux-fcitx5-plugin/openless.cpp b/openless-all/scripts/linux-fcitx5-plugin/openless.cpp index 4e143da35..cc85afd26 100644 --- a/openless-all/scripts/linux-fcitx5-plugin/openless.cpp +++ b/openless-all/scripts/linux-fcitx5-plugin/openless.cpp @@ -20,6 +20,7 @@ * SetAuxDown(s: text) — 在候选词列表下方显示状态文本 * ClearAuxDown() — 清除候选词列表下方文本 * GetSelectionText() -> s — 读取当前 PRIMARY 选区文本(由 clipboard addon 维护) + * SetClipboardText(s: text) -> b — 通过 clipboard addon 写入 CLIPBOARD * CaptureSelectionTarget(s: ticket) -> s — 捕获选区和原输入上下文 * ApplySelectionTarget(sss: ticket, source, replacement) -> b — 校验后替换 * RevertSelectionTarget(s: ticket) -> b — 校验光标前文本后撤销替换 @@ -34,8 +35,10 @@ * TranslationModifierEvent(uub: sym, states, isPress) — 翻译修饰键按下/抬起 */ +#include #include #include +#include #include #include @@ -87,6 +90,10 @@ class OpenLess final : public AddonInstance, translationRawStates_(0), lessComputerRawSym_(0), lessComputerRawStates_(0), + switchStyleRawSym_(0), + switchStyleRawStates_(0), + openAppRawSym_(0), + openAppRawStates_(0), hasCustomDictationKey_(false), dictationTriggerHeld_(false), dictationTriggerCombined_(false), @@ -240,6 +247,25 @@ class OpenLess final : public AddonInstance, << "Translation modifier: sym=" << sym; translationModifierEvent(sym, states, isPress); } + if (switchStyleRawSym_ != 0 && sym == switchStyleRawSym_ && + states == switchStyleRawStates_) { + switchStyleEvent(sym, states, isPress); + keyEvent.filterAndAccept(); + return; + } + if (openAppRawSym_ != 0 && sym == openAppRawSym_ && + states == openAppRawStates_) { + openAppEvent(sym, states, isPress); + keyEvent.filterAndAccept(); + return; + } + for (const auto &[packId, packSym, packStates] : stylePackHotkeys_) { + if (packSym != 0 && sym == packSym && states == packStates) { + stylePackHotkeyEvent(sym, states, isPress); + keyEvent.filterAndAccept(); + return; + } + } })); // 4. 监听 InputContext 销毁事件,自动清空 savedIc_ 避免野指针 @@ -658,6 +684,37 @@ class OpenLess final : public AddonInstance, safeSaveAsIni(raw, configFile()); } + void setSwitchStyleHotkeyRaw(uint32_t sym, uint32_t states) { + switchStyleRawSym_ = sym; + switchStyleRawStates_ = states; + persistRawHotkey("SwitchStyle", sym, states); + } + + void setOpenAppHotkeyRaw(uint32_t sym, uint32_t states) { + openAppRawSym_ = sym; + openAppRawStates_ = states; + persistRawHotkey("OpenApp", sym, states); + } + + void setStylePackHotkeys( + const std::vector> &bindings) { + stylePackHotkeys_.clear(); + stylePackHotkeys_.reserve(bindings.size()); + for (const auto &binding : bindings) { + stylePackHotkeys_.push_back(binding.data()); + } + RawConfig raw; + readAsIni(raw, configFile()); + raw.setValueByPath("StylePackHotkeyCount", std::to_string(bindings.size())); + for (size_t index = 0; index < stylePackHotkeys_.size(); ++index) { + const auto prefix = "StylePackHotkey" + std::to_string(index); + raw.setValueByPath(prefix + "Id", std::get<0>(stylePackHotkeys_[index])); + raw.setValueByPath(prefix + "Sym", std::to_string(std::get<1>(stylePackHotkeys_[index]))); + raw.setValueByPath(prefix + "States", std::to_string(std::get<2>(stylePackHotkeys_[index]))); + } + safeSaveAsIni(raw, configFile()); + } + /// 读取当前 PRIMARY 选区文本。空字符串表示无选区或 clipboard addon 不可用。 std::string getSelectionText() { auto *clipboard = instance_->addonManager().addon("clipboard"); @@ -674,6 +731,17 @@ class OpenLess final : public AddonInstance, return text; } + bool setClipboardText(const std::string &text) { + auto *clipboard = instance_->addonManager().addon("clipboard"); + if (!clipboard) { + FCITX_LOGC(openless, Debug) + << "SetClipboardText: clipboard addon not loaded"; + return false; + } + clipboard->call("openless", text); + return true; + } + FCITX_OBJECT_VTABLE_METHOD(commitText, "CommitText", "s", "b"); FCITX_OBJECT_VTABLE_METHOD(captureDictationTarget, "CaptureDictationTarget", "s", "b"); FCITX_OBJECT_VTABLE_METHOD(commitDictationTarget, "CommitDictationTarget", "ss", "b"); @@ -692,7 +760,11 @@ class OpenLess final : public AddonInstance, FCITX_OBJECT_VTABLE_METHOD(setSelectionPolishHotkeyRaw, "SetSelectionPolishHotkeyRaw", "uu", ""); FCITX_OBJECT_VTABLE_METHOD(setTranslationHotkeyRaw, "SetTranslationHotkeyRaw", "uu", ""); FCITX_OBJECT_VTABLE_METHOD(setLessComputerHotkeyRaw, "SetLessComputerHotkeyRaw", "uu", ""); + FCITX_OBJECT_VTABLE_METHOD(setSwitchStyleHotkeyRaw, "SetSwitchStyleHotkeyRaw", "uu", ""); + FCITX_OBJECT_VTABLE_METHOD(setOpenAppHotkeyRaw, "SetOpenAppHotkeyRaw", "uu", ""); + FCITX_OBJECT_VTABLE_METHOD(setStylePackHotkeys, "SetStylePackHotkeys", "a(suu)", ""); FCITX_OBJECT_VTABLE_METHOD(getSelectionText, "GetSelectionText", "", "s"); + FCITX_OBJECT_VTABLE_METHOD(setClipboardText, "SetClipboardText", "s", "b"); FCITX_OBJECT_VTABLE_SIGNAL(dictationKeyEvent, "DictationKeyEvent", "uub"); FCITX_OBJECT_VTABLE_SIGNAL(dictationKeyCombined, "DictationKeyCombined", "uub"); FCITX_OBJECT_VTABLE_SIGNAL(lessComputerKeyEvent, "LessComputerKeyEvent", "uub"); @@ -700,6 +772,9 @@ class OpenLess final : public AddonInstance, FCITX_OBJECT_VTABLE_SIGNAL(qaShortcutEvent, "QaShortcutEvent", "uub"); FCITX_OBJECT_VTABLE_SIGNAL(selectionPolishEvent, "SelectionPolishEvent", "uub"); FCITX_OBJECT_VTABLE_SIGNAL(translationModifierEvent, "TranslationModifierEvent", "uub"); + FCITX_OBJECT_VTABLE_SIGNAL(switchStyleEvent, "SwitchStyleEvent", "uub"); + FCITX_OBJECT_VTABLE_SIGNAL(openAppEvent, "OpenAppEvent", "uub"); + FCITX_OBJECT_VTABLE_SIGNAL(stylePackHotkeyEvent, "StylePackHotkeyEvent", "uub"); Instance *instance() { return instance_; } @@ -749,6 +824,24 @@ class OpenLess final : public AddonInstance, auto *v = raw.valueByPath("LessComputerRawStates"); lessComputerRawStates_ = v ? std::stoul(*v, nullptr, 0) : 0; } + loadRawHotkey(raw, "SwitchStyle", switchStyleRawSym_, switchStyleRawStates_); + loadRawHotkey(raw, "OpenApp", openAppRawSym_, openAppRawStates_); + stylePackHotkeys_.clear(); + if (auto *countValue = raw.valueByPath("StylePackHotkeyCount")) { + const auto count = std::min( + std::stoul(*countValue, nullptr, 0), 128); + for (size_t index = 0; index < count; ++index) { + const auto prefix = "StylePackHotkey" + std::to_string(index); + auto *id = raw.valueByPath(prefix + "Id"); + auto *sym = raw.valueByPath(prefix + "Sym"); + auto *states = raw.valueByPath(prefix + "States"); + if (id && sym && states && !id->empty()) { + stylePackHotkeys_.emplace_back( + *id, std::stoul(*sym, nullptr, 0), + std::stoul(*states, nullptr, 0)); + } + } + } lessComputerTriggerHeld_ = false; lessComputerTriggerCombined_ = false; rebuildTriggerKeys(); @@ -814,6 +907,23 @@ class OpenLess final : public AddonInstance, triggerKeyList_ = config_.triggerKey.value(); } + void persistRawHotkey(const std::string &name, uint32_t sym, + uint32_t states) { + RawConfig raw; + readAsIni(raw, configFile()); + raw.setValueByPath(name + "RawSym", std::to_string(sym)); + raw.setValueByPath(name + "RawStates", std::to_string(states)); + safeSaveAsIni(raw, configFile()); + } + + static void loadRawHotkey(RawConfig &raw, const std::string &name, + uint32_t &sym, uint32_t &states) { + auto *symValue = raw.valueByPath(name + "RawSym"); + auto *statesValue = raw.valueByPath(name + "RawStates"); + sym = symValue ? std::stoul(*symValue, nullptr, 0) : 0; + states = statesValue ? std::stoul(*statesValue, nullptr, 0) : 0; + } + Instance *instance_; OpenLessConfig config_; KeyList triggerKeyList_; @@ -827,6 +937,11 @@ class OpenLess final : public AddonInstance, uint32_t translationRawStates_; uint32_t lessComputerRawSym_; uint32_t lessComputerRawStates_; + uint32_t switchStyleRawSym_; + uint32_t switchStyleRawStates_; + uint32_t openAppRawSym_; + uint32_t openAppRawStates_; + std::vector> stylePackHotkeys_; Key customDictationKey_; bool hasCustomDictationKey_; bool dictationTriggerHeld_;