diff --git a/.github/workflows/acceptance.yml b/.github/workflows/acceptance.yml index d286e148..2ee71709 100644 --- a/.github/workflows/acceptance.yml +++ b/.github/workflows/acceptance.yml @@ -12,7 +12,7 @@ permissions: env: # Unified immutable upstream revision containing AppImage handoff, # branded-DMG signature preservation, and macOS update rollback safety. - RIVET_PIN: a92877c02b6453b1826bfd061d7bbe70647ce5fa + RIVET_PIN: e18c28e6b6b18ab0f59559e82e30c51c4229d91f jobs: windows: @@ -106,7 +106,7 @@ jobs: - name: Remember full Racket command path run: echo "FULL_RACKET_BIN=$(dirname "$(command -v raco)")" >> "$GITHUB_ENV" - name: Set up embeddable Racket CS - uses: turinglambdaai/rivet/.github/actions/setup-embed-racket@a92877c02b6453b1826bfd061d7bbe70647ce5fa + uses: turinglambdaai/rivet/.github/actions/setup-embed-racket@e18c28e6b6b18ab0f59559e82e30c51c4229d91f - name: Run executable product acceptance run: PATH="$FULL_RACKET_BIN:$PATH" dbus-run-session -- raco rivet acceptance --json - name: Require generated Linux client to be current diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 80092ffd..2468ce78 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,7 +16,7 @@ concurrency: env: # Unified immutable upstream revision containing AppImage handoff, # branded-DMG signature preservation, and macOS update rollback safety. - RIVET_PIN: a92877c02b6453b1826bfd061d7bbe70647ce5fa + RIVET_PIN: e18c28e6b6b18ab0f59559e82e30c51c4229d91f jobs: backend: name: Backend tests (${{ matrix.os }}) @@ -108,14 +108,26 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - name: Set up embeddable Racket CS - uses: turinglambdaai/rivet/.github/actions/setup-embed-racket@a92877c02b6453b1826bfd061d7bbe70647ce5fa - # Installed after the embeddable runtime so Rivet's catalog closure and - # Fulcrum's SQLite dependency (db-lib) resolve against the minimal build. - - name: Install Rivet (catalog) + # Match packaged Linux acceptance: install dependencies in full Racket, + # then configure the embeddable runtime used in staged applications. + # Minimal embedded Racket's db-lib catalog fetch has timed out in CI. + - name: Install full Racket CS 9.3 + uses: Bogdanp/setup-racket@v1.15 + with: + version: '9.3' + architecture: x64 + distribution: full + variant: CS + - name: Install Rivet and SQLite dependencies in full Racket + shell: bash run: | racket -e '(dynamic-require (quote db) #f)' || raco pkg install --auto --no-docs db-lib raco pkg install --auto --no-docs 'https://github.com/turinglambdaai/rivet.git#${{ env.RIVET_PIN }}' + - name: Remember full Racket command path + shell: bash + run: echo "FULL_RACKET_BIN=$(dirname "$(command -v raco)")" >> "$GITHUB_ENV" + - name: Set up embeddable Racket CS + uses: turinglambdaai/rivet/.github/actions/setup-embed-racket@e18c28e6b6b18ab0f59559e82e30c51c4229d91f - name: Install Linux toolchain run: | sudo apt-get update @@ -126,10 +138,10 @@ jobs: run: python3 tests/run-linux-hotkey-tests.py - name: raco rivet doctor shell: bash - run: raco rivet doctor + run: PATH="$FULL_RACKET_BIN:$PATH" raco rivet doctor - name: raco rivet build shell: bash - run: raco rivet build + run: PATH="$FULL_RACKET_BIN:$PATH" raco rivet build - name: Generated Linux client is current shell: bash run: git diff --exit-code -- linux/GeneratedBackend.hpp diff --git a/.github/workflows/linux-appimage-upgrade-smoke.yml b/.github/workflows/linux-appimage-upgrade-smoke.yml index 55cab3b3..ec92c38e 100644 --- a/.github/workflows/linux-appimage-upgrade-smoke.yml +++ b/.github/workflows/linux-appimage-upgrade-smoke.yml @@ -13,7 +13,7 @@ permissions: contents: read env: - RIVET_PIN: a92877c02b6453b1826bfd061d7bbe70647ce5fa + RIVET_PIN: e18c28e6b6b18ab0f59559e82e30c51c4229d91f jobs: installed-upgrade: @@ -31,7 +31,7 @@ jobs: steps: - uses: actions/checkout@v4 - name: Set up embeddable Racket CS - uses: turinglambdaai/rivet/.github/actions/setup-embed-racket@a92877c02b6453b1826bfd061d7bbe70647ce5fa + uses: turinglambdaai/rivet/.github/actions/setup-embed-racket@e18c28e6b6b18ab0f59559e82e30c51c4229d91f - name: Install Rivet (pinned source) run: | racket -e '(dynamic-require (quote db) #f)' || raco pkg install --auto --no-docs db-lib diff --git a/.github/workflows/macos-native-update-e2e.yml b/.github/workflows/macos-native-update-e2e.yml index 1a00498c..b1fb0385 100644 --- a/.github/workflows/macos-native-update-e2e.yml +++ b/.github/workflows/macos-native-update-e2e.yml @@ -15,7 +15,7 @@ permissions: contents: read env: - RIVET_PIN: a92877c02b6453b1826bfd061d7bbe70647ce5fa + RIVET_PIN: e18c28e6b6b18ab0f59559e82e30c51c4229d91f FULCRUM_UPDATE_E2E_TESTING: "1" jobs: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index af034cd0..793a5fc3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -37,7 +37,7 @@ env: # lockstep by hand. # Unified immutable upstream revision containing AppImage handoff, # branded-DMG signature preservation, and macOS update rollback safety. - RIVET_PIN: a92877c02b6453b1826bfd061d7bbe70647ce5fa + RIVET_PIN: e18c28e6b6b18ab0f59559e82e30c51c4229d91f permissions: contents: write actions: read @@ -148,7 +148,7 @@ jobs: rpm desktop-file-utils - name: Set up embeddable Racket CS (Linux) if: matrix.platform == 'linux' - uses: turinglambdaai/rivet/.github/actions/setup-embed-racket@a92877c02b6453b1826bfd061d7bbe70647ce5fa + uses: turinglambdaai/rivet/.github/actions/setup-embed-racket@e18c28e6b6b18ab0f59559e82e30c51c4229d91f # Installed after the embeddable runtime so Rivet's catalog closure and # Fulcrum's SQLite dependency (db-lib) resolve against the minimal build. - name: Install Rivet (catalog) diff --git a/.github/workflows/windows-msi-upgrade-smoke.yml b/.github/workflows/windows-msi-upgrade-smoke.yml index 089bf492..a4027ee9 100644 --- a/.github/workflows/windows-msi-upgrade-smoke.yml +++ b/.github/workflows/windows-msi-upgrade-smoke.yml @@ -13,7 +13,7 @@ permissions: jobs: installed-upgrade: - name: Installed MSI 0.13.1 to 0.13.2 + name: Installed MSI 0.13.3 to 0.14.0 runs-on: windows-latest timeout-minutes: 30 steps: @@ -28,7 +28,7 @@ jobs: shell: bash run: | racket -e '(dynamic-require (quote db) #f)' || raco pkg install --auto --no-docs db-lib - raco pkg install --auto --no-docs 'https://github.com/turinglambdaai/rivet.git#a92877c02b6453b1826bfd061d7bbe70647ce5fa' + raco pkg install --auto --no-docs 'https://github.com/turinglambdaai/rivet.git#e18c28e6b6b18ab0f59559e82e30c51c4229d91f' - name: Verify official signed update discovery and MSI payload shell: bash run: racket scripts/acceptance/windows-signed-feed-smoke.rkt diff --git a/CHANGELOG.md b/CHANGELOG.md index 874baa34..35e2b7de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,87 @@ independently per platform packaging run. ## [Unreleased] +## [0.14.1] - 2026-10-27 + +### Fixed + +- **Windows launcher no longer flashes an empty search panel.** It waits for + the embedded backend's first search result and builds the native rows before + showing the window. Startup failures surface a visible diagnostic instead. +- **Windows second-launch behavior is recoverable.** Launching Fulcrum again + summons the resident panel and exits the secondary process cleanly, rather + than accumulating headless copies; activation respects the exact lease ID. +- **Windows Quit releases the background process.** The tray Quit and verified + MSI installation paths unregister native hooks, shut down the embedded + Racket/RVT1 backend, and terminate WinUI. Esc and Alt+F4 still hide the + launcher without quitting the resident service. + +- **Windows Settings now matches the macOS navigation model.** A native WinUI + NavigationView exposes General, Features, AI, Commands, and Sync & Updates + as individual pages; users can edit command aliases and shortcuts, and + record the global summon shortcut without typing the backend key grammar. +- **Windows updater remains visible from the tray.** Check for Updates now opens + the launcher directly in its update workflow, refreshes authenticated + download actions after checks, keeps native download progress visible, + and presents the verified MSI Install action immediately after download. + The existing signed Ed25519 manifest, bounded download/SHA-256 checks, and + external MSI quit/install/relaunch handoff remain the trust boundary. +- **Windows Settings shows Fulcrum's brand icon in the title bar.** The window + now installs the embedded resource ID 1 through `SetIcon(IconId)` instead of + passing `RivetHost.exe` to the `.ico`-path overload. +- **Windows explicit `find ` search works even if Windows Search is + unavailable.** The PowerShell command now searches the user's actual + Documents, Desktop, Downloads and redirected OneDrive locations before + trying the Windows Search index or a bounded profile fallback; it handles + UTF-8 paths. File results remain behind the explicit `find` command — + plain searches and the search placeholder are deliberately unchanged. + +- **Linux GTK search icon aligns with the search placeholder.** The themed + magnifying-glass glyph and editable query now occupy one vertically centered + horizontal row instead of relying on GtkSearchEntry's internally padded icon. +- **Linux hides the redundant resident-ready banner.** The normal `Ready — + hotkey or fulcrum --toggle` status no longer sits permanently above search + results when the launcher is visible. Actual startup failures, hotkey + conflicts, download errors, and progress notices remain discoverable. + +- **Linux no longer flashes an empty startup search window.** Initial + launcher presentation waits for native search results from the embedded + backend. Failed boot still shows a visible diagnostic instead of hiding it. +- **Linux Settings uses native five-section navigation.** General, Features, + AI, Commands and Sync & Updates now share one GTK sidebar/stack, matching + the information architecture of macOS and Windows; Commands uses the same + bindings-commands/list/set/clear backend API for aliases; desktop-wide + command hotkey execution is explicitly disabled on Linux until a native + binding runner is implemented. +- **Start at login is available as an opt-in per-device control on all hosts.** + Linux uses XDG Autostart, Windows uses the current-user Run key, and macOS + uses SMAppService's main-app Login Item with system approval. No automatic + sync or default enablement; Windows/Linux login launches stay resident + without popping open the launcher. + +### Regression coverage + +- Ubuntu 26.04 GNOME 50 Wayland portal identity: native installs now + register their reverse-DNS `site.jrtx.fulcrum.desktop` app id on + a retained shared GDBus connection + before requesting shortcuts; the fallback remains visible if registration + or permission fails. Full stock-desktop verification remains open in #111. +- Linux widget tests cover the deferred first frame, five Settings sections, + and enable/disable round-trip of a temporary XDG login entry. Windows and + macOS host checks and packaged acceptance remain release gates. +- Windows backend tests and packaged WinUI acceptance seed a real user file + and require `find ` to return its actual path, not simply invoke a + search subprocess. This check is a release gate on the final commit. +- Linux GTK widget regression checks image/input vertical alignment and + routine-ready visibility while preserving actionable status messages; its + packaged Wayland/AT-SPI acceptance uses backend result readiness rather than + depending on a permanent idle status row. +- Windows packaged native-UI acceptance exercises ready-on-first-frame, + duplicate-launch handoff, normal process exit, and clean restart. Stable + release remains subject to the exact-commit CI, installer, signing, and + publishing gates documented in the release runbook. + + ## [0.14.0] - 2026-10-27 ### Added diff --git a/README.md b/README.md index b73758de..171f9652 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ One keystroke. Everything within reach. A keyboard-first launcher for macOS, Windows, and Linux — one Racket brain, three first-party native UIs, no WebView anywhere. -[![CI](https://github.com/turinglambdaai/fulcrum/actions/workflows/ci.yml/badge.svg)](https://github.com/turinglambdaai/fulcrum/actions/workflows/ci.yml) ![Windows](https://img.shields.io/badge/Windows-WinUI_3-0078D4?logo=windows11&logoColor=white) ![macOS](https://img.shields.io/badge/macOS-SwiftUI-000000?logo=apple&logoColor=white) ![Linux](https://img.shields.io/badge/Linux-GTK4-F9A03C?logo=linux&logoColor=white) [![License](https://img.shields.io/badge/license-BUSL--1.1-blue)](LICENSE) ![Version](https://img.shields.io/badge/version-0.14.0-C15F3C) +[![CI](https://github.com/turinglambdaai/fulcrum/actions/workflows/ci.yml/badge.svg)](https://github.com/turinglambdaai/fulcrum/actions/workflows/ci.yml) ![Windows](https://img.shields.io/badge/Windows-WinUI_3-0078D4?logo=windows11&logoColor=white) ![macOS](https://img.shields.io/badge/macOS-SwiftUI-000000?logo=apple&logoColor=white) ![Linux](https://img.shields.io/badge/Linux-GTK4-F9A03C?logo=linux&logoColor=white) [![License](https://img.shields.io/badge/license-BUSL--1.1-blue)](LICENSE) ![Version](https://img.shields.io/badge/version-0.14.1-C15F3C) **English** · [中文](README.zh-CN.md) diff --git a/README.zh-CN.md b/README.zh-CN.md index 16818622..63abf8f9 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -2,7 +2,7 @@ 一次按键,直达一切。macOS、Windows、Linux 三平台的键盘优先启动器——一个 Racket 大脑,三套第一方原生 UI,全程无 WebView。 -[![CI](https://github.com/turinglambdaai/fulcrum/actions/workflows/ci.yml/badge.svg)](https://github.com/turinglambdaai/fulcrum/actions/workflows/ci.yml) ![Windows](https://img.shields.io/badge/Windows-WinUI_3-0078D4?logo=windows11&logoColor=white) ![macOS](https://img.shields.io/badge/macOS-SwiftUI-000000?logo=apple&logoColor=white) ![Linux](https://img.shields.io/badge/Linux-GTK4-F9A03C?logo=linux&logoColor=white) [![License](https://img.shields.io/badge/license-BUSL--1.1-blue)](LICENSE) ![Version](https://img.shields.io/badge/version-0.14.0-C15F3C) +[![CI](https://github.com/turinglambdaai/fulcrum/actions/workflows/ci.yml/badge.svg)](https://github.com/turinglambdaai/fulcrum/actions/workflows/ci.yml) ![Windows](https://img.shields.io/badge/Windows-WinUI_3-0078D4?logo=windows11&logoColor=white) ![macOS](https://img.shields.io/badge/macOS-SwiftUI-000000?logo=apple&logoColor=white) ![Linux](https://img.shields.io/badge/Linux-GTK4-F9A03C?logo=linux&logoColor=white) [![License](https://img.shields.io/badge/license-BUSL--1.1-blue)](LICENSE) ![Version](https://img.shields.io/badge/version-0.14.1-C15F3C) [English](README.md) · **中文** diff --git a/VERSION b/VERSION index a803cc22..930e3000 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.14.0 +0.14.1 diff --git a/app/backend.rkt b/app/backend.rkt index 855e0465..a25b61cf 100644 --- a/app/backend.rkt +++ b/app/backend.rkt @@ -72,7 +72,7 @@ ;; update check: drift here means every install believes it is outdated ;; (0.2.0 shipped believing it was 0.1.0). (define app-version - (with-handlers ([exn:fail? (lambda (_) "0.14.0")]) + (with-handlers ([exn:fail? (lambda (_) "0.14.1")]) (string-append (rivet-info:app-version)))) ;; The updater learns the running version from here (the packaged app reads diff --git a/app/core/files.rkt b/app/core/files.rkt index d5c2f1e0..4b9477bc 100644 --- a/app/core/files.rkt +++ b/app/core/files.rkt @@ -3,9 +3,9 @@ ;; File search — per-platform, always available on desktop: ;; macOS: Spotlight (`mdfind -name`) — millisecond responses, deep index ;; Linux: `locate -i` (the mlocate/plocate index) or `find` as fallback -;; Windows: the Windows Search index (SYSTEMINDEX) when the service is -;; up, falling back in-process to a bounded `Get-ChildItem -Recurse` -;; scan when it is not +;; Windows: scan the user's actual known folders first (including redirected +;; OneDrive locations), then query Windows Search (SYSTEMINDEX) for broader +;; indexed results; a bounded profile walk is the last resort. ;; Claims `find ` queries so it never clutters unrelated searches. ;; Opening a result uses the platform opener, the same side-effect path ;; application launches use. @@ -232,7 +232,7 @@ void (lambda () (runner program args - #:timeout-ms 3000 + #:timeout-ms (if (eq? os 'windows) 9000 3000) #:cancel-evt (semaphore-peek-evt cancel))) (lambda () (release-file-search! cancel)))) (cond @@ -268,12 +268,16 @@ ;; ---- windows: index-first search script ----------------------------------- -;; One PowerShell invocation, two tiers inside the same 3-second budget: -;; 1. the Windows Search index (SYSTEMINDEX) — filename prefix match, -;; then FREETEXT for the mid-word case; millisecond-fast when the -;; service is healthy -;; 2. a bounded Get-ChildItem scan of the user profile — the honest -;; fallback when the index is off, empty or errors +;; One PowerShell invocation, three tiers inside a bounded explicit-find +;; request (9 seconds on Windows, not on ordinary launcher typing): +;; 1. Windows known folders — Desktop, Documents and Downloads (including +;; shell-redirected and OneDrive directories), before probing OLE DB. +;; A broken Windows Search service must not prevent common-file results. +;; 2. Windows Search (SYSTEMINDEX) for other indexed scopes. +;; 3. A bounded user-profile walk only if the first two tiers found nothing. +;; The former index-first chain frequently spent its entire three-second +;; deadline in OLE DB or an enormous USERPROFILE recursion, yielding zero +;; results for files that were plainly present under Documents. ;; The script is defensive on purpose: any index failure lands in the ;; catch and the scan still answers. Two query spellings travel in the ;; script: $q is the raw text (the scan's -Filter wants it literal) and @@ -287,10 +291,23 @@ (define sql-q (string-replace raw-q "'" "''")) (string-append "$ErrorActionPreference='SilentlyContinue';" + "[Console]::OutputEncoding=[System.Text.UTF8Encoding]::new($false);" "$out=@();" "$q='" (ps-literal raw-q) "';" "$qs='" (ps-literal sql-q) "';" - "try{" + "$roots=@([Environment]::GetFolderPath('DesktopDirectory')," + "[Environment]::GetFolderPath('MyDocuments')," + "(Join-Path $env:USERPROFILE 'Downloads'));" + "foreach($cloud in @($env:OneDriveConsumer,$env:OneDrive)){" + "if($cloud){$roots+=@((Join-Path $cloud 'Desktop')," + "(Join-Path $cloud 'Documents'))}};" + "foreach($root in ($roots|Where-Object{$_ -and (Test-Path -LiteralPath $_ -PathType Container)}|Select-Object -Unique)){" + "try{$out+=@(Get-ChildItem -LiteralPath $root -Recurse -Depth 4 -File " + "-Filter ('*'+$q+'*') -ErrorAction SilentlyContinue|" + "Select-Object -First 128 -ExpandProperty FullName)}catch{};" + "if($out.Count -ge 128){break}" + "};" + "if($out.Count -eq 0){try{" "$c=New-Object System.Data.OleDb.OleDbConnection(\"Provider=Search.CollatorDSO;Extended Properties='Application=Windows'\");" "$c.Open();$cmd=$c.CreateCommand();" "$cmd.CommandText=\"SELECT TOP 128 System.ItemUrl FROM SYSTEMINDEX WHERE CONTAINS(System.FileName,'`\"$qs*`\"') AND SCOPE='file:'\";" @@ -300,9 +317,11 @@ "$r=$cmd.ExecuteReader();while($r.Read()){$out+=$r.GetString(0)};$r.Close()" "}" "$c.Close()" - "}catch{};" + "}catch{}};" "if($out.Count -gt 0){" - "$out|Select-Object -First 128|ForEach-Object{[System.Uri]::UnescapeDataString($_ -replace '^file:/+','')}" + "$out|Select-Object -Unique -First 128|ForEach-Object{" + "if($_ -like 'file:*'){[System.Uri]::UnescapeDataString($_ -replace '^file:/+','')}" + "else{$_}}" "}else{" "Get-ChildItem -Path $env:USERPROFILE -Recurse -Depth 3 -Filter ('*'+$q+'*') -File -ErrorAction SilentlyContinue|Select-Object -First 128 -ExpandProperty FullName" "}")) diff --git a/docs/cross-platform-interaction.md b/docs/cross-platform-interaction.md index 208f2934..2d5b379f 100644 --- a/docs/cross-platform-interaction.md +++ b/docs/cross-platform-interaction.md @@ -32,6 +32,12 @@ the panel has been active once, otherwise the summon operation can erase itself. update already-open surfaces without restarting. - Platform-specific diagnostic entries are allowed only when the mechanism is platform-specific. The GNOME Alt+Space repair action is one such exception. +- Search glyph and placeholder must share a single horizontal centerline on + all hosts. Linux uses a native, centered GtkImage beside GtkEntry rather + than GtkSearchEntry's independently padded built-in search icon. +- A resident-ready status is **not** a visible launcher row when the launcher + is already open. Host startup failures, update progress/errors, and + platform-specific hotkey recovery remain visible and actionable when needed. ## Entry and settings parity @@ -48,6 +54,37 @@ feature would incorrectly suggest a different product model. Settings and snippets remain backend-owned. Hosts may render different native form layouts, but keys, values, descriptions and availability must agree. +### Settings information architecture (Windows / macOS) + +Windows and macOS must expose the same five settings areas: General, Features, +AI, Commands, and Sync & Updates. Both use a fixed category sidebar and a +single current section (native NavigationView + Mica/cards on WinUI, native +sidebar + grouped Form on SwiftUI). The shared Racket backend owns validation, +command alias/global bindings, AI key storage and updates; hosts must not +silently omit the Commands editor on Windows. The summon hotkey and command +bindings use native in-window key recording, without exposing canonical +`mod+key` grammar in the default flow. Windows regular titled windows must +show the product icon from the packaged numeric icon resource; borderless +launcher windows do not need a fake titlebar or caption icon. macOS follows +AppKit window decoration, not Win32 caption-icon conventions. + +Linux now uses a GTK StackSidebar + five scrollable native content pages +(General, Features, AI, Commands, Sync & Updates). Its Commands editor uses +the same backend validation API for aliases/shortcuts as WinUI and SwiftUI; +Linux records its global summon shortcut directly with GTK key capture. +Its Commands page edits aliases through the shared backend, but desktop-wide +*per-command* hotkeys are currently unsupported on Linux; that input is +visibly disabled rather than accepting inert bindings. Supporting per-command +X11 grabs and Wayland portal bindings is a remaining explicit parity gap. +The GTK widget and packaged Wayland acceptance suites must pass. + +Start at login is a **per-device opt-in** in General on all three hosts, not +a synced Racket setting: macOS uses SMAppService and system approval, Windows +uses HKCU Run, and Linux writes its own XDG autostart .desktop entry. An OS +login launches a resident *hidden* host; an ordinary interactive launch keeps +the existing summon behavior. Background login for an already running host +must never toggle its visible panel. + ## Acceptance contract Each packaged-host acceptance suite exercises the same observable sequence: diff --git a/docs/user-guide.md b/docs/user-guide.md index 11c79109..a8366892 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -263,6 +263,57 @@ Write your own plugin: one manifest + one script. See ## Settings +**Start at login** is available in **General** on macOS, Windows and +Ubuntu/Linux. It defaults to off, affects only the current computer, and +starts Fulcrum quietly after you sign in. macOS may ask you to approve the +Login Item in System Settings; Windows uses your current user's startup +setting; Linux uses `~/.config/autostart` (or `XDG_CONFIG_HOME`). +For Linux AppImage copies, keep the AppImage at a stable path. + +On Linux, Settings has the same five sidebar areas as macOS/Windows: +General, Features, AI, Commands and Sync & Updates. Commands can edit aliases through the shared backend. Linux can record the +global summon shortcut by pressing keys in General, but individual command +hotkeys are visibly disabled until native desktop registration is supported. +The initial launcher panel appears only when its first search results or an +actionable startup failure is available. + +On **Ubuntu/Linux**, the search symbol is aligned with the search placeholder. +The idle `Ready — hotkey or fulcrum --toggle` message does not stay above the +results while the launcher is open. Use your configured summon shortcut or +`fulcrum --toggle` to bring the hidden launcher back; actual startup, +shortcut, and update errors are still shown in its status area. + +**Ubuntu 26.04 Wayland global shortcut:** Fulcrum now attempts the host-app +`Registry.Register` handshake on its retained shared D-Bus connection +**before** requesting global shortcuts, identifying the installed +`site.jrtx.fulcrum.desktop` entry. This is intended to address the portal's `An app id is required` +rejection on native installs; it has **not yet been verified** on stock +Ubuntu 26.04 GNOME 50. Portal availability and compositor consent are +still required, and real-machine verification remains tracked +in [issue #111](https://github.com/turinglambdaai/fulcrum/issues/111). +If the portal still rejects a shortcut (for example from an AppImage with no +installed desktop integration), add a custom desktop shortcut that runs +`fulcrum --toggle`; the failure stays visible rather than showing "ready". + +On **Windows**, the tray menu's **Check for Updates** opens a visible +`update` view rather than reporting into a hidden footer. The list refreshes +after checking: choose **Download** to fetch and verify the signed MSI, wait +for the progress to finish, then choose **Quit and install** to upgrade and +relaunch. Keep the installer window open if Windows requests elevation. If the +check or installation fails, the original app remains available with an +explicit error; you can also open the downloaded MSI folder from the same +update results. The installation handoff requires an MSI-installed copy; a +portable ZIP isn't upgraded in place by an MSI. + +On **macOS, Windows and Linux**, open **Settings** from the menu bar, tray, +or launcher and choose **General**, **Features**, **AI**, **Commands**, or +**Sync & Updates** in the native sidebar. General records the summon shortcut +by pressing the desired key combination (Esc cancels). Commands edits aliases +on all three platforms; per-command global hotkeys are **not supported on +Linux** and its control is disabled rather than saving a nonfunctional binding. +Windows uses native WinUI controls and shows the app icon in the Settings title +bar; macOS uses native AppKit/SwiftUI chrome; Linux uses GTK4. + Query `settings` and run a row to change it: | Setting | What it does | diff --git a/docs/user-guide.zh-CN.md b/docs/user-guide.zh-CN.md index 42748380..14ddeefb 100644 --- a/docs/user-guide.zh-CN.md +++ b/docs/user-guide.zh-CN.md @@ -160,6 +160,18 @@ Fulcrum 的 AI 用你自己的 OpenAI、Anthropic 或本地 Ollama 密钥。Fulc ## 设置 +**开机启动(登录后启动):** 三平台均可在 **General(常规)→ Start at login** 自行开启或关闭,默认关闭,仅对当前设备生效,不随同步复制。登录系统后会在后台常驻,不自动弹出搜索界面。macOS 可能需要在系统设置的登录项中批准;Windows 使用当前用户的启动项;Ubuntu/Linux 使用 XDG 自动启动。AppImage 用户开启后请勿随意移动安装文件。 + +Linux 设置界面改为与 macOS、Windows 一致的五类侧边导航:General、Features、AI、Commands、Sync & Updates。Commands 可通过共同的后端编辑命令别名,但 Linux 的**单个命令全局快捷键**目前尚不支持执行,界面明确禁用以避免保存无效绑定。常规 General 中的全局唤出快捷键支持直接按键录制。Ubuntu 启动器会在首批搜索结果准备好后显示,避免只看到空白搜索框。 + +**Ubuntu / Linux 启动器外观:** 搜索图标与「搜索应用、剪贴板、片段、网页…」提示在同一水平线上。面板已显示时,不再常驻展示「就绪 — 使用热键或 `fulcrum --toggle` 唤出」;需要从隐藏状态重新唤出时,仍可使用全局快捷键或 `fulcrum --toggle`。启动失败、快捷键冲突和更新失败等真正需要注意的状态信息仍会显示。 + +**Ubuntu 26.04 Wayland 全局热键:** Fulcrum 在调用全局快捷键接口前,现会通过与 Rivet 共用、持续存活的 D-Bus 连接注册已安装 `site.jrtx.fulcrum.desktop` 的应用身份(`Registry.Register`),旨在解决新版 portal 对 `An app id is required` 的拒绝,**尚未在 Ubuntu 26.04 GNOME 50 原生实机验证成功**。仍需桌面 portal 支持及用户授权;GNOME 50 原生实机验收见 [Issue #111](https://github.com/turinglambdaai/fulcrum/issues/111),**尚不能宣称实机通过**。若仍失败(例如未集成桌面文件的 AppImage),请在桌面自定义快捷键中设置 `fulcrum --toggle`;失败状态仍会显示,不会被「就绪」掩盖。 + +**Windows 更新操作:** 从系统托盘选择「Check for Updates(检查更新)」会直接打开可见的 `update` 页面。检查后会自动刷新下载操作;点击 Download(下载)获取并校验签名 MSI,等下载结束后点击 Quit and install(退出并安装),程序退出后由系统安装器升级并重新启动。如果 Windows 要求管理员确认,按系统提示操作;失败时可以查看错误或从更新页面打开下载目录。**便携 ZIP 版本不会被 MSI 原地替换**,建议从正式 MSI 安装版使用此自动升级路径。 + +**macOS、Windows 和 Linux** 的设置窗口均使用左侧五类导航:**General(常规)**、**Features(功能)**、**AI**、**Commands(命令)**、**Sync & Updates(同步与更新)**,可从菜单栏、托盘或启动器进入。三平台都可在 General 中直接按组合键录制全局唤出快捷键(Esc 取消),无需输入 `alt+space` 等内部格式;Commands 均可编辑命令别名。**Linux 暂不支持单个命令的全局快捷键**,对应控件已禁用,不会保存无效绑定。Windows 使用 WinUI 原生控件和带品牌图标的设置标题栏;macOS 使用 AppKit/SwiftUI;Linux 使用 GTK4 原生侧边导航。 + 输入 `settings`,运行对应的行即可修改: | 设置 | 说明 | diff --git a/linux/GeneratedBackend.hpp b/linux/GeneratedBackend.hpp index 659d42f3..1160ea43 100644 --- a/linux/GeneratedBackend.hpp +++ b/linux/GeneratedBackend.hpp @@ -19,8 +19,8 @@ namespace rivet_app { inline constexpr char kModuleName[] = "backend"; inline constexpr char kEntryName[] = "start"; inline constexpr char kDisplayName[] = "Fulcrum"; -inline constexpr char kVersion[] = "0.14.0"; -inline constexpr std::int64_t kBuild = 20; +inline constexpr char kVersion[] = "0.14.1"; +inline constexpr std::int64_t kBuild = 21; inline constexpr char kIdentifier[] = "site.jrtx.fulcrum"; inline constexpr char kReleaseChannel[] = "stable"; diff --git a/linux/src/autostart.hpp b/linux/src/autostart.hpp new file mode 100644 index 00000000..ff97ad1e --- /dev/null +++ b/linux/src/autostart.hpp @@ -0,0 +1,63 @@ +#pragma once +// Per-user XDG autostart belongs to the local host, not synced settings. +#include +#include +#include +#include +#include + +inline std::filesystem::path fulcrum_autostart_path() { + char const* config = std::getenv("XDG_CONFIG_HOME"); + auto const base = config && *config ? std::filesystem::path(config) + : std::filesystem::path(g_get_home_dir()) / ".config"; + return base / "autostart" / "site.jrtx.fulcrum.desktop"; +} + +inline bool fulcrum_autostart_enabled() { + return std::filesystem::is_regular_file(fulcrum_autostart_path()); +} + +inline std::string fulcrum_desktop_exec_quote(std::string const& path) { + std::string result(1, '"'); + for (char c : path) { + if (c == '%') result += "%%"; + else { + if (c == '$' || c == 0x60 || c == '\\' || c == '"') result += '\\'; + result += c; + } + } + return result + '"'; +} + +inline bool fulcrum_set_autostart(bool enabled, + std::string const& executable, + std::string& error) { + try { + auto const path = fulcrum_autostart_path(); + if (!enabled) { + std::error_code ec; + std::filesystem::remove(path, ec); + if (ec) { error = ec.message(); return false; } + return true; + } + if (!std::filesystem::exists(executable)) { + error = "Fulcrum executable does not exist"; return false; + } + std::filesystem::create_directories(path.parent_path()); + std::string const entry = + "[Desktop Entry]\nType=Application\nName=Fulcrum\n" + "Comment=Start Fulcrum in the background at login\n" + "Terminal=false\nX-GNOME-Autostart-enabled=true\nExec=" + + fulcrum_desktop_exec_quote(executable) + " --background\n"; + GError* write_error = nullptr; + bool const ok = g_file_set_contents(path.string().c_str(), + entry.c_str(), -1, &write_error); + if (!ok) { + error = write_error ? write_error->message : "Could not write autostart"; + if (write_error) g_error_free(write_error); + } + return ok; + } catch (std::exception const& e) { + error = e.what(); return false; + } +} diff --git a/linux/src/main.cpp b/linux/src/main.cpp index 43e05c68..cc715781 100644 --- a/linux/src/main.cpp +++ b/linux/src/main.cpp @@ -11,6 +11,7 @@ // Wayland compositors own those decisions, and the status bar says so. #include #include +#include #include #include @@ -44,6 +45,7 @@ #include "GeneratedBackend.hpp" #include "system_services.hpp" #include "update_handoff.hpp" +#include "autostart.hpp" #include "x11_hotkey.hpp" namespace { @@ -86,6 +88,7 @@ ResultRow parse_row(std::vector const& cells) { // Re-applied when the backend pushes the theme state. static GtkCssProvider* g_panel_css = nullptr; +bool g_login_background_start = false; static void apply_panel_css() { gboolean dark = FALSE; @@ -111,6 +114,9 @@ window.fulcrum-window { } )CSS"; static char const* const shared_css = R"CSS( +/* A single centered row owns the glyph and the editable placeholder. + GTK SearchEntry's built-in icon uses a different internal box/padding; + ordinary GtkEntry plus an explicit GtkImage avoids the baseline drift. */ window.fulcrum-window entry { background: none; border: none; @@ -119,6 +125,9 @@ window.fulcrum-window entry { font-size: 19px; padding: 10px 4px; } +window.fulcrum-window .fulcrum-search-icon { + color: #D97706; +} window.fulcrum-window list { padding: 2px 8px 8px; background: none; @@ -894,7 +903,8 @@ void show_about_dialog(GtkWindow* parent) { struct Launcher { GtkWindow* window{nullptr}; - GtkSearchEntry* search{nullptr}; + GtkEntry* search{nullptr}; + GtkWidget* search_icon{nullptr}; GtkListBox* results{nullptr}; GtkLabel* status{nullptr}; GtkButton* hotkey_fix{nullptr}; @@ -919,6 +929,9 @@ struct Launcher { // GNOME's stock activate-window-menu binding owns Alt+Space: the tray // grows a one-click fix while the conflict stands. bool hotkey_shadowed{false}; + // A failed Wayland portal bind must not be replaced by the later generic + // backend "ready" notification (Ubuntu 26.04's missing app-id case). + bool hotkey_failure_active{false}; GtkApplication* app{nullptr}; std::vector rows; guint search_source{0}; @@ -928,6 +941,12 @@ struct Launcher { // as visible-but-inactive while it resolves activation; hiding during that // handoff made the v0.13.1 summon hotkey look completely dead on Ubuntu. bool activated_since_show{false}; + // Startup frame gate: keep the GTK shell unmapped until the backend has + // produced its first native result rows (or an actionable boot error). + // Only an actual summon may request display; never leak the placeholder + // scaffold before Rivet's first search completes. + bool initial_results_ready{false}; + bool summon_when_ready{false}; int host_settings_generation{0}; int theme_revision{0}; int hotkey_revision{0}; @@ -965,7 +984,16 @@ struct Launcher { } void set_status(std::string const& message) { + if (message == tr("ready") && hotkey_failure_active) { + return; + } gtk_label_set_text(status, message.c_str()); + // The resident ready/hotkey hint duplicates the search chrome and is + // especially misleading while the panel is already open. Only show + // actionable status or errors; hidden widgets allocate no layout space. + // Keep status messages themselves selectable/copyable when visible. + gtk_widget_set_visible(GTK_WIDGET(status), + !message.empty() && message != tr("ready")); } // Marshal a status string from any worker thread onto the main loop. @@ -977,6 +1005,19 @@ struct Launcher { }, new std::string(std::move(message))); } + // Preserve permission/portal failures across the asynchronous backend + // startup. This callback runs on the GTK main loop, so it cannot race + // with set_status() or the startup handoff's ready notification. + static void post_hotkey_failure(std::string message) { + g_idle_add([](gpointer user_data) -> int { + std::unique_ptr text(static_cast(user_data)); + auto& launcher = Launcher::instance(); + launcher.hotkey_failure_active = true; + launcher.set_status(*text); + return G_SOURCE_REMOVE; + }, new std::string(std::move(message))); + } + // ---- backend-rendered settings ------------------------------------------- // Explicit light/dark uses GTK's built-in theme; system clears overrides. @@ -1401,10 +1442,28 @@ struct Launcher { void apply_search(SearchOutcome result) { if (!result.ok) { set_status("Search failed: " + result.error); + // The first search is part of the presentation gate. A failed query + // must reveal its error instead of stranding an invisible resident app. + // On login-background starts summon_when_ready is false, so this still + // never forces the panel open at sign-in. + if (!initial_results_ready) { + initial_results_ready = true; + if (summon_when_ready) { + summon_when_ready = false; + show(); + } + } return; } rows = std::move(result.rows); refresh_results(result.selected_arg); + if (!initial_results_ready) { + initial_results_ready = true; + if (summon_when_ready) { + summon_when_ready = false; + show(); + } + } } void scroll_to_selection() { @@ -1742,6 +1801,10 @@ struct Launcher { } void show(guint32 user_time = GDK_CURRENT_TIME) { + if (!initial_results_ready) { + summon_when_ready = true; + return; + } cancel_focus_loss(); activated_since_show = false; actions_mode = false; @@ -1784,6 +1847,7 @@ struct Launcher { } void hide() { + summon_when_ready = false; cancel_focus_loss(); gtk_widget_set_visible(GTK_WIDGET(window), FALSE); } @@ -1812,6 +1876,8 @@ struct Launcher { !std::filesystem::exists(core)) { set_status("Missing Rivet runtime layout (runtime/*.boot, res/core.zo) " "next to the executable. Build with raco rivet build/dev."); + initial_results_ready = true; + if (summon_when_ready) { summon_when_ready = false; show(); } return; } @@ -1875,10 +1941,14 @@ struct Launcher { } if (!error.empty()) { set_status("Fulcrum backend failed to start: " + error); + initial_results_ready = true; + if (summon_when_ready) { summon_when_ready = false; show(); } return; } if (backend == nullptr) { set_status("Fulcrum backend error: startup completed without a backend"); + initial_results_ready = true; + if (summon_when_ready) { summon_when_ready = false; show(); } return; } @@ -1939,7 +2009,7 @@ void set_i18n_language(std::string const& lang) { bool const changed = g_i18n_active.exchange(active) != active; auto& launcher = Launcher::instance(); if (launcher.search != nullptr) { - gtk_search_entry_set_placeholder_text(launcher.search, + gtk_entry_set_placeholder_text(launcher.search, tr("searchPlaceholder").c_str()); } if (launcher.navigation_hint != nullptr) { @@ -1984,6 +2054,9 @@ struct SettingBinding { struct SettingsForm { GtkWindow* window{nullptr}; + GtkWidget* stack{nullptr}; + GtkWidget* commands_list{nullptr}; + GtkWidget* commands_message{nullptr}; std::vector> bindings; // Keep backend-defined localized labels alive across language changes. std::vector> localized_labels; @@ -2251,11 +2324,65 @@ void settings_add_number(GtkWidget* section, char const* label, char const* key, settings_row(section, label, spin, key); } +// GTK-native shortcut capture. The backend still validates duplicates, +// unsupported combinations and collisions with the global summon shortcut. +std::string linux_key_spec(guint keyval, GdkModifierType modifiers) { + std::string result; + if ((modifiers & GDK_CONTROL_MASK) != 0) result += "ctrl+"; + if ((modifiers & GDK_ALT_MASK) != 0) result += "alt+"; + if ((modifiers & GDK_SHIFT_MASK) != 0) result += "shift+"; + if ((modifiers & GDK_SUPER_MASK) != 0) result += "super+"; + if (result.empty()) return {}; + guint const key = gdk_keyval_to_lower(keyval); + if (key >= GDK_KEY_a && key <= GDK_KEY_z) { + return result + static_cast('a' + (key - GDK_KEY_a)); + } + if (key >= GDK_KEY_0 && key <= GDK_KEY_9) { + return result + static_cast('0' + (key - GDK_KEY_0)); + } + if (key >= GDK_KEY_F1 && key <= GDK_KEY_F12) { + return result + "f" + std::to_string(key - GDK_KEY_F1 + 1); + } + switch (key) { + case GDK_KEY_space: return result + "space"; + case GDK_KEY_comma: return result + "comma"; + case GDK_KEY_period: return result + "period"; + case GDK_KEY_minus: return result + "minus"; + case GDK_KEY_equal: return result + "equal"; + case GDK_KEY_Tab: return result + "tab"; + default: return {}; + } +} + +gboolean linux_record_key(GtkEventControllerKey*, guint keyval, guint, + GdkModifierType modifiers, gpointer data) { + // Esc cancels the active recorder; a bare key is ignored rather than + // producing an invalid intermediate backend setting. + if (keyval == GDK_KEY_Escape) return TRUE; + auto* entry = GTK_EDITABLE(data); + if (auto const spec = linux_key_spec(keyval, modifiers); !spec.empty()) { + gtk_editable_set_text(entry, spec.c_str()); + } + return TRUE; +} + +void linux_attach_key_recorder(GtkWidget* widget) { + auto* controller = gtk_event_controller_key_new(); + gtk_event_controller_set_propagation_phase(controller, GTK_PHASE_CAPTURE); + g_signal_connect(controller, "key-pressed", G_CALLBACK(linux_record_key), + widget); + gtk_widget_add_controller(widget, controller); +} + void settings_add_text(GtkWidget* section, char const* label, char const* key, char const* placeholder) { auto* entry = gtk_entry_new(); gtk_entry_set_placeholder_text(GTK_ENTRY(entry), placeholder); gtk_widget_set_size_request(entry, 260, -1); + if (std::string(key) == "hotkey") { + gtk_entry_set_placeholder_text(GTK_ENTRY(entry), "Focus and press a shortcut"); + linux_attach_key_recorder(entry); + } auto* binding = settings_bind(key, SettingKind::text, entry); g_signal_connect(entry, "activate", G_CALLBACK(on_setting_entry_activate), binding); auto* focus = gtk_event_controller_focus_new(); @@ -2272,38 +2399,84 @@ void refresh_settings_language() { } } +void on_linux_startup_toggle(GObject* object, GParamSpec*, gpointer) { + if (!g_settings_form || g_settings_form->loading) return; + bool const enabled = gtk_switch_get_active(GTK_SWITCH(object)); + char const* appimage = std::getenv("APPIMAGE"); + std::string const executable = appimage && *appimage + ? std::string(appimage) : executable_path().string(); + std::string error; + if (!fulcrum_set_autostart(enabled, executable, error)) { + g_settings_form->loading = true; + gtk_switch_set_active(GTK_SWITCH(object), !enabled); + g_settings_form->loading = false; + settings_show_error("Start at login: " + error); + } else { + settings_show_error(""); + } +} + void settings_build() { g_settings_form = new SettingsForm(); auto* window = gtk_window_new(); g_settings_form->window = GTK_WINDOW(window); gtk_window_set_title(GTK_WINDOW(window), "Fulcrum Settings"); - gtk_window_set_default_size(GTK_WINDOW(window), 660, 760); + gtk_window_set_default_size(GTK_WINDOW(window), 760, 560); gtk_window_set_hide_on_close(GTK_WINDOW(window), TRUE); - auto* scrolled = gtk_scrolled_window_new(); - gtk_scrolled_window_set_policy(GTK_SCROLLED_WINDOW(scrolled), - GTK_POLICY_NEVER, GTK_POLICY_AUTOMATIC); - auto* page = gtk_box_new(GTK_ORIENTATION_VERTICAL, 8); - gtk_widget_set_margin_top(page, 8); - gtk_widget_set_margin_bottom(page, 24); - gtk_widget_set_margin_start(page, 28); - gtk_widget_set_margin_end(page, 28); - gtk_scrolled_window_set_child(GTK_SCROLLED_WINDOW(scrolled), page); - gtk_window_set_child(GTK_WINDOW(window), scrolled); - - auto* general = settings_section(page, "General"); + // Mirror native macOS/WinUI Settings information architecture: five + // named sidebar categories, one current scrollable content page. + auto* layout = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 0); + auto* content = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0); + gtk_widget_set_hexpand(content, TRUE); + gtk_widget_set_vexpand(content, TRUE); + auto* stack = gtk_stack_new(); + g_settings_form->stack = stack; + gtk_widget_set_hexpand(stack, TRUE); + gtk_widget_set_vexpand(stack, TRUE); + auto* sidebar = gtk_stack_sidebar_new(); + gtk_stack_sidebar_set_stack(GTK_STACK_SIDEBAR(sidebar), GTK_STACK(stack)); + gtk_widget_set_size_request(sidebar, 170, -1); + gtk_widget_add_css_class(sidebar, "navigation-sidebar"); + gtk_box_append(GTK_BOX(layout), sidebar); + gtk_box_append(GTK_BOX(layout), gtk_separator_new(GTK_ORIENTATION_VERTICAL)); + gtk_box_append(GTK_BOX(content), stack); + gtk_box_append(GTK_BOX(layout), content); + gtk_window_set_child(GTK_WINDOW(window), layout); + + auto make_page = [stack](char const* id, char const* title) -> GtkWidget* { + auto* scrolled = gtk_scrolled_window_new(); + gtk_scrolled_window_set_policy(GTK_SCROLLED_WINDOW(scrolled), + GTK_POLICY_NEVER, GTK_POLICY_AUTOMATIC); + auto* page = gtk_box_new(GTK_ORIENTATION_VERTICAL, 8); + gtk_widget_set_margin_top(page, 8); + gtk_widget_set_margin_bottom(page, 24); + gtk_widget_set_margin_start(page, 24); + gtk_widget_set_margin_end(page, 24); + gtk_scrolled_window_set_child(GTK_SCROLLED_WINDOW(scrolled), page); + gtk_stack_add_titled(GTK_STACK(stack), scrolled, id, title); + return page; + }; + + auto* general_page = make_page("general", "General"); + auto* general = settings_section(general_page, "General"); settings_add_choice(general, "Language", "language", {{"中文", "zh"}, {"English", "en"}}); settings_add_choice(general, "Theme", "theme", {{"System", "system"}, {"Light", "light"}, {"Dark", "dark"}}); settings_add_text(general, "Hotkey", "hotkey", "alt+space"); settings_hint(general, - "Modifiers alt / ctrl / shift / super plus a key: space, a–z, 0–9, " - "f1–f12, comma, period, minus, equal, tab. Applies immediately; " - "a taken combo falls back to Ctrl+Alt+Space."); + "Focus the hotkey field and press modifiers plus a key. " + "Esc cancels; the shared backend rejects unsupported combos."); settings_add_number(general, "Maximum results", "max-results", 5, 25); - - auto* features = settings_section(page, "Features"); + auto* startup_toggle = gtk_switch_new(); + gtk_switch_set_active(GTK_SWITCH(startup_toggle), fulcrum_autostart_enabled()); + settings_row(general, "Start at login", startup_toggle); + g_signal_connect(startup_toggle, "notify::active", + G_CALLBACK(on_linux_startup_toggle), nullptr); + settings_hint(general, "Runs quietly after sign-in. This setting belongs to this device."); + + auto* features = settings_section(make_page("features", "Features"), "Features"); settings_add_toggle(features, "Record clipboard history", "clipboard-enabled"); settings_add_number(features, "Clipboard history limit", "clipboard-limit", 10, 100000); settings_add_toggle(features, "Web search rows (bangs and fallback)", "web-search-enabled"); @@ -2320,7 +2493,7 @@ void settings_build() { settings_add_toggle(features, "Load plugins", "plugins-enabled"); settings_add_number(features, "Plugin call timeout (ms)", "plugins-timeout-ms", 100, 10000); - auto* ai = settings_section(page, "AI"); + auto* ai = settings_section(make_page("ai", "AI"), "AI"); settings_add_choice(ai, "Provider", "ai-provider", {{"Off", ""}, {"OpenAI", "openai"}, {"Anthropic", "anthropic"}, {"Ollama", "ollama"}}); @@ -2356,7 +2529,21 @@ void settings_build() { g_settings_form->key_notice = GTK_LABEL(notice); settings_hint(ai, "Stored in ai-keys.json on this machine; never mirrored to the sync folder."); - auto* sync = settings_section(page, "Sync & Updates"); + // Commands is a real backend-owned editor, not just a placeholder label. + auto* commands_page = make_page("commands", "Commands"); + auto* commands = settings_section(commands_page, "Commands"); + settings_hint(commands, "Configure command aliases. Desktop-wide command hotkeys are not yet registered on Linux."); + auto* commands_list = gtk_box_new(GTK_ORIENTATION_VERTICAL, 6); + gtk_box_append(GTK_BOX(commands), commands_list); + g_settings_form->commands_list = commands_list; + auto* commands_message = gtk_label_new("Loading command bindings…"); + gtk_label_set_xalign(GTK_LABEL(commands_message), 0); + gtk_label_set_wrap(GTK_LABEL(commands_message), TRUE); + gtk_box_append(GTK_BOX(commands), commands_message); + g_settings_form->commands_message = commands_message; + + auto* sync_page = make_page("sync", "Sync & Updates"); + auto* sync = settings_section(sync_page, "Sync & Updates"); settings_add_text(sync, "Sync folder (beta)", "sync-root", "empty = off"); settings_hint(sync, "A folder a file sync service replicates (Syncthing, Nextcloud, " @@ -2372,7 +2559,9 @@ void settings_build() { gtk_widget_add_css_class(error, "error"); gtk_widget_set_margin_top(error, 12); gtk_widget_set_visible(error, FALSE); - gtk_box_append(GTK_BOX(page), error); + // Errors belong to a persistent shared footer, not an arbitrary section. + // It stays under the content pages while users navigate the sidebar. + gtk_box_append(GTK_BOX(content), error); g_settings_form->error = GTK_LABEL(error); } @@ -2445,6 +2634,126 @@ void settings_load() { }).detach(); } +// Native Commands editor mirrors the existing bindings-commands, +// bindings-list, binding-set and binding-clear RVT1 API of macOS/Windows. +// It never invents a Linux-only config file or bypasses backend validation. +struct LinuxCommandRow { + std::string id; + GtkWidget* alias{nullptr}; + GtkWidget* hotkey{nullptr}; +}; + +void settings_load_commands(); + +void settings_commit_binding(std::string id, std::string alias, + std::string hotkey, bool clear) { + auto* api = Launcher::instance().api.get(); + if (!api || !g_settings_form) return; + gtk_label_set_text(GTK_LABEL(g_settings_form->commands_message), "Saving…"); + std::thread([api, id = std::move(id), alias = std::move(alias), + hotkey = std::move(hotkey), clear] { + std::string message; + try { + bool const ok = clear + ? api->binding_clear(id).get() + : api->binding_set(id, alias, hotkey).get(); + if (!ok) message = "Binding rejected (shortcut conflict or invalid alias/key)"; + } catch (std::exception const& e) { message = e.what(); } + g_idle_add([](gpointer data) -> gboolean { + std::unique_ptr error(static_cast(data)); + if (g_settings_form) { + if (error->empty()) { + settings_load_commands(); + } else { + gtk_label_set_text(GTK_LABEL(g_settings_form->commands_message), + error->c_str()); + } + } + return G_SOURCE_REMOVE; + }, new std::string(std::move(message))); + }).detach(); +} + +void settings_load_commands() { + auto* api = Launcher::instance().api.get(); + if (!api || !g_settings_form || !g_settings_form->commands_list) return; + using Commands = std::vector>; + struct Payload { Commands commands; Commands bindings; std::string error; }; + std::thread([api] { + auto* payload = new Payload; + try { + payload->commands = api->bindings_commands().get(); + payload->bindings = api->bindings_list().get(); + } catch (std::exception const& e) { payload->error = e.what(); } + g_idle_add([](gpointer data) -> gboolean { + std::unique_ptr payload(static_cast(data)); + if (!g_settings_form) return G_SOURCE_REMOVE; + auto* list = g_settings_form->commands_list; + for (auto* child = gtk_widget_get_first_child(list); child;) { + auto* next = gtk_widget_get_next_sibling(child); + gtk_box_remove(GTK_BOX(list), child); + child = next; + } + if (!payload->error.empty()) { + gtk_label_set_text(GTK_LABEL(g_settings_form->commands_message), + payload->error.c_str()); + return G_SOURCE_REMOVE; + } + for (auto const& command : payload->commands) { + if (command.size() < 2) continue; + std::string alias, hotkey; + for (auto const& saved : payload->bindings) { + if (saved.size() >= 3 && saved[0] == command[0]) { + alias = saved[1]; hotkey = saved[2]; break; + } + } + auto* row = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 8); + gtk_widget_set_margin_top(row, 6); + gtk_widget_set_margin_bottom(row, 6); + auto* title = gtk_label_new(command[1].c_str()); + gtk_label_set_xalign(GTK_LABEL(title), 0); + gtk_widget_set_hexpand(title, TRUE); + gtk_widget_set_size_request(title, 110, -1); + auto* alias_entry = gtk_entry_new(); + gtk_entry_set_placeholder_text(GTK_ENTRY(alias_entry), "Alias"); + gtk_editable_set_text(GTK_EDITABLE(alias_entry), alias.c_str()); + gtk_widget_set_size_request(alias_entry, 115, -1); + auto* hotkey_entry = gtk_entry_new(); + gtk_entry_set_placeholder_text(GTK_ENTRY(hotkey_entry), "ctrl+alt+k"); + gtk_editable_set_text(GTK_EDITABLE(hotkey_entry), hotkey.c_str()); + gtk_widget_set_size_request(hotkey_entry, 145, -1); + gtk_widget_set_sensitive(hotkey_entry, FALSE); + auto* save = gtk_button_new_with_label("Save"); + auto* clear = gtk_button_new_with_label("Clear"); + auto* state = new LinuxCommandRow{command[0], alias_entry, hotkey_entry}; + g_object_set_data_full(G_OBJECT(row), "command-binding", state, + [](gpointer data) { delete static_cast(data); }); + g_signal_connect(save, "clicked", G_CALLBACK(+[](GtkButton*, gpointer data) { + auto* r = static_cast(data); + settings_commit_binding( + r->id, + settings_trimmed(gtk_editable_get_text(GTK_EDITABLE(r->alias))), + settings_trimmed(gtk_editable_get_text(GTK_EDITABLE(r->hotkey))), + false); + }), state); + g_signal_connect(clear, "clicked", G_CALLBACK(+[](GtkButton*, gpointer data) { + auto* r = static_cast(data); + settings_commit_binding(r->id, "", "", true); + }), state); + gtk_box_append(GTK_BOX(row), title); + gtk_box_append(GTK_BOX(row), alias_entry); + gtk_box_append(GTK_BOX(row), hotkey_entry); + gtk_box_append(GTK_BOX(row), save); + gtk_box_append(GTK_BOX(row), clear); + gtk_box_append(GTK_BOX(list), row); + } + gtk_label_set_text(GTK_LABEL(g_settings_form->commands_message), + "Save aliases through the shared backend. Global command hotkeys are unavailable on Linux."); + return G_SOURCE_REMOVE; + }, payload); + }).detach(); +} + void show_settings_window() { if (Launcher::instance().api == nullptr) { Launcher::instance().set_status("Settings open once the backend is ready…"); @@ -2454,6 +2763,7 @@ void show_settings_window() { settings_build(); } settings_load(); + settings_load_commands(); gtk_window_present(g_settings_form->window); } @@ -2676,6 +2986,7 @@ void tray_fix_hotkey() { Launcher& launcher = Launcher::instance(); if (rc == 0) { launcher.hotkey_shadowed = false; + launcher.hotkey_failure_active = false; if (launcher.hotkey_fix != nullptr) { gtk_widget_set_visible(GTK_WIDGET(launcher.hotkey_fix), FALSE); } @@ -2773,7 +3084,7 @@ void check_hotkey_shadowing(std::string const& trigger) { gtk_widget_set_visible(GTK_WIDGET(launcher.hotkey_fix), TRUE); } rebuild_tray_menu(); - Launcher::post_status(tr_format("hotkeyShadowed", {trigger})); + Launcher::post_hotkey_failure(tr_format("hotkeyShadowed", {trigger})); } // The menu is composed at tray construction with the startup language; the @@ -2838,7 +3149,7 @@ gboolean on_close_request(GtkWindow*, gpointer) { return TRUE; } -void on_search_changed(GtkSearchEntry*, gpointer) { +void on_search_changed(GtkEntry*, gpointer) { Launcher& launcher = Launcher::instance(); if (launcher.search_source != 0) { g_source_remove(launcher.search_source); @@ -3019,18 +3330,33 @@ void on_activate(GtkApplication* app, gpointer) { gtk_widget_add_css_class(window, "fulcrum-window"); auto* root = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0); - auto* search = gtk_search_entry_new(); - gtk_search_entry_set_placeholder_text(GTK_SEARCH_ENTRY(search), - "Search apps, clipboard, snippets, the web…"); + // GtkSearchEntry owns its icon in an internal GTK subnode, where the + // entry-level CSS padding shifts it relative to the placeholder baseline. + // Build one deliberate row: a 22px symbolic icon centered against a plain + // editable entry. GtkEntry retains IME, clipboard and keyboard semantics. + auto* search_row = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 10); + gtk_widget_set_margin_top(search_row, 10); + gtk_widget_set_margin_start(search_row, 18); + gtk_widget_set_margin_end(search_row, 14); + auto const* search_icon_name = pick_icon({"system-search-symbolic", + "edit-find-symbolic"}); + auto* search_icon = gtk_image_new_from_icon_name( + search_icon_name == nullptr ? "edit-find-symbolic" : search_icon_name); + gtk_image_set_pixel_size(GTK_IMAGE(search_icon), 22); + gtk_widget_set_size_request(search_icon, 24, 24); + gtk_widget_set_valign(search_icon, GTK_ALIGN_CENTER); + gtk_widget_add_css_class(search_icon, "fulcrum-search-icon"); + gtk_box_append(GTK_BOX(search_row), search_icon); + launcher.search_icon = search_icon; + auto* search = gtk_entry_new(); + gtk_entry_set_placeholder_text(GTK_ENTRY(search), + "Search apps, clipboard, snippets, the web…"); gtk_accessible_update_property(GTK_ACCESSIBLE(search), GTK_ACCESSIBLE_PROPERTY_LABEL, "FulcrumQuery", -1); - gtk_widget_set_margin_top(search, 10); - gtk_widget_set_margin_start(search, 10); - gtk_widget_set_margin_end(search, 10); - launcher.search = GTK_SEARCH_ENTRY(search); + gtk_widget_set_valign(search, GTK_ALIGN_CENTER); gtk_widget_set_hexpand(search, TRUE); - auto* search_row = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 0); + launcher.search = GTK_ENTRY(search); gtk_box_append(GTK_BOX(search_row), search); auto* status = gtk_label_new("Starting embedded Racket CS…"); @@ -3148,7 +3474,7 @@ void on_activate(GtkApplication* app, gpointer) { gtk_window_set_child(launcher.window, root); set_i18n_language(g_i18n_active.load() == 0 ? "zh" : "en"); - g_signal_connect(search, "search-changed", + g_signal_connect(search, "changed", G_CALLBACK(on_search_changed), nullptr); g_signal_connect(results, "row-activated", G_CALLBACK(on_row_activated), nullptr); @@ -3201,18 +3527,26 @@ void on_activate(GtkApplication* app, gpointer) { [](rivet::system::GlobalShortcutBindResult const& result) { switch (result.status) { case rivet::system::GlobalShortcutBindStatus::cancelled: - Launcher::post_status( + Launcher::post_hotkey_failure( "Hotkey permission was dismissed. Reopen Fulcrum and allow " "the shortcut, or bind `fulcrum --toggle` in Settings."); - check_hotkey_shadowing("Alt+Space"); - g_idle_add(show_on_main, nullptr); + // Login items must remain silent even if the compositor + // rejects the shortcut permission prompt. Interactive starts + // still expose the recovery notice and launcher. + if (!g_login_background_start) { + g_idle_add(show_on_main, nullptr); + } break; case rivet::system::GlobalShortcutBindStatus::failed: - Launcher::post_status( + Launcher::post_hotkey_failure( "Portal hotkey failed: " + result.message + ". Bind `fulcrum --toggle` in desktop Settings."); - check_hotkey_shadowing("Alt+Space"); - g_idle_add(show_on_main, nullptr); + // Login items must remain silent even if the compositor + // rejects the shortcut permission prompt. Interactive starts + // still expose the recovery notice and launcher. + if (!g_login_background_start) { + g_idle_add(show_on_main, nullptr); + } break; case rivet::system::GlobalShortcutBindStatus::bound: // The compositor owns the final combo; say what it chose — @@ -3238,10 +3572,10 @@ void on_activate(GtkApplication* app, gpointer) { } launcher.start_backend(); } - // Open on launch. A launcher that starts hidden is an invisible app on - // every session where the summon path is not yet alive — Wayland before - // the portal consent is granted, after a reboot, a rejected grab. The - // panel is one Esc (or focus loss) away from hidden again. + // Open on a normal interactive launch, but *only after* the backend has + // populated native rows. The old unconditional show() mapped an empty GTK + // window with the English placeholder before Racket finished booting. + // Errors still reveal their diagnostic; acceptance uses the same gate. char const* acceptance_hotkey = std::getenv("FULCRUM_ACCEPTANCE_HOTKEY"); if (acceptance_hotkey != nullptr && std::string(acceptance_hotkey) == "1") { // Product acceptance drives the same main-loop activation handoff used by @@ -3252,6 +3586,8 @@ void on_activate(GtkApplication* app, gpointer) { activate_on_main(nullptr); return G_SOURCE_REMOVE; }, nullptr); + } else if (g_login_background_start) { + launcher.hide(); } else { launcher.show(); } @@ -3284,7 +3620,68 @@ void on_shutdown(GApplication*, gpointer) { } // namespace -int run_application() { +// XDG desktop portal >= 1.20 allows unsandboxed applications to register +// their identity on the SAME session-bus peer used by GlobalShortcuts. +// Rivet's session_bus() uses g_bus_get_sync(), which GIO caches per bus type. +// This call must precede GTK initialization and every portal request: +// registering after a portal call can be rejected as "too late". +// +// Must match Rivet's (linux-desktop-id) and the installed .desktop name. +// GNOME 50 rejects non-reverse-DNS app identities even after registration. +constexpr char const* kFulcrumInstalledDesktopId = "site.jrtx.fulcrum"; + +bool should_register_wayland_host_identity(char const* wayland_display, + char const* flatpak_id, + char const* snap_name) { + return wayland_display && *wayland_display && + !(flatpak_id && *flatpak_id) && + !(snap_name && *snap_name); +} + +bool register_wayland_host_identity(std::string* reason = nullptr) { + if (!should_register_wayland_host_identity( + std::getenv("WAYLAND_DISPLAY"), std::getenv("FLATPAK_ID"), + std::getenv("SNAP"))) return true; + + GError* error = nullptr; + // g_bus_get_sync() is the Rivet system adapter's connection, rather than + // a short-lived gdbus subprocess (whose registration would be useless). + GDBusConnection* bus = g_bus_get_sync(G_BUS_TYPE_SESSION, nullptr, &error); + if (!bus) { + if (reason) *reason = error ? error->message : "no session bus"; + if (error) g_error_free(error); + return false; + } + GVariantBuilder options; + g_variant_builder_init(&options, G_VARIANT_TYPE_VARDICT); + GVariant* response = g_dbus_connection_call_sync( + bus, "org.freedesktop.portal.Desktop", + "/org/freedesktop/portal/desktop", + "org.freedesktop.host.portal.Registry", "Register", + g_variant_new("(sa{sv})", kFulcrumInstalledDesktopId, &options), + G_VARIANT_TYPE_UNIT, G_DBUS_CALL_FLAGS_NONE, 1500, nullptr, &error); + if (!response) { + g_object_unref(bus); + // Older portals and unintegrated AppImage/tar installs may not expose + // Registry or a matching .desktop. Do not break startup: the normal + // portal bind path still reports a visible actionable failure. + if (reason) *reason = error ? error->message : "no registry reply"; + if (error) g_error_free(error); + return false; + } + g_variant_unref(response); + // g_bus_get_sync() is weakly cached. Retain our successful registration + // connection for the process lifetime so Rivet GlobalShortcuts' subsequent + // CreateSession uses the same D-Bus sender identity. + static GDBusConnection* registered_session_bus = nullptr; + registered_session_bus = bus; + return true; +} + +int run_application(int argc = 0, char** argv = nullptr) { + bool const background = argc > 1 && argv != nullptr && + std::string(argv[1]) == "--background"; + g_login_background_start = background; // The first-party rivet lease replaces the old pid-file + SIGUSR1 scheme: // second launches forward --toggle to the primary and exit. char const* isolated_id = std::getenv("FULCRUM_INSTANCE_ID"); @@ -3294,6 +3691,7 @@ int run_application() { : "site.jrtx.fulcrum"; rivet::system::SingleInstanceLease lease(application_id); if (!lease.is_primary()) { + if (background) return 0; // login item must never toggle resident UI if (!lease.forward_arguments({"--toggle"})) { std::cerr << "fulcrum: running instance not reachable\n"; return 1; @@ -3306,6 +3704,15 @@ int run_application() { // the option parser cannot reject it. lease.set_activation_handler(on_activation); + // Register the unsandboxed Wayland app id before GTK or Rivet first + // contacts xdg-desktop-portal (Issue #111 / Ubuntu 26.04 GNOME 50). + // Non-fatal on older desktops or missing portable-app integration. + std::string portal_identity_warning; + if (!register_wayland_host_identity(&portal_identity_warning)) { + std::cerr << "fulcrum: Wayland portal app identity not registered: " + << portal_identity_warning << '\n'; + } + // Load the i18n tables before any UI string is composed: the staged // shared/i18n JSON when packaged, the embedded fallback otherwise. load_i18n(executable_path().parent_path()); @@ -3321,6 +3728,6 @@ int run_application() { return status; } -int main(int, char**) { - return run_application(); +int main(int argc, char** argv) { + return run_application(argc, argv); } diff --git a/macos-host/Sources/RivetHost/GeneratedBackend.swift b/macos-host/Sources/RivetHost/GeneratedBackend.swift index 5c34d381..9571bc53 100644 --- a/macos-host/Sources/RivetHost/GeneratedBackend.swift +++ b/macos-host/Sources/RivetHost/GeneratedBackend.swift @@ -7,8 +7,8 @@ public enum RivetGeneratedConfig { public static let moduleName = "backend" public static let entryName = "start" public static let displayName = "Fulcrum" - public static let version = "0.14.0" - public static let build: Int64 = 20 + public static let version = "0.14.1" + public static let build: Int64 = 21 public static let identifier = "site.jrtx.fulcrum" public static let releaseChannel = "stable" } diff --git a/macos-host/Sources/RivetHost/SettingsView.swift b/macos-host/Sources/RivetHost/SettingsView.swift index 25a0dab0..1f6b6751 100644 --- a/macos-host/Sources/RivetHost/SettingsView.swift +++ b/macos-host/Sources/RivetHost/SettingsView.swift @@ -1,6 +1,7 @@ import SwiftUI import AppKit import Carbon.HIToolbox +import ServiceManagement /// Settings window state: one string per backend key, written through the /// settings-set RPC as the user edits. The backend owns validation (hotkey @@ -13,6 +14,8 @@ final class SettingsModel: ObservableObject { @Published var values: [String: String] = [:] @Published var error: String? @Published var aiStatus = "" + @Published var startAtLogin = SMAppService.mainApp.status == .enabled + @Published var loginItemError: String? @Published var aiKeyDraft = "" @Published var aiKeyNotice = "" // Per-command bindings (#48): what is bindable, what is bound, and the @@ -42,6 +45,29 @@ final class SettingsModel: ObservableObject { values = loaded } await refreshAiStatus() + refreshLoginItem() + } + + // The operating system owns login items per machine. They are not part + // of backend settings or sync, and registration may require OS approval. + func refreshLoginItem() { + startAtLogin = SMAppService.mainApp.status == .enabled + } + + func setStartAtLogin(_ enabled: Bool) { + do { + if enabled { + try SMAppService.mainApp.register() + } else { + try SMAppService.mainApp.unregister() + } + refreshLoginItem() + loginItemError = enabled && !startAtLogin + ? "macOS needs approval in System Settings → Login Items." : nil + } catch { + refreshLoginItem() + loginItemError = "Could not change Login Items: \(error.localizedDescription)" + } } func refreshAiStatus() async { @@ -415,6 +441,24 @@ struct SettingsView: View { Text("Dark").tag("dark") } } + Section("Startup") { + Toggle("Start at login", isOn: Binding( + get: { model.startAtLogin }, + set: { model.setStartAtLogin($0) })) + .accessibilityIdentifier("settings-start-at-login") + Text("Starts quietly after you sign in. Only this Mac is affected.") + .font(.system(size: 11)) + .foregroundStyle(.secondary) + if let error = model.loginItemError { + Text(error) + .font(.system(size: 11)) + .foregroundStyle(.red) + .textSelection(.enabled) + Button("Open Login Items settings") { + SMAppService.openSystemSettingsLoginItems() + } + } + } Section { GlobalHotkeySetting(title: t("setting.hotkey"), model: model) Text("Select the current shortcut, then press the new modifier and key combination. Applies immediately; a taken combo falls back to ⌃⌥Space.") diff --git a/rivet.rktd b/rivet.rktd index 5dac9087..85ff6834 100644 --- a/rivet.rktd +++ b/rivet.rktd @@ -1,7 +1,7 @@ #hasheq((name . "fulcrum") (display-name . "Fulcrum") - (version . "0.14.0") - (build . 20) + (version . "0.14.1") + (build . 21) (identifier . "site.jrtx.fulcrum") (release-channel . stable) (url-schemes . ("fulcrum")) @@ -19,6 +19,8 @@ (dmg-background . "assets/dmg-background.png") (windows-icon . "assets/fulcrum.ico") (linux-icon . "assets/fulcrum-icon-512.png") + ;; Explicit reverse-DNS desktop identity for GNOME 50 portal consent. + (linux-desktop-id . "site.jrtx.fulcrum") ;; Native Linux installers (deb installs /opt/fulcrum with a desktop ;; entry, rpm builds via rpmbuild, AppImage carries its own GTK4 ;; closure) ride along with the tar.gz. The signed update feed carries diff --git a/scripts/acceptance/windows-msi-upgrade-smoke.ps1 b/scripts/acceptance/windows-msi-upgrade-smoke.ps1 index 4bbedd64..3f8ada0c 100644 --- a/scripts/acceptance/windows-msi-upgrade-smoke.ps1 +++ b/scripts/acceptance/windows-msi-upgrade-smoke.ps1 @@ -1,11 +1,11 @@ # Windows MSI real installed-version upgrade smoke. # This validates the *installer* lifecycle, not the in-app MsiHandoff UI or # Ed25519 feed authorization; both require a separate signed-feed E2E test. -# Uses immutable Fulcrum v0.13.1 -> v0.13.2 public release assets. +# Uses immutable Fulcrum v0.13.3 -> v0.14.0 public release assets. [CmdletBinding()] param( - [string]$OldVersion = "0.13.1", - [string]$NewVersion = "0.13.2" + [string]$OldVersion = "0.13.3", + [string]$NewVersion = "0.14.0" ) $ErrorActionPreference = "Stop" Set-StrictMode -Version Latest diff --git a/scripts/acceptance/windows-signed-feed-smoke.rkt b/scripts/acceptance/windows-signed-feed-smoke.rkt index 2c6936e9..727ab186 100644 --- a/scripts/acceptance/windows-signed-feed-smoke.rkt +++ b/scripts/acceptance/windows-signed-feed-smoke.rkt @@ -3,14 +3,14 @@ ;; Real public-release Ed25519 manifest → Windows MSI download regression. ;; This intentionally does NOT call crypto/all use-all-factories!: installed ;; Fulcrum's backend must verify using its ordinary crypto factory setup. -;; Pin the known v0.13.1 → v0.13.2 feed to avoid dependence on "latest". +;; Pin the known v0.13.3 → v0.14.0 feed to avoid dependence on "latest". (require racket/file racket/path racket/string "../../app/update.rkt") (define base-url - "https://github.com/turinglambdaai/fulcrum/releases/download/v0.13.2") + "https://github.com/turinglambdaai/fulcrum/releases/download/v0.14.0") (define tmp (make-temporary-file "fulcrum-signed-win-update-~a" 'directory)) (define previous-data-dir (getenv "FULCRUM_DATA_DIR")) (define (require! condition fmt . args) @@ -33,16 +33,16 @@ (dynamic-wind (lambda () (putenv "FULCRUM_DATA_DIR" (path->string tmp))) (lambda () - (parameterize ([current-update-version "0.13.1"] + (parameterize ([current-update-version "0.13.3"] [current-update-settings #f]) (define result (perform-check! base-url)) (require! (equal? (hash-ref result 'status #f) "available") "expected signed update: ~a" result) - (require! (equal? (hash-ref result 'availableVersion #f) "0.13.2") + (require! (equal? (hash-ref result 'availableVersion #f) "0.14.0") "unexpected signed update version: ~a" result) (require! (equal? (hash-ref result 'installer #f) "msi") "installed Windows update must select MSI, got: ~a" result) - (displayln "PASS: real signed v0.13.2 Windows manifest verified and MSI selected") + (displayln "PASS: real signed v0.14.0 Windows manifest verified and MSI selected") (start-download! tmp) (let* ([snapshot (wait-for-download!)] [downloaded (hash-ref snapshot 'downloadedPath #f)] diff --git a/scripts/e2e/e2e-windows.ps1 b/scripts/e2e/e2e-windows.ps1 index fd980f1b..bde46d01 100644 --- a/scripts/e2e/e2e-windows.ps1 +++ b/scripts/e2e/e2e-windows.ps1 @@ -13,6 +13,10 @@ using System.Runtime.InteropServices; public static class FulcrumNative { [DllImport("user32.dll", SetLastError = true)] public static extern bool PostMessage(IntPtr hwnd, uint message, IntPtr wParam, IntPtr lParam); + [DllImport("user32.dll", EntryPoint="SendMessageW", SetLastError = true)] + public static extern IntPtr SendMessage(IntPtr hwnd, uint message, IntPtr wParam, IntPtr lParam); + [DllImport("user32.dll", EntryPoint="GetClassLongPtrW", SetLastError = true)] + public static extern IntPtr GetClassLongPtr(IntPtr hwnd, int index); } '@ @@ -76,7 +80,17 @@ $process = $null $savedData = $env:FULCRUM_DATA_DIR $savedInstanceId = $env:FULCRUM_INSTANCE_ID $savedAcceptance = $env:FULCRUM_ACCEPTANCE +# Verify the explicit file-search command against a real, existing file +# in the shell-resolved Documents directory. Seed before app launch so the +# query does not depend on the asynchronous catalog finishing its first scan. +$documentRoot = [Environment]::GetFolderPath('MyDocuments') +if (-not $documentRoot) { $documentRoot = Join-Path $env:USERPROFILE 'Documents' } +$createdDocumentRoot = -not (Test-Path -LiteralPath $documentRoot -PathType Container) +$fixtureName = "fulcrum-find-acceptance-$PID.txt" +$fixturePath = Join-Path $documentRoot $fixtureName try { + New-Item -ItemType Directory -Force -Path $documentRoot | Out-Null + Set-Content -LiteralPath $fixturePath -Value 'find command acceptance fixture' -Encoding utf8 $env:FULCRUM_DATA_DIR = $data $env:FULCRUM_INSTANCE_ID = "site.jrtx.fulcrum.acceptance.$PID" $env:FULCRUM_ACCEPTANCE = '1' @@ -126,6 +140,32 @@ try { throw "$($_.Exception.Message); status=$statusText" } + # A normal filename query is NOT supposed to invoke the file provider. + # File search is an explicit user command: "find ". Exercise + # the real PowerShell/Windows Search fallback through packaged RVT1/WinUI. + $query.SetFocus() + [System.Windows.Forms.SendKeys]::SendWait('^a') + [System.Windows.Forms.SendKeys]::SendWait("find fulcrum-find-acceptance-$PID") + try { + [void](Wait-Until 'find command displays a real Documents file' { + $candidate = Find-ByName $window $fixtureName + if ($candidate -and -not $candidate.Current.IsOffscreen) { return $true } + return $false + } 90) + } catch { + Save-AccessibilitySnapshot $window (Join-Path $output 'file-search-failure-accessibility.json') + $status = Find-ById $window 'StatusText' + $statusText = if ($status) { $status.Current.Name } else { '' } + throw "$($_.Exception.Message); status=$statusText" + } + Save-AccessibilitySnapshot $window (Join-Path $output 'file-search-accessibility.json') + # Restore the arithmetic query for the existing action and clipboard test. + $query.SetFocus() + [System.Windows.Forms.SendKeys]::SendWait('^a') + [System.Windows.Forms.SendKeys]::SendWait('12*12') + [void](Wait-Until 'calculator result returns after explicit file search' { + Find-ByNameFragment $window '144' + }) Save-AccessibilitySnapshot $window (Join-Path $output 'accessibility.json') $query.SetFocus() @@ -160,7 +200,170 @@ try { # natural process exit (not a test force-kill). The next primary must be # free to acquire the same instance ID and expose a usable window again. $hwnd = [IntPtr]::new([int64]$reopened.Current.NativeWindowHandle) + # The packaged product must expose the same five navigable settings + # sections as macOS, including the actual bindings editor. Open the + # real settings window via an acceptance-gated route to ShowSettings. if ($hwnd -eq [IntPtr]::Zero -or -not [FulcrumNative]::PostMessage( + $hwnd, [uint32]0x8007, [IntPtr]::Zero, [IntPtr]::Zero)) { + throw 'could not open settings via the packaged WinUI host' + } + $settings = Wait-Until 'Fulcrum Settings top-level window' { + $pidCondition = [System.Windows.Automation.PropertyCondition]::new( + [System.Windows.Automation.AutomationElement]::ProcessIdProperty, $process.Id) + $windows = $desktop.FindAll([System.Windows.Automation.TreeScope]::Children, $pidCondition) + for ($i = 0; $i -lt $windows.Count; $i++) { + if ($windows[$i].Current.Name -eq 'Fulcrum Settings') { return $windows[$i] } + } + return $null + } + foreach ($section in @('general','features','ai','commands','sync')) { + [void](Wait-Until "settings sidebar $section" { Find-ById $settings "settings-sidebar-$section" }) + } + $commandTab = Find-ById $settings 'settings-sidebar-commands' + $selection = $commandTab.GetCurrentPattern([System.Windows.Automation.SelectionItemPattern]::Pattern) + ([System.Windows.Automation.SelectionItemPattern]$selection).Select() + # StackPanel is a layout-only element: UIA does not expose it. Assert + # the actual titled content and functional Save control instead. + # WinUI NavigationView SelectionChanged and its layout run asynchronously. + # A collapsed TextBlock may still be discoverable by UIA before the new + # section is mapped, so wait for genuinely visible content rather than + # immediately failing on the stale IsOffscreen value. + try { + [void](Wait-Until 'visible Commands settings editor' { + $title = Find-ById $settings 'settings-commands-title' + if ($title -and -not $title.Current.IsOffscreen) { return $title } + return $null + }) + [void](Wait-Until 'visible functional command binding Save action' { + $buttons = $settings.FindAll( + [System.Windows.Automation.TreeScope]::Descendants, + [System.Windows.Automation.PropertyCondition]::new( + [System.Windows.Automation.AutomationElement]::NameProperty, 'Save')) + for ($i = 0; $i -lt $buttons.Count; $i++) { + $button = $buttons[$i] + if ($button.Current.ControlType -eq [System.Windows.Automation.ControlType]::Button -and + -not $button.Current.IsOffscreen) { return $button } + } + return $null + }) + } catch { + Save-AccessibilitySnapshot $settings (Join-Path $output 'settings-navigation-failure-accessibility.json') + throw "Commands tab did not reveal its visible binding editor: $($_.Exception.Message)" + } + $generalTab = Find-ById $settings 'settings-sidebar-general' + $selection = $generalTab.GetCurrentPattern([System.Windows.Automation.SelectionItemPattern]::Pattern) + ([System.Windows.Automation.SelectionItemPattern]$selection).Select() + [void](Wait-Until 'recordable global hotkey in General settings' { Find-ById $settings 'settings-record-hotkey' }) + + # Start at login must be a *working local OS toggle*, not a backend-only + # setting. Verify native UI Automation changes HKCU Run and restores it. + $loginControl = Wait-Until 'Start at login native ToggleSwitch' { + Find-ById $settings 'settings-start-at-login' + } + $toggle = $loginControl.GetCurrentPattern( + [System.Windows.Automation.TogglePattern]::Pattern) + $toggle = [System.Windows.Automation.TogglePattern]$toggle + $runKey = 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Run' + $priorRunValue = (Get-ItemProperty -Path $runKey -Name Fulcrum -ErrorAction SilentlyContinue).Fulcrum + try { + if ($toggle.Current.ToggleState -eq [System.Windows.Automation.ToggleState]::Off) { + $toggle.Toggle() + [void](Wait-Until 'Windows startup Run entry registered' { + $value = (Get-ItemProperty -Path $runKey -Name Fulcrum -ErrorAction SilentlyContinue).Fulcrum + if ($value -and $value.Contains('--background')) { return $true } + return $false + }) + } else { + $toggle.Toggle() + [void](Wait-Until 'Windows startup Run entry removed' { + if (-not (Get-ItemProperty -Path $runKey -Name Fulcrum -ErrorAction SilentlyContinue).Fulcrum) { + return $true + } + return $false + }) + } + } finally { + if ($null -ne $priorRunValue) { + New-ItemProperty -Path $runKey -Name Fulcrum -Value $priorRunValue -PropertyType String -Force | Out-Null + } else { + Remove-ItemProperty -Path $runKey -Name Fulcrum -ErrorAction SilentlyContinue + } + } + + + # WinUI titles don't automatically inherit the EXE icon. The settings + # window must explicitly set it from Rivet's branded numeric resource. + $settingsHwnd = [IntPtr]::new([int64]$settings.Current.NativeWindowHandle) + if ($settingsHwnd -eq [IntPtr]::Zero) { throw 'settings HWND is null' } + $small = [FulcrumNative]::SendMessage($settingsHwnd, [uint32]0x007F, + [IntPtr]::new(2), [IntPtr]::Zero) # WM_GETICON / ICON_SMALL2 + $large = [FulcrumNative]::SendMessage($settingsHwnd, [uint32]0x007F, + [IntPtr]::new(1), [IntPtr]::Zero) # WM_GETICON / ICON_BIG + $classIcon = [FulcrumNative]::GetClassLongPtr($settingsHwnd, -14) # GCLP_HICON + if ($small -eq [IntPtr]::Zero -and $large -eq [IntPtr]::Zero -and + $classIcon -eq [IntPtr]::Zero) { + throw 'settings title bar has no Win32 icon after SetIcon' + } + Save-AccessibilitySnapshot $settings (Join-Path $output 'settings-accessibility.json') + + # Exercise the same tray handler against a *hidden* launcher. Prior to + # this change, CheckForUpdates wrote exclusively to an invisible footer + # and never populated the user's update workflow. + $windowPattern = $settings.GetCurrentPattern( + [System.Windows.Automation.WindowPattern]::Pattern) + ([System.Windows.Automation.WindowPattern]$windowPattern).Close() + if (-not [FulcrumNative]::PostMessage( + $hwnd, [uint32]0x8009, [IntPtr]::Zero, [IntPtr]::Zero)) { + throw 'could not hide primary before tray update check' + } + Start-Sleep -Milliseconds 300 + # OS login while Fulcrum already owns the instance lease must exit + # without summoning the panel that the user deliberately hid. + $loginDuplicate = Start-Process -FilePath $executable.FullName -ArgumentList '--background' -WorkingDirectory $repo -PassThru + try { + if (-not $loginDuplicate.WaitForExit(15000)) { + throw 'login-item duplicate stayed running instead of exiting' + } + if ($loginDuplicate.ExitCode -ne 0 -or $process.HasExited) { + throw 'login-item duplicate disturbed the resident process' + } + Start-Sleep -Milliseconds 200 + $allResident = [System.Windows.Automation.PropertyCondition]::new( + [System.Windows.Automation.AutomationElement]::ProcessIdProperty, $process.Id) + $unexpected = $desktop.FindFirst( + [System.Windows.Automation.TreeScope]::Children, $allResident) + if ($unexpected -and -not $unexpected.Current.IsOffscreen) { + throw 'background login duplicate re-opened the hidden launcher' + } + } finally { + if (-not $loginDuplicate.HasExited) { + Stop-Process -Id $loginDuplicate.Id -Force -ErrorAction SilentlyContinue + } + $loginDuplicate.Dispose() + } + if (-not [FulcrumNative]::PostMessage( + $hwnd, [uint32]0x8008, [IntPtr]::Zero, [IntPtr]::Zero)) { + throw 'could not request tray CheckForUpdates' + } + $updates = Wait-Until 'tray CheckForUpdates shows a visible launcher' { + $condition = [System.Windows.Automation.PropertyCondition]::new( + [System.Windows.Automation.AutomationElement]::ProcessIdProperty, $process.Id) + $desktop.FindFirst([System.Windows.Automation.TreeScope]::Children, $condition) + } + $updatesQuery = Wait-Until 'visible update query' { Find-ById $updates 'QueryBox' } + [void](Wait-Until 'update workflow query' { + $value = $updatesQuery.GetCurrentPattern([System.Windows.Automation.ValuePattern]::Pattern) + if (([System.Windows.Automation.ValuePattern]$value).Current.Value -eq 'update') { + return $true + } + return $false + }) + [void](Wait-Until 'actionable update check row' { + Find-ByNameFragment $updates 'Check for updates' + }) + Save-AccessibilitySnapshot $updates (Join-Path $output 'update-accessibility.json') + + if (-not [FulcrumNative]::PostMessage( $hwnd, [uint32]0x8006, [IntPtr]::Zero, [IntPtr]::Zero)) { throw 'could not request acceptance-only QuitApplication' } @@ -189,10 +392,14 @@ try { [ordered]@{ platform = 'windows' artifact = $executable.FullName - actions = @('set:QueryBox=12*12','press:Enter','launch:secondary','quit:acceptance-tray-path','launch:again') - assertions = @('no blank startup XAML frame','packaged WinUI window activated','QueryBox exposed through UI Automation','query crossed RVT1 into the embedded backend','native result list contains 144','run-action emitted 144 to the native clipboard','secondary exited without a phantom process','secondary summons primary','QuitApplication terminated primary normally','relaunch obtained same lease and painted the ready window') + actions = @('set:QueryBox=12*12','search:find-explicit-windows-document','press:Enter','launch:secondary','settings:five-tabs','settings:commands','settings:startup-registry-roundtrip','login:background-does-not-summon','settings:window-icon','tray:update-while-hidden','quit:acceptance-tray-path','launch:again') + assertions = @('no blank startup XAML frame','packaged WinUI window activated','QueryBox exposed through UI Automation','query crossed RVT1 into the embedded backend','native result list contains 144','find prefix locates a real Windows Documents file using packaged RVT1/WinUI','run-action emitted 144 to the native clipboard','secondary exited without a phantom process','secondary summons primary','settings sidebar parity with macOS','Commands settings selectable','Windows Start at login toggle changes HKCU Run then restores previous value','background duplicate exits without re-opening hidden launcher','settings titlebar app icon visible','tray update opens visible actionable update UI','QuitApplication terminated primary normally','relaunch obtained same lease and painted the ready window') } | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath (Join-Path $output 'interaction-trace.json') -Encoding utf8 } finally { + Remove-Item -LiteralPath $fixturePath -Force -ErrorAction SilentlyContinue + if ($createdDocumentRoot) { + Remove-Item -LiteralPath $documentRoot -ErrorAction SilentlyContinue + } $env:FULCRUM_DATA_DIR = $savedData $env:FULCRUM_INSTANCE_ID = $savedInstanceId $env:FULCRUM_ACCEPTANCE = $savedAcceptance diff --git a/scripts/e2e/linux-atspi.py b/scripts/e2e/linux-atspi.py index bc25ff9a..c11f1a07 100644 --- a/scripts/e2e/linux-atspi.py +++ b/scripts/e2e/linux-atspi.py @@ -69,7 +69,12 @@ def main(): application = wait_for("Fulcrum application", find_application) window = wait_for("Fulcrum frame", lambda: next((n for n in walk(application) if role(n) == "frame"), None)) query = wait_for("FulcrumQuery", lambda: next((n for n in walk(window) if (n.name or "") == "FulcrumQuery"), None)) - wait_for("embedded backend ready", lambda: contains(window, "Ready") or contains(window, "就绪")) + # Product readiness is proved by backend-owned native result rows, not + # by a permanent \"Ready — use hotkey or --toggle\" label. The launcher is + # already summoned in acceptance; that hint must not occupy an extra row. + wait_for("embedded backend ready search results", lambda: contains(window, "Settings…")) + if contains(window, "Ready — hotkey") or contains(window, "就绪 — 使用热键"): + raise AssertionError("routine resident-ready hint is visible above results") wait_for("summoned query focus", lambda: focused(query)) # AT-SPI exposes and verifies focus, but Wayland intentionally rejects its # synthetic-key API. Send XTest input to the focused nested Weston window; @@ -84,6 +89,6 @@ def main(): wait_for("typed query 12*12", lambda: text(query) == "12*12") wait_for("calculator result 144 from embedded backend", lambda: contains(window, "144")) (args.output / "accessibility.json").write_text(json.dumps(snapshot(application), ensure_ascii=False, indent=2), encoding="utf-8") - (args.output / "interaction-trace.json").write_text(json.dumps({"platform": "linux", "actions": ["summon:activation-handler", "type-via-weston-seat:12*12"], "assertions": ["packaged GTK4 panel started hidden and was summoned through the global-hotkey activation handoff", "summoned panel remained visible after the focus-loss debounce", "FulcrumQuery held keyboard focus", "XTest input crossed the nested Weston Wayland seat and RVT1 into the embedded backend", "native result list contains 144"]}, indent=2), encoding="utf-8") + (args.output / "interaction-trace.json").write_text(json.dumps({"platform": "linux", "actions": ["summon:activation-handler", "type-via-weston-seat:12*12"], "assertions": ["packaged GTK4 panel started hidden and was summoned through the global-hotkey activation handoff", "summoned panel remained visible after the focus-loss debounce", "FulcrumQuery held keyboard focus", "XTest input crossed the nested Weston Wayland seat and RVT1 into the embedded backend", "native result list contains 144", "ready state does not render a redundant line above search results"]}, indent=2), encoding="utf-8") if __name__ == "__main__": main() diff --git a/scripts/e2e/macos-ax.swift b/scripts/e2e/macos-ax.swift index dc5af720..f20823d4 100644 --- a/scripts/e2e/macos-ax.swift +++ b/scripts/e2e/macos-ax.swift @@ -168,6 +168,9 @@ do { guard findByIdentifier(settingsWindow, "settings-record-hotkey") != nil else { throw DriverError.failure("settings hotkey recorder is unavailable") } + guard findByIdentifier(settingsWindow, "settings-start-at-login") != nil else { + throw DriverError.failure("native Start at login setting is unavailable") + } guard !contains(settingsWindow, fragment: "Feed base URL"), !contains(settingsWindow, fragment: "update-base-url"), !contains(settingsWindow, fragment: "releases/latest/download") else { @@ -181,7 +184,7 @@ do { contains(settingsWindow, fragment: "Fulcrum checks signed releases automatically") ? true : nil } try writeJSON(snapshot(application), to: output.appendingPathComponent("settings-accessibility.json")) - try writeJSON(["platform": "macos", "actions": ["copy:clipboard-fixture", "set:FulcrumQuery=clip", "set:FulcrumQuery=12*12", "press:Return", "press:CommandComma", "press:settings-sidebar-sync"], "assertions": ["packaged SwiftUI window activated", "FulcrumQuery exposed through Accessibility", "macOS pasteboard watcher recorded text through RVT1", "clip query listed clipboard history", "query crossed RVT1 into the embedded backend", "native result list contains 144", "run-action emitted 144 to the native clipboard", "all five settings destinations are directly accessible", "global hotkey uses a recorder control", "internal update feed is absent", "Sync & Updates opens without a toolbar overflow menu"]], to: output.appendingPathComponent("interaction-trace.json")) + try writeJSON(["platform": "macos", "actions": ["copy:clipboard-fixture", "set:FulcrumQuery=clip", "set:FulcrumQuery=12*12", "press:Return", "press:CommandComma", "press:settings-sidebar-sync"], "assertions": ["packaged SwiftUI window activated", "FulcrumQuery exposed through Accessibility", "macOS pasteboard watcher recorded text through RVT1", "clip query listed clipboard history", "query crossed RVT1 into the embedded backend", "native result list contains 144", "run-action emitted 144 to the native clipboard", "all five settings destinations are directly accessible", "global hotkey uses a recorder control", "Start at login is available in native General settings", "internal update feed is absent", "Sync & Updates opens without a toolbar overflow menu"]], to: output.appendingPathComponent("interaction-trace.json")) guard let running = NSRunningApplication(processIdentifier: pid), running.terminate() else { throw DriverError.failure("native application rejected termination") } } catch { if let application = diagnosticApplication, let output = diagnosticOutput { try? writeJSON(snapshot(application), to: output.appendingPathComponent("accessibility-failure.json")) } diff --git a/tests/files-test.rkt b/tests/files-test.rkt index 5cb339b6..8b52ef60 100644 --- a/tests/files-test.rkt +++ b/tests/files-test.rkt @@ -5,6 +5,7 @@ ;; elsewhere. (require racket/file + racket/path racket/async-channel racket/list racket/string @@ -77,8 +78,9 @@ 2)) (check-equal? (car (car ranked)) "微信计划.md")) -(test-case "windows backend runs the index-first script inside one command" +(test-case "windows find script prioritizes known folders and preserves index fallback" (define captured #f) + (define captured-timeout-ms #f) (define result (file-search/status "o'brien" 8 @@ -87,16 +89,25 @@ #:run (lambda (program args #:timeout-ms [timeout-ms 3000] #:cancel-evt [_cancel-evt never-evt]) (set! captured (cons program args)) + (set! captured-timeout-ms timeout-ms) "file:///C:/Users/me/obrien.txt\n"))) - ;; The call stays a single PowerShell invocation with the 3s budget. + ;; The explicit find command gets a Windows-specific 9s budget; ordinary + ;; launcher typing never invokes this search. + (check-equal? captured-timeout-ms 9000) + ;; The call stays a single PowerShell invocation with known-folder first + ;; and Windows Search / bounded profile fallback. (check-equal? (car captured) "powershell.exe") (check-equal? (cadr captured) "-NoProfile") (check-equal? (file-search-result-status result) 'ok) (check-equal? (file-search-result-hits result) '(("obrien.txt" . "C:/Users/me/obrien.txt"))) (define script (cadddr captured)) - ;; Index-first: SYSTEMINDEX query, then the honest scan fallback — both - ;; inside the same invocation so any index failure still answers. + ;; A failed/hung Windows Search provider must not starve a real file in + ;; Documents or in redirected OneDrive. Search common folders before OLE DB. + (check-true (string-contains? script "GetFolderPath('MyDocuments')")) + (check-true (string-contains? script "OneDriveConsumer")) + (check-true (string-contains? script "OutputEncoding")) + (check-true (string-contains? script "if($out.Count -eq 0){try{")) (check-true (string-contains? script "SYSTEMINDEX")) (check-true (string-contains? script "CONTAINS(System.FileName,")) (check-true (string-contains? script "FREETEXT(System.FileName,")) @@ -107,6 +118,35 @@ (check-false (string-contains? script "o'brien")) (check-true (string-contains? script "o''brien"))) +(test-case "Windows explicit find resolves a real fixture with the production PowerShell runner" + (when (eq? (system-type 'os) 'windows) + ;; Native backend smoke, not a string-mock: proves the Windows scanner + ;; returns usable filesystem paths even when SYSTEMINDEX is disabled. + (let ([profile (getenv "USERPROFILE")]) + (when profile + (let* ([downloads (build-path profile "Downloads")] + [already-exists? (directory-exists? downloads)] + [fixture-name (format "fulcrum-find-smoke-~a.txt" + (current-process-milliseconds))] + [fixture (build-path downloads fixture-name)] + [query (substring fixture-name 0 + (- (string-length fixture-name) 4))]) + (make-directory* downloads) + (dynamic-wind + (lambda () + (display-to-file "test" fixture #:exists 'truncate)) + (lambda () + (define result (file-search/status query 8 #:os 'windows)) + (check-equal? (file-search-result-status result) 'ok) + (check-true + (for/or ([hit (in-list (file-search-result-hits result))]) + (string-ci=? (car hit) fixture-name)))) + (lambda () + (when (file-exists? fixture) (delete-file fixture)) + (when (not already-exists?) + (with-handlers ([exn:fail? (lambda (_) (void))]) + (delete-directory downloads)))))))))) + (test-case "Linux find fallback reports timeout instead of an empty success" (define (find-only program) (and (string=? program "find") "/usr/bin/find")) diff --git a/tests/linux-ui-test.cpp b/tests/linux-ui-test.cpp index bb188f29..5d8896d2 100644 --- a/tests/linux-ui-test.cpp +++ b/tests/linux-ui-test.cpp @@ -1,4 +1,5 @@ // Exercise the real launcher widgets without starting its embedded backend. +#include #define main fulcrum_host_main #include "../linux/src/main.cpp" #undef main @@ -87,6 +88,15 @@ int main() { check(small_pos.x == 100 && small_pos.y == 50, "small work areas do not produce a negative placement offset"); + // The installed desktop file and portal identity must match exactly. + check(std::string(kFulcrumInstalledDesktopId) == "site.jrtx.fulcrum", + "host portal app-id agrees with Rivet's reverse-DNS desktop identity"); + check(should_register_wayland_host_identity("wayland-0", nullptr, nullptr) && + !should_register_wayland_host_identity("", nullptr, nullptr) && + !should_register_wayland_host_identity(nullptr, nullptr, nullptr) && + !should_register_wayland_host_identity("wayland-0", "org.example.App", nullptr) && + !should_register_wayland_host_identity("wayland-0", nullptr, "fulcrum"), + "portal host identity registers only for unsandboxed Wayland sessions"); auto* app = gtk_application_new("site.jrtx.fulcrum.ui-test", G_APPLICATION_NON_UNIQUE); g_application_register(G_APPLICATION(app), nullptr, nullptr); @@ -94,6 +104,139 @@ int main() { auto& launcher = Launcher::instance(); // The test executable has no adjacent runtime: startup stops before boot. check(launcher.api == nullptr, "UI fixture does not start the backend"); + check(GTK_IS_ENTRY(launcher.search) && launcher.search_icon != nullptr && + GTK_IS_IMAGE(launcher.search_icon), + "search uses an editable GTK entry plus a separate native symbolic icon"); + launcher.set_status(tr("ready")); + check(!gtk_widget_get_visible(GTK_WIDGET(launcher.status)), + "normal ready/hotkey hint is hidden instead of taking up a search row"); + launcher.set_status("Failed to register hotkey: change desktop bindings"); + check(gtk_widget_get_visible(GTK_WIDGET(launcher.status)) && + has_label(GTK_WIDGET(launcher.status), + "Failed to register hotkey: change desktop bindings"), + "actionable hotkey failures remain visible in the native status area"); + launcher.set_status(tr("ready")); + check(!gtk_widget_get_visible(GTK_WIDGET(launcher.status)), + "clearing an actionable status removes its layout space"); + // Ubuntu 26.04 may reject portal CreateSession before backend startup. + // The late backend ready notification must not conceal a broken hotkey. + Launcher::post_hotkey_failure( + "Portal hotkey failed: An app id is required. " + "Bind fulcrum --toggle in desktop Settings."); + pump(30); + launcher.set_status(tr("ready")); + check(launcher.hotkey_failure_active && + gtk_widget_get_visible(GTK_WIDGET(launcher.status)) && + has_label(GTK_WIDGET(launcher.status), + "Portal hotkey failed: An app id is required. " + "Bind fulcrum --toggle in desktop Settings."), + "Wayland portal rejection remains visible after backend ready"); + launcher.hotkey_failure_active = false; + launcher.set_status(tr("ready")); + check(!gtk_widget_get_visible(GTK_WIDGET(launcher.status)), + "normal ready status may clear after explicit hotkey-error recovery"); + // Regression: a normal interactive summon must not map the bare search + // placeholder before the first asynchronous backend result arrives. + launcher.hide(); + launcher.initial_results_ready = false; + launcher.show(); + check(!gtk_widget_get_visible(GTK_WIDGET(launcher.window)) && + launcher.summon_when_ready, + "startup query shell remains hidden before first native result rows"); + Launcher::SearchOutcome initial; + initial.ok = true; + initial.rows.push_back(parse_row( + {"settings.open", "Settings…", "Preferences", "Setting", "", "", "", ""})); + launcher.apply_search(std::move(initial)); + check(launcher.initial_results_ready && + gtk_widget_get_visible(GTK_WIDGET(launcher.window)), + "first native backend rows reveal the requested launcher exactly once"); + + // Error is also a completed first result: a failed backend search must + // display the actionable failure, not leave the resident process invisible. + launcher.hide(); + launcher.initial_results_ready = false; + launcher.show(); + Launcher::SearchOutcome failed; + failed.ok = false; + failed.error = "test query failure"; + launcher.apply_search(std::move(failed)); + check(launcher.initial_results_ready && + gtk_widget_get_visible(GTK_WIDGET(launcher.window)) && + has_label(GTK_WIDGET(launcher.status), "Search failed: test query failure"), + "initial search RPC errors reveal the launcher and visible diagnostic"); + launcher.set_status(tr("ready")); + + // A user-authorized login item creates the same backend but never opens + // a launcher on its own. Only the later interactive summon reveals it. + launcher.hide(); + g_login_background_start = true; + launcher.initial_results_ready = false; + Launcher::SearchOutcome login_background; + login_background.ok = true; + login_background.rows.push_back(parse_row( + {"settings.open", "Settings…", "", "Setting", "", "", "", ""})); + launcher.apply_search(std::move(login_background)); + check(launcher.initial_results_ready && + !gtk_widget_get_visible(GTK_WIDGET(launcher.window)) && + !launcher.summon_when_ready, + "login background receives native results without revealing the panel"); + g_login_background_start = false; + launcher.show(); + check(gtk_widget_get_visible(GTK_WIDGET(launcher.window)), + "the same resident launcher can still be summoned interactively"); + + // The GTK Settings window uses the same five-section information + // architecture as Windows/macOS, with a functional Commands content slot. + settings_build(); + check(GTK_IS_STACK(g_settings_form->stack), "GTK Settings has a real native page stack"); + for (auto const* page : {"general", "features", "ai", "commands", "sync"}) { + check(gtk_stack_get_child_by_name(GTK_STACK(g_settings_form->stack), page) != nullptr, + "settings stack contains platform-aligned category"); + } + check(g_settings_form->commands_list != nullptr && + has_label(GTK_WIDGET(g_settings_form->window), "Start at login"), + "Linux Commands and local startup controls are discoverable"); + gtk_window_destroy(g_settings_form->window); + delete g_settings_form; + g_settings_form = nullptr; + + // XDG Exec values are parsed as desktop-entry command syntax. AppImages + // can legitimately live in folders containing spaces and '%' characters; + // those bytes need quoting / field-code escaping, not a shell invocation. + std::string const special_exec = + fulcrum_desktop_exec_quote("/tmp/My Files/Fulcrum 50%.AppImage"); + check(special_exec == "\"/tmp/My Files/Fulcrum 50%%.AppImage\"", + "autostart Exec quotes paths and escapes percent field codes"); + + char const* saved_xdg = g_getenv("XDG_CONFIG_HOME"); + std::string const original_xdg = saved_xdg ? saved_xdg : ""; + bool const had_xdg = saved_xdg != nullptr; + gchar* temp_config = g_dir_make_tmp("fulcrum-autostart-ui-XXXXXX", nullptr); + check(temp_config != nullptr, "isolated XDG autostart fixture created"); + if (temp_config != nullptr) { + g_setenv("XDG_CONFIG_HOME", temp_config, TRUE); + std::string error; + check(fulcrum_set_autostart(true, executable_path().string(), error) && + fulcrum_autostart_enabled(), "XDG login item writes only after opt-in"); + gchar* saved_content = nullptr; + if (g_file_get_contents(fulcrum_autostart_path().string().c_str(), + &saved_content, nullptr, nullptr)) { + std::string const content(saved_content); + check(content.find("--background") != std::string::npos && + content.find("Exec=") != std::string::npos, + "Linux login item starts silently with a valid desktop Exec entry"); + g_free(saved_content); + } else { + check(false, "XDG login item content can be read back"); + } + check(fulcrum_set_autostart(false, executable_path().string(), error) && + !fulcrum_autostart_enabled(), "disabling login startup removes own entry"); + std::filesystem::remove_all(temp_config); + g_free(temp_config); + } + if (had_xdg) g_setenv("XDG_CONFIG_HOME", original_xdg.c_str(), TRUE); + else g_unsetenv("XDG_CONFIG_HOME"); // Check that the live X11 window actually advertises its program-chosen // position to the WM. The helper runs on every summon, not just startup. launcher.show(); @@ -115,6 +258,19 @@ int main() { launcher.refresh_results(); focus(launcher.window); pump(80); + graphene_rect_t glyph_bounds{}, entry_bounds{}; + bool const glyph_measured = gtk_widget_compute_bounds( + launcher.search_icon, GTK_WIDGET(launcher.window), &glyph_bounds); + bool const entry_measured = gtk_widget_compute_bounds( + GTK_WIDGET(launcher.search), GTK_WIDGET(launcher.window), &entry_bounds); + if (glyph_measured && entry_measured) { + auto const glyph_center = glyph_bounds.origin.y + glyph_bounds.size.height / 2.0; + auto const entry_center = entry_bounds.origin.y + entry_bounds.size.height / 2.0; + check(std::abs(glyph_center - entry_center) <= 3.0, + "search glyph and entry share one horizontal visual centerline"); + } else { + check(false, "native search glyph and editable entry must both lay out"); + } auto* scrolled = gtk_widget_get_ancestor(GTK_WIDGET(launcher.results), GTK_TYPE_SCROLLED_WINDOW); auto* first = gtk_list_box_get_row_at_index(launcher.results, 0); @@ -195,7 +351,7 @@ int main() { gtk_list_box_select_row(launcher.results, gtk_list_box_get_row_at_index(launcher.results, 11)); set_i18n_language("zh"); - check(std::string(gtk_search_entry_get_placeholder_text(launcher.search)).find( + check(std::string(gtk_entry_get_placeholder_text(launcher.search)).find( "搜索") != std::string::npos, "language changes update the existing search field immediately"); check(gtk_list_box_row_get_index(gtk_list_box_get_selected_row(launcher.results)) == 11, diff --git a/windows/App.xaml.cpp b/windows/App.xaml.cpp index b61c0b6e..a04a85b4 100644 --- a/windows/App.xaml.cpp +++ b/windows/App.xaml.cpp @@ -2,6 +2,7 @@ #include "App.xaml.h" #include "MainWindow.xaml.h" #include "WindowsInstance.h" +#include "Autostart.h" namespace winrt::RivetHost::implementation { @@ -30,8 +31,10 @@ void App::OnLaunched(Microsoft::UI::Xaml::LaunchActivatedEventArgs const&) { application_id); if (!instance_lease_->is_primary()) { // Reopen the resident launcher; never leave a second headless process. - if (HWND existing = fulcrum::windows::FindResidentWindow()) { - ::PostMessageW(existing, fulcrum::windows::kSummonResidentMessage, 0, 0); + if (!fulcrum::windows::BackgroundLaunch()) { + if (HWND existing = fulcrum::windows::FindResidentWindow()) { + ::PostMessageW(existing, fulcrum::windows::kSummonResidentMessage, 0, 0); + } } Microsoft::UI::Xaml::Application::Current().Exit(); return; diff --git a/windows/Autostart.h b/windows/Autostart.h new file mode 100644 index 00000000..bde02eed --- /dev/null +++ b/windows/Autostart.h @@ -0,0 +1,56 @@ +#pragma once +// Per-user Windows login item. Never add this to the synced backend schema. +#include +#include +#include + +namespace fulcrum::windows { +inline constexpr wchar_t kAutostartRegistry[] = + L"Software\\Microsoft\\Windows\\CurrentVersion\\Run"; +inline bool BackgroundLaunch() { + int argc = 0; + LPWSTR* argv = ::CommandLineToArgvW(::GetCommandLineW(), &argc); + if (argv == nullptr) return false; + bool const active = argc > 1 && ::lstrcmpiW(argv[1], L"--background") == 0; + ::LocalFree(argv); + return active; +} +inline bool AutostartEnabled() { + HKEY key = nullptr; + if (::RegOpenKeyExW(HKEY_CURRENT_USER, kAutostartRegistry, 0, KEY_READ, + &key) != ERROR_SUCCESS) return false; + wchar_t buffer[1024]{}; + DWORD size = sizeof(buffer), type = 0; + auto const status = ::RegQueryValueExW(key, L"Fulcrum", nullptr, &type, + reinterpret_cast(buffer), &size); + ::RegCloseKey(key); + return status == ERROR_SUCCESS && type == REG_SZ && + size >= sizeof(wchar_t) && buffer[0] != L'\0'; +} +inline bool SetAutostart(bool enabled) { + if (!enabled) { + HKEY key = nullptr; + if (::RegOpenKeyExW(HKEY_CURRENT_USER, kAutostartRegistry, 0, + KEY_SET_VALUE, &key) != ERROR_SUCCESS) return true; + auto const rc = ::RegDeleteValueW(key, L"Fulcrum"); + ::RegCloseKey(key); + return rc == ERROR_SUCCESS || rc == ERROR_FILE_NOT_FOUND; + } + wchar_t executable[32768]{}; + DWORD const chars = ::GetModuleFileNameW(nullptr, executable, 32768); + if (chars == 0 || chars >= 32768) return false; + std::wstring command = L"\"" + std::wstring(executable) + + L"\" --background"; + // Windows Run command lines must remain within the documented limit. + if (command.size() >= 260) return false; + HKEY key = nullptr; + if (::RegCreateKeyExW(HKEY_CURRENT_USER, kAutostartRegistry, 0, nullptr, + 0, KEY_SET_VALUE, nullptr, &key, nullptr) + != ERROR_SUCCESS) return false; + DWORD const bytes = static_cast((command.size() + 1) * sizeof(wchar_t)); + auto const rc = ::RegSetValueExW(key, L"Fulcrum", 0, REG_SZ, + reinterpret_cast(command.c_str()), bytes); + ::RegCloseKey(key); + return rc == ERROR_SUCCESS; +} +} // namespace fulcrum::windows diff --git a/windows/GeneratedBackend.hpp b/windows/GeneratedBackend.hpp index cb7a3956..69813a55 100644 --- a/windows/GeneratedBackend.hpp +++ b/windows/GeneratedBackend.hpp @@ -19,8 +19,8 @@ namespace rivet_app { inline constexpr char kModuleName[] = "backend"; inline constexpr char kEntryName[] = "start"; inline constexpr char kDisplayName[] = "Fulcrum"; -inline constexpr char kVersion[] = "0.14.0"; -inline constexpr std::int64_t kBuild = 20; +inline constexpr char kVersion[] = "0.14.1"; +inline constexpr std::int64_t kBuild = 21; inline constexpr char kIdentifier[] = "site.jrtx.fulcrum"; inline constexpr char kReleaseChannel[] = "stable"; diff --git a/windows/MainWindow.xaml.cpp b/windows/MainWindow.xaml.cpp index ea7e649e..5a17f3c1 100644 --- a/windows/MainWindow.xaml.cpp +++ b/windows/MainWindow.xaml.cpp @@ -1,5 +1,6 @@ #include "pch.h" #include "MainWindow.xaml.h" +#include "Autostart.h" #if __has_include("MainWindow.g.cpp") #include "MainWindow.g.cpp" #endif @@ -114,6 +115,22 @@ std::filesystem::path executable_path() { return std::filesystem::path(buffer); } +// MSI updates must replace the executable we are about to relaunch. If +// Fulcrum was unpacked from the portable ZIP, handing an MSI to Windows +// Installer would upgrade a different Program Files copy while reopening +// this old ZIP exe, so it would look like the update never took effect. +// The official x64 MSI installs at %ProgramFiles%\\Fulcrum\\RivetHost.exe. +bool running_official_msi_installation() { + wchar_t program_files[32768]{}; + auto const length = ::GetEnvironmentVariableW( + L"ProgramFiles", program_files, static_cast(std::size(program_files))); + if (length == 0 || length >= std::size(program_files)) return false; + auto const installed = std::filesystem::path(program_files) / + L"Fulcrum" / L"RivetHost.exe"; + std::error_code ec; + return std::filesystem::equivalent(executable_path(), installed, ec) && !ec; +} + std::string utf8(std::filesystem::path const& path) { auto const wide = path.wstring(); if (wide.empty()) { @@ -1038,6 +1055,21 @@ void MainWindow::FinishNativeSetup() { self->QuitApplication(); return 0; } + if (msg == fulcrum::windows::kAcceptanceSettingsMessage && + self != nullptr && is_acceptance_run()) { + self->ShowSettings(); + return 0; + } + if (msg == fulcrum::windows::kAcceptanceUpdateCheckMessage && + self != nullptr && is_acceptance_run()) { + self->CheckForUpdates(); + return 0; + } + if (msg == fulcrum::windows::kAcceptanceHideMessage && + self != nullptr && is_acceptance_run()) { + self->HideLauncher(); + return 0; + } if (msg == WM_NCHITTEST && self != nullptr) { // The borderless panel has no titlebar to drag by, // and the macOS panel stays movable through its @@ -1607,7 +1639,9 @@ void MainWindow::ApplyResults(std::vector rows) { UpdateEnterChips(); if (!startup_presented_) { startup_presented_ = true; - ShowLauncher(false); // show only after the first native rows are built + if (!fulcrum::windows::BackgroundLaunch()) { + ShowLauncher(false); // show only after the first native rows are built + } } } @@ -1719,8 +1753,12 @@ void MainWindow::RunSelected() { // ai-stream State drives the streaming panel. window->SetStatusOk(t("aiRunning")); } else if (status == "stay") { - // The result is meant to be read (update check, ...): the - // event already wrote the status line; keep the panel open. + // The backend has a new signed-feed candidate; refresh the + // visible update rows so Download appears without retyping. + // Do not replace an unrelated user query while the RPC ran. + if (row.id == "update.check" && window->QueryBox().Text() == L"update") { + window->SearchAsync(L"update"); + } } else if (status == "detail") { // FPP2 plugin detail: fetch the payload and show it in the // panel slot. The RPC runs off the UI thread; the result @@ -1748,9 +1786,13 @@ void MainWindow::RunSelected() { } }).detach(); } else if (status == "downloading") { - // The backend download thread owns the transfer; the host - // mirrors its progress into the status bar. - window->HideLauncher(); + // Keep the update workflow visible. The earlier implementation + // hid the launcher and wrote progress into its *hidden* footer, + // leaving users with no visible way to install the MSI. + window->SetStatusOk(L"Downloading verified update…"); + if (window->QueryBox().Text() == L"update") { + window->SearchAsync(L"update"); + } window->StartUpdateProgressPoll(); } else if (status == "quit-and-install" && row.id == "update.install") { @@ -1758,6 +1800,12 @@ void MainWindow::RunSelected() { if (!update_path) { throw std::runtime_error("downloaded update path is missing"); } + if (!running_official_msi_installation()) { + throw std::runtime_error( + "This copy was not launched from the official MSI installation. " + "Open the verified MSI from the updates folder to install " + "Fulcrum; the portable ZIP cannot be upgraded in place by MSI."); + } auto const msi = std::filesystem::path(to_wide(*update_path)); rivet::update::MsiHandoff::LaunchVerifiedInstaller( L"site.jrtx.fulcrum", msi, executable_path()); @@ -2034,11 +2082,19 @@ void MainWindow::AttachStatusBarCopyAffordance() { StatusText().ContextFlyout(footer_flyout); } -// Manual check from the tray: manual=true bypasses the 4-hour family -// throttle (the backend stamps the timestamp either way, per the update -// trigger contract). Results surface in the footer; the `update` launcher -// rows drive download and install from here. +// Open the real update search workflow from the tray, even when Fulcrum +// is resident and hidden. The footer alone is invisible in that state. +void MainWindow::OpenUpdatesPanel() { + ShowLauncher(); + if (!startup_presented_ || quitting_) return; + QueryBox().Text(L"update"); + SearchAsync(L"update"); +} + +// Manual check bypasses the shared four-hour silent-check throttle, but +// always returns the user to a visible, actionable update surface. void MainWindow::CheckForUpdates() { + OpenUpdatesPanel(); if (api_ == nullptr) { SetStatusError(L"Update check once the backend is ready."); return; @@ -2077,6 +2133,11 @@ void MainWindow::CheckForUpdates() { } else { window->SetStatusOk(line); } + // The check updates the candidate in the Racket backend. A stale + // WinUI result list otherwise continues to show only Check again. + if (window->QueryBox().Text() == L"update") { + window->SearchAsync(L"update"); + } } }); }).detach(); @@ -2126,6 +2187,11 @@ void MainWindow::StartUpdateProgressPoll() { dispatcher.TryEnqueue([weak]() mutable { if (auto window = weak.get()) { window->SetStatusOk(t("updateDownloadedStatusWin")); + // Download finished: replace the progress row with the real + // verified MSI Install row without requiring another query. + if (window->QueryBox().Text() == L"update") { + window->SearchAsync(L"update"); + } } }); break; @@ -2137,6 +2203,9 @@ void MainWindow::StartUpdateProgressPoll() { std::wstring const detail = message ? to_wide(*message) : t("updateUnknownError"); window->SetStatusError(t_format("updateDownloadFailed", {detail})); + if (window->QueryBox().Text() == L"update") { + window->SearchAsync(L"update"); + } } }); break; diff --git a/windows/MainWindow.xaml.h b/windows/MainWindow.xaml.h index 508911df..5d067885 100644 --- a/windows/MainWindow.xaml.h +++ b/windows/MainWindow.xaml.h @@ -123,14 +123,13 @@ struct MainWindow : MainWindowT { std::wstring last_status_; // Snippet auto-expansion (WH_KEYBOARD_LL): types the snippet text when // its keyword lands against a word boundary in any app. - // Updater 0.2 (in-app install stays macOS-only on this line): the silent - // launch check is throttled backend-side, and progress polling follows the - // backend's download thread, surfacing percent in the status bar. Once the - // artifact is downloaded the user gets the open-folder status; the - // "update" launcher rows drive check/download/reveal on this state. + // Windows in-app updates use the backend's signed manifest, verified MSI + // download, and Rivet's external MSI handoff. Keep the update panel visible + // and refresh actionable rows when the candidate or download state changes. void StartSilentUpdateCheck(); void StartUpdateProgressPoll(); - // Tray "Check for Updates…": manual check, result in the footer. + void OpenUpdatesPanel(); + // Tray "Check for Updates…" opens the panel and bypasses the silent throttle. void CheckForUpdates(); // About panel (tray menu + the `about` launcher row): one ContentDialog // with version, tagline, license and the site/issue links. diff --git a/windows/RivetHost.vcxproj b/windows/RivetHost.vcxproj index 7a4be22f..4909ddd8 100644 --- a/windows/RivetHost.vcxproj +++ b/windows/RivetHost.vcxproj @@ -73,7 +73,7 @@ stdcpp17 $(RIVET_RACKET_INCLUDE);$(RIVET_ROOT)\runtime\include;$(RIVET_ROOT)\platform\windows\runtime;$(RIVET_ROOT)\platform\windows\system;%(AdditionalIncludeDirectories) - $(RIVET_RACKET_IMPORT_LIB);%(AdditionalDependencies) + $(RIVET_RACKET_IMPORT_LIB);Shell32.lib;%(AdditionalDependencies) _DEBUG;%(PreprocessorDefinitions) @@ -97,6 +97,7 @@ App.xaml MainWindow.xaml + SettingsWindow.xaml diff --git a/windows/SettingsWindow.xaml b/windows/SettingsWindow.xaml index aae350d0..1dd22eca 100644 --- a/windows/SettingsWindow.xaml +++ b/windows/SettingsWindow.xaml @@ -3,9 +3,9 @@ xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"> - + - - + + + + + + + + + + + @@ -70,11 +83,25 @@ + AutomationProperties.AutomationId="settings-record-hotkey" + VerticalAlignment="Center" PlaceholderText="Focus and press a shortcut" + IsReadOnly="True" KeyDown="Hotkey_KeyDown" /> + + + + + + + + + + Text="Focus the hotkey box, then press Alt/Ctrl/Shift/Win plus a key. Esc cancels; a taken combo is rejected with an error." /> @@ -88,6 +115,9 @@ + + + @@ -172,6 +202,9 @@ + + + @@ -238,6 +271,21 @@ + + + + + + + + + + + + + + @@ -258,9 +306,12 @@ + + - - + + + diff --git a/windows/SettingsWindow.xaml.cpp b/windows/SettingsWindow.xaml.cpp index 6cb1b826..ae172b67 100644 --- a/windows/SettingsWindow.xaml.cpp +++ b/windows/SettingsWindow.xaml.cpp @@ -1,5 +1,6 @@ #include "pch.h" #include "SettingsWindow.xaml.h" +#include "Autostart.h" #include "GeneratedI18n.hpp" #if __has_include("SettingsWindow.g.cpp") #include "SettingsWindow.g.cpp" @@ -7,6 +8,7 @@ #include #include +#include namespace winrt::RivetHost::implementation { namespace { @@ -91,6 +93,38 @@ double number_or(std::string const& value, double fallback) { } } +// Canonical hotkey grammar shared with the Racket backend. Only modified +// keys are accepted; Esc cancels, and the backend rejects collisions. +std::string recorded_hotkey(winrt::Windows::System::VirtualKey key) { + unsigned const vk = static_cast(key); + if (vk == VK_ESCAPE) return ""; + std::string name; + if (vk >= 'A' && vk <= 'Z') { + name += static_cast(vk + ('a' - 'A')); + } else if (vk >= '0' && vk <= '9') { + name += static_cast(vk); + } else if (vk >= VK_F1 && vk <= VK_F12) { + name = "f" + std::to_string(vk - VK_F1 + 1); + } else { + switch (vk) { + case VK_SPACE: name = "space"; break; + case VK_TAB: name = "tab"; break; + case VK_OEM_COMMA: name = "comma"; break; + case VK_OEM_PERIOD: name = "period"; break; + case VK_OEM_MINUS: name = "minus"; break; + case VK_OEM_PLUS: name = "equal"; break; + default: return {}; + } + } + std::string modifiers; + if (::GetKeyState(VK_CONTROL) & 0x8000) modifiers += "ctrl+"; + if (::GetKeyState(VK_MENU) & 0x8000) modifiers += "alt+"; + if (::GetKeyState(VK_SHIFT) & 0x8000) modifiers += "shift+"; + if ((::GetKeyState(VK_LWIN) | ::GetKeyState(VK_RWIN)) & 0x8000) + modifiers += "win+"; + return modifiers.empty() ? std::string() : modifiers + name; +} + } // namespace SettingsWindow::SettingsWindow() { @@ -101,25 +135,58 @@ SettingsWindow::SettingsWindow() { // window is a document-style surface, not the launcher overlay. SystemBackdrop(mx::Media::MicaBackdrop()); if (auto appWindow = this->AppWindow()) { - appWindow.Resize(winrt::Windows::Graphics::SizeInt32{680, 840}); - // Titled Windows windows carry their icon left of the title; WinUI - // ships a blank unless one is set. Our exe embeds the app icon at - // resource ID 1 (rivet build), so pointing at the exe itself works - // in the staged tree and after install alike. - wchar_t exe_path[MAX_PATH]; - if (::GetModuleFileNameW(nullptr, exe_path, MAX_PATH) > 0) { - appWindow.SetIcon(exe_path); + appWindow.Resize(winrt::Windows::Graphics::SizeInt32{820, 580}); + // SetIcon(String) takes an .ico file, not the EXE path. Rivet embeds + // the branded RT_GROUP_ICON at numeric resource ID 1 (#333 upstream). + // Apply that icon to this secondary *titled* window explicitly. + if (HICON icon = ::LoadIconW(::GetModuleHandleW(nullptr), + MAKEINTRESOURCEW(1))) { + appWindow.SetIcon(winrt::Microsoft::UI::IconId{ + static_cast(reinterpret_cast(icon))}); } } } catch (...) { - // Backdrop, sizing and the titlebar icon are polish; the form works - // without them. + // The settings controls still work if composition is unavailable. } + SettingsNavigation().SelectedItem(SettingsNavigation().MenuItems().GetAt(0)); +} + +void SettingsWindow::NavigateTo(std::string const& section) { + GeneralSection().Visibility(section == "general" ? mx::Visibility::Visible : mx::Visibility::Collapsed); + FeaturesSection().Visibility(section == "features" ? mx::Visibility::Visible : mx::Visibility::Collapsed); + AiSection().Visibility(section == "ai" ? mx::Visibility::Visible : mx::Visibility::Collapsed); + CommandsSection().Visibility(section == "commands" ? mx::Visibility::Visible : mx::Visibility::Collapsed); + SyncSection().Visibility(section == "sync" ? mx::Visibility::Visible : mx::Visibility::Collapsed); + if (section == "commands") LoadBindings(); +} + +void SettingsWindow::Navigation_SelectionChanged( + mxc::NavigationView const&, + mxc::NavigationViewSelectionChangedEventArgs const& args) { + if (auto item = args.SelectedItem().try_as()) { + NavigateTo(tag_key(item)); + } +} + +void SettingsWindow::Hotkey_KeyDown( + winrt::Windows::Foundation::IInspectable const&, + mx::Input::KeyRoutedEventArgs const& args) { + if (loading_) return; + auto const spec = recorded_hotkey(args.Key()); + if (spec == "") { + auto const it = values_.find("hotkey"); + HotkeyBox().Text(to_wide(it == values_.end() ? "" : it->second)); + } else if (!spec.empty()) { + HotkeyBox().Text(to_wide(spec)); + Write("hotkey", spec); + } + args.Handled(true); } void SettingsWindow::Attach(rivet_app::API* api) { api_ = api; Load(); + if (CommandsSection().Visibility() == mx::Visibility::Visible) LoadBindings(); } void SettingsWindow::Load() { @@ -166,6 +233,7 @@ void SettingsWindow::Apply(std::map values, select_choice(ThemeBox(), get("theme")); HotkeyBox().Text(to_wide(get("hotkey"))); MaxResultsBox().Value(number_or(get("max-results"), 12)); + LoginStartupSwitch().IsOn(fulcrum::windows::AutostartEnabled()); ClipboardSwitch().IsOn(get("clipboard-enabled") == "on"); ClipboardLimitBox().Value(number_or(get("clipboard-limit"), 1000)); WebSearchSwitch().IsOn(get("web-search-enabled") == "on"); @@ -257,6 +325,140 @@ void SettingsWindow::RefreshAiStatus() { }).detach(); } + +void SettingsWindow::LoadBindings() { + if (api_ == nullptr) return; + auto const dispatcher = DispatcherQueue(); + auto const weak = get_weak(); + auto* api = api_; + std::thread([weak, api, dispatcher]() mutable { + std::vector> commands, bindings; + try { + commands = api->bindings_commands().get(); + bindings = api->bindings_list().get(); + } catch (...) { + dispatcher.TryEnqueue([weak] { + if (auto window = weak.get()) { + window->BindingErrorText().Text(L"Could not load command bindings."); + window->BindingErrorText().Visibility(mx::Visibility::Visible); + } + }); + return; + } + dispatcher.TryEnqueue([weak, commands = std::move(commands), + bindings = std::move(bindings)]() mutable { + if (auto window = weak.get()) + window->ShowBindings(std::move(commands), std::move(bindings)); + }); + }).detach(); +} + +void SettingsWindow::ShowBindings( + std::vector> commands, + std::vector> bindings) { + std::map> existing; + for (auto const& row : bindings) { + if (row.size() >= 3) existing[row[0]] = {row[1], row[2]}; + } + auto children = CommandRows().Children(); + children.Clear(); + for (auto const& command : commands) { + if (command.empty()) continue; + auto const id = command[0]; + auto const current = existing.find(id); + std::string const alias = current == existing.end() ? "" : current->second.first; + std::string const hotkey = current == existing.end() ? "" : current->second.second; + mxc::StackPanel row; + row.Orientation(mxc::Orientation::Horizontal); + row.Spacing(8); + mxc::TextBlock label; + label.Text(to_wide(command.size() >= 2 ? command[1] : id)); + label.Width(145); + label.TextTrimming(mx::TextTrimming::CharacterEllipsis); + label.VerticalAlignment(mx::VerticalAlignment::Center); + row.Children().Append(label); + mxc::TextBox aliasBox; + aliasBox.Width(105); + aliasBox.Text(to_wide(alias)); + aliasBox.PlaceholderText(L"alias"); + row.Children().Append(aliasBox); + mxc::TextBox hotkeyBox; + hotkeyBox.Width(125); + hotkeyBox.Text(to_wide(hotkey)); + hotkeyBox.PlaceholderText(L"Press keys"); + hotkeyBox.IsReadOnly(true); + hotkeyBox.KeyDown([hotkeyBox, previous = to_wide(hotkey)] + (auto const&, mx::Input::KeyRoutedEventArgs const& args) { + auto spec = recorded_hotkey(args.Key()); + if (spec == "") hotkeyBox.Text(previous); + else if (!spec.empty()) hotkeyBox.Text(to_wide(spec)); + args.Handled(true); + }); + row.Children().Append(hotkeyBox); + mxc::Button save; + save.Content(winrt::box_value(L"Save")); + save.Click([weak = get_weak(), id, aliasBox, hotkeyBox](auto const&, auto const&) { + if (auto window = weak.get()) + window->SaveBinding(id, trimmed(to_utf8(std::wstring(aliasBox.Text()))), + to_utf8(std::wstring(hotkeyBox.Text()))); + }); + row.Children().Append(save); + mxc::Button clear; + clear.Content(winrt::box_value(L"Clear")); + clear.Click([weak = get_weak(), id](auto const&, auto const&) { + if (auto window = weak.get()) window->ClearBinding(id); + }); + row.Children().Append(clear); + children.Append(row); + } +} + +void SettingsWindow::SaveBinding(std::string id, std::string alias, + std::string hotkey) { + if (!api_) return; + auto const weak = get_weak(); + auto const dispatcher = DispatcherQueue(); + auto* api = api_; + std::thread([weak, api, dispatcher, id, alias, hotkey]() mutable { + std::string error; + try { + if (!api->binding_set(id, alias, hotkey).get()) + error = "Binding rejected: shortcut already used, summon key, or unsupported command."; + } catch (std::exception const& e) { error = e.what(); } + catch (...) { error = "Could not save command binding."; } + dispatcher.TryEnqueue([weak, error] { + if (auto window = weak.get()) { + window->BindingErrorText().Text(to_wide(error)); + window->BindingErrorText().Visibility( + error.empty() ? mx::Visibility::Collapsed : mx::Visibility::Visible); + window->LoadBindings(); + } + }); + }).detach(); +} + +void SettingsWindow::ClearBinding(std::string id) { + if (!api_) return; + auto const weak = get_weak(); + auto const dispatcher = DispatcherQueue(); + auto* api = api_; + std::thread([weak, api, dispatcher, id]() mutable { + std::string error; + try { + if (!api->binding_clear(id).get()) error = "Could not clear command binding."; + } catch (std::exception const& e) { error = e.what(); } + catch (...) { error = "Could not clear command binding."; } + dispatcher.TryEnqueue([weak, error] { + if (auto window = weak.get()) { + window->BindingErrorText().Text(to_wide(error)); + window->BindingErrorText().Visibility( + error.empty() ? mx::Visibility::Collapsed : mx::Visibility::Visible); + window->LoadBindings(); + } + }); + }).detach(); +} + void SettingsWindow::SaveKey() { if (api_ == nullptr) { return; @@ -325,6 +527,17 @@ void SettingsWindow::Toggle_Toggled( if (toggle == nullptr) { return; } + if (tag_key(sender) == "local-login-startup") { + if (!fulcrum::windows::SetAutostart(toggle.IsOn())) { + ShowError(L"Could not change this PC's login startup setting."); + loading_ = true; + LoginStartupSwitch().IsOn(fulcrum::windows::AutostartEnabled()); + loading_ = false; + } else { + ClearError(); + } + return; + } Write(tag_key(sender), toggle.IsOn() ? "on" : "off"); } diff --git a/windows/SettingsWindow.xaml.h b/windows/SettingsWindow.xaml.h index 7bd91901..5500c072 100644 --- a/windows/SettingsWindow.xaml.h +++ b/windows/SettingsWindow.xaml.h @@ -6,6 +6,7 @@ #include #include +#include namespace winrt::RivetHost::implementation { @@ -23,6 +24,15 @@ struct SettingsWindow : SettingsWindowT { // show, which reloads). The API outlives this window: MainWindow owns it. void Attach(rivet_app::API* api); + // Native five-section settings IA. The same backend keys are used by + // macOS, Windows and Linux; only the host controls differ. + void Navigation_SelectionChanged( + Microsoft::UI::Xaml::Controls::NavigationView const&, + Microsoft::UI::Xaml::Controls::NavigationViewSelectionChangedEventArgs const&); + void Hotkey_KeyDown( + winrt::Windows::Foundation::IInspectable const&, + Microsoft::UI::Xaml::Input::KeyRoutedEventArgs const&); + // XAML handlers (SettingsWindow.xaml). void Toggle_Toggled(winrt::Windows::Foundation::IInspectable const&, Microsoft::UI::Xaml::RoutedEventArgs const&); @@ -44,6 +54,12 @@ struct SettingsWindow : SettingsWindowT { void Apply(std::map values, std::string ai_status); void Write(std::string key, std::string value); void RefreshAiStatus(); + void LoadBindings(); + void ShowBindings(std::vector> commands, + std::vector> bindings); + void SaveBinding(std::string id, std::string alias, std::string hotkey); + void ClearBinding(std::string id); + void NavigateTo(std::string const& section); void ApplyLocalization(std::string const& language); void SaveKey(); void CommitText(Microsoft::UI::Xaml::Controls::TextBox const& box); diff --git a/windows/WindowsInstance.h b/windows/WindowsInstance.h index f4ffb73a..5b8d747b 100644 --- a/windows/WindowsInstance.h +++ b/windows/WindowsInstance.h @@ -10,6 +10,9 @@ namespace fulcrum::windows { inline constexpr UINT kSummonResidentMessage = WM_APP + 5; inline constexpr UINT kAcceptanceQuitMessage = WM_APP + 6; +inline constexpr UINT kAcceptanceSettingsMessage = WM_APP + 7; +inline constexpr UINT kAcceptanceUpdateCheckMessage = WM_APP + 8; +inline constexpr UINT kAcceptanceHideMessage = WM_APP + 9; inline std::wstring InstanceId() { std::wstring id = L"site.jrtx.fulcrum";