From 0b71878be910f4e372e9d0a23a3b46c0b6706939 Mon Sep 17 00:00:00 2001 From: rgdevment Date: Fri, 25 Sep 2026 20:59:10 -0300 Subject: [PATCH 1/3] feat: a Mac offers to remove the 1.x it finds, and the resident holds the shortcut MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1.x ships under com.rgdevment.linkunbound and 2.x under dev.rgdevment.linkunbound, so Launch Services treats them as unrelated applications: the old copy keeps its own login item, starts at sign-in and goes on answering for links however 2.x registers. Windows removes it from the installer; a Mac is dragged in, so the settings window is the only place that can ask. Links now offers to retire a 1.x it finds: the copy goes to the trash, never deleted, and rules and browsers are untouched. A refusal — /Applications needs an administrator — hands the person to Finder. The command takes no path, so it cannot be asked to trash anything else. The global shortcut moves to the resident. It was claimed by the settings binary, whose only effect was to open the very window that had to be open for the claim to exist, and sign-in starts the resident alone: the preference did nothing. The resident claims it through global-hotkey and writes down what it holds, which is what the window now reads. Also: - The notice no longer takes the keyboard on a Mac. There is no window style for it there, so the application stops being the active one instead — unless a picker is up, which is the one moment it has to be. - Copy and private answer to Cmd on a Mac, where Ctrl+click is a right click. - The link channel refuses to open rather than fall back to a descriptor every account on the machine can read, with a test for both ways it can fail. - The uninstaller sweeps the OpenWithProgIds values it writes; without them "Open with" went on offering a program that is gone. A test fails if the list in the installer and the one in the code ever drift apart. - The diagnostics report is written in the language that was chosen, file name included, and lands on the Desktop of a Mac instead of its temporary folder. - One rule decides whether a version is worth offering. The strip compared semver alone, so it offered candidates the errand behind the button refused. - The resident's build script reads the target rather than the host, which is what made a cross build for a Mac embed a Windows manifest and die. - release: the tested gate waits forty minutes instead of twenty and the bundles wait for it; the tap token stays out of the clone's .git/config; the signing certificate leaves the runner store; the Store is asked what became of a submission that was tolerated, which is the case the question was written for. - scripts: the cache sweep sees past the hundredth cache. - save_prefs had no callers but its own tests; the stale version in app/package.json and the unread release-manifest.json at the root are gone. --- .github/workflows/release.yml | 43 +++++---- Cargo.lock | 18 +--- Cargo.toml | 1 + app/package-lock.json | 1 - app/package.json | 1 - app/src-tauri/Cargo.toml | 1 - app/src-tauri/nsis/hooks.nsh | 11 +++ app/src-tauri/src/lib.rs | 72 +++++++-------- app/src-tauri/src/shortcut.rs | 62 ------------- app/src-tauri/src/system.rs | 46 +++++++++- app/src-tauri/src/update.rs | 12 +-- app/src/Settings.test.tsx | 44 +++++++++ app/src/Settings.tsx | 81 +++++++++++++++++ app/src/i18n.ts | 14 +++ crates/linkunbound-core/src/i18n.rs | 16 ++++ crates/linkunbound-core/src/report.rs | 100 ++++++++++++++++----- crates/linkunbound-core/src/store.rs | 44 ++++++--- crates/linkunbound-core/src/update.rs | 17 ++++ crates/linkunbound-mac/Cargo.toml | 1 + crates/linkunbound-mac/src/legacy.rs | 77 ++++++++++++++++ crates/linkunbound-mac/src/lib.rs | 6 ++ crates/linkunbound-shell/Cargo.toml | 1 + crates/linkunbound-shell/build.rs | 2 +- crates/linkunbound-shell/src/hotkey.rs | 72 +++++++++++++++ crates/linkunbound-shell/src/lib.rs | 5 +- crates/linkunbound-shell/src/main.rs | 34 ++++++- crates/linkunbound-shell/src/single.rs | 57 ++++++++---- crates/linkunbound-shell/ui/shell.slint | 5 +- crates/linkunbound-win/src/registration.rs | 13 +++ release-manifest.json | 4 - scripts/generations.py | 2 +- 31 files changed, 651 insertions(+), 212 deletions(-) delete mode 100644 app/src-tauri/src/shortcut.rs create mode 100644 crates/linkunbound-mac/src/legacy.rs create mode 100644 crates/linkunbound-shell/src/hotkey.rs delete mode 100644 release-manifest.json diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5b7e46b..64c552d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -46,7 +46,7 @@ jobs: tested: name: Tested when it landed runs-on: ubuntu-latest - timeout-minutes: 25 + timeout-minutes: 45 permissions: contents: read actions: read @@ -58,7 +58,7 @@ jobs: env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - for attempt in $(seq 1 40); do + for attempt in $(seq 1 80); do if ! said=$(gh run list --workflow ci.yml --commit "$GITHUB_SHA" --event push \ --limit 1 --json status,conclusion \ --jq 'if length == 0 then "" else .[0] | "\(.status) \(.conclusion)" end' \ @@ -77,16 +77,16 @@ jobs: exit 1 ;; "") - echo "no CI run for ${GITHUB_SHA:0:8} yet (${attempt}/40)" + echo "no CI run for ${GITHUB_SHA:0:8} yet (${attempt}/80)" sleep 30 ;; *) - echo "CI on ${GITHUB_SHA:0:8} is still running (${attempt}/40)" + echo "CI on ${GITHUB_SHA:0:8} is still running (${attempt}/80)" sleep 30 ;; esac done - echo "::error::twenty minutes and CI on ${GITHUB_SHA:0:8} has not finished, or never \ + echo "::error::forty minutes and CI on ${GITHUB_SHA:0:8} has not finished, or never \ ran: a tag belongs on a commit that reached main and was tested there" exit 1 @@ -209,7 +209,7 @@ jobs: bundle-windows: name: linkunbound-windows-installer - needs: version + needs: [version, tested] runs-on: windows-latest timeout-minutes: 60 outputs: @@ -440,9 +440,18 @@ jobs: retention-days: 7 if-no-files-found: error + - name: Take the certificate back out of the store + if: always() && env.CERT_THUMBPRINT != '' + shell: pwsh + run: | + $gone = Get-ChildItem Cert:\CurrentUser\My | + Where-Object { $_.Thumbprint -eq $env:CERT_THUMBPRINT } + $gone | Remove-Item -Force -ErrorAction SilentlyContinue + Write-Host "removed $($gone.Count) certificate(s) from the runner store" + bundle-macos: name: linkunbound-macos-installer (${{ matrix.arch }}) - needs: version + needs: [version, tested] if: needs.version.outputs.macos == 'true' runs-on: macos-latest timeout-minutes: 60 @@ -1228,6 +1237,7 @@ jobs: if [ "$code" -ne 0 ] && grep -q "CommitStarted" publish.log; then echo "::warning::the Store took the submission and is processing it; the client lost \ the poll it was holding. Nothing here has to be sent again." + echo "tolerated=yes" >>"$GITHUB_OUTPUT" exit 0 fi if [ "$code" -eq 0 ]; then @@ -1236,13 +1246,15 @@ jobs: exit "$code" - name: What the Store made of it - if: always() && steps.gate.outputs.go == 'yes' && steps.sent.outcome == 'failure' + if: >- + always() && steps.gate.outputs.go == 'yes' + && (steps.sent.outcome == 'failure' || steps.sent.outputs.tolerated == 'yes') shell: bash run: | - echo "::warning::publishing failed before the Store took the submission; asking what state it is in" + echo "::warning::asking the Store what state the submission is in" msstore submission status "$STORE_APP_ID" || true - echo "::error::the package reached the Store but no commit was confirmed. Read the status \ - above before tagging again: a submission already committed must not be sent twice." + echo "::warning::the package reached the Store and no commit was confirmed here. Read the \ + status above before tagging again: a submission already committed must not be sent twice." homebrew: name: Homebrew tap @@ -1295,9 +1307,8 @@ jobs: ARM_SHA=$(sha256sum "/tmp/$DMG_ARM" | awk '{print $1}') INTEL_SHA=$(sha256sum "/tmp/$DMG_INTEL" | awk '{print $1}') - git clone https://github.com/rgdevment/homebrew-tap.git /tmp/tap - git -C /tmp/tap config --local http.extraheader \ - "AUTHORIZATION: basic $(printf 'x-access-token:%s' "$GIST_TOKEN" | base64 -w0)" + AUTH="AUTHORIZATION: basic $(printf 'x-access-token:%s' "$GIST_TOKEN" | base64 -w0)" + git -c http.extraheader="$AUTH" clone https://github.com/rgdevment/homebrew-tap.git /tmp/tap cd /tmp/tap git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" @@ -1368,11 +1379,11 @@ jobs: git commit -m "linkunbound ${VERSION}" for attempt in 1 2 3 4 5; do - if git push origin HEAD:main; then + if git -c http.extraheader="$AUTH" push origin HEAD:main; then exit 0 fi echo "push rejected (attempt ${attempt}/5); rebasing onto the concurrent release" - git fetch origin main + git -c http.extraheader="$AUTH" fetch origin main if ! git rebase origin/main; then git rebase --abort || true echo "::error::the tap moved under this commit and it will not rebase cleanly" diff --git a/Cargo.lock b/Cargo.lock index 3a1337a..d2c652e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2091,7 +2091,6 @@ dependencies = [ "objc2 0.6.4", "objc2-app-kit 0.3.2", "once_cell", - "serde", "thiserror 2.0.21", "windows-sys 0.59.0", "x11rb", @@ -3545,7 +3544,6 @@ dependencies = [ "serde_json", "tauri", "tauri-build", - "tauri-plugin-global-shortcut", "tauri-plugin-opener", "tauri-plugin-single-instance", "tauri-plugin-updater", @@ -3559,6 +3557,7 @@ name = "linkunbound-shell" version = "2.0.0" dependencies = [ "embed-manifest", + "global-hotkey", "i-slint-backend-testing", "interprocess", "linkunbound-core", @@ -6611,21 +6610,6 @@ dependencies = [ "walkdir", ] -[[package]] -name = "tauri-plugin-global-shortcut" -version = "2.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4dd9f4c5136c09cd962da0c86dc4accd4666db2ea591cf16e6597435843bd2b" -dependencies = [ - "global-hotkey", - "log", - "serde", - "serde_json", - "tauri", - "tauri-plugin", - "thiserror 2.0.21", -] - [[package]] name = "tauri-plugin-opener" version = "2.5.5" diff --git a/Cargo.toml b/Cargo.toml index ce359e1..b5944fa 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,6 +24,7 @@ slint = { version = "1.17", default-features = false, features = [ ] } slint-build = "1.17" tray-icon = "0.24" +global-hotkey = "0.8" muda = "0.19" interprocess = "2.4" # No tray feature: the resident owns the tray, through the `tray-icon` crate. diff --git a/app/package-lock.json b/app/package-lock.json index 2813e95..6551d92 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -6,7 +6,6 @@ "packages": { "": { "name": "linkunbound", - "version": "2.0.0", "license": "GPL-3.0-only", "dependencies": { "@tauri-apps/api": "^2", diff --git a/app/package.json b/app/package.json index 86c89c6..8b1c318 100644 --- a/app/package.json +++ b/app/package.json @@ -1,7 +1,6 @@ { "name": "linkunbound", "private": true, - "version": "2.0.0", "type": "module", "scripts": { "dev": "vite", diff --git a/app/src-tauri/Cargo.toml b/app/src-tauri/Cargo.toml index 67fb75e..af7a591 100644 --- a/app/src-tauri/Cargo.toml +++ b/app/src-tauri/Cargo.toml @@ -45,7 +45,6 @@ tauri.workspace = true tauri-plugin-single-instance.workspace = true -tauri-plugin-global-shortcut.workspace = true tauri-plugin-opener.workspace = true tauri-plugin-updater.workspace = true reqwest.workspace = true diff --git a/app/src-tauri/nsis/hooks.nsh b/app/src-tauri/nsis/hooks.nsh index 2c916ce..efaab50 100644 --- a/app/src-tauri/nsis/hooks.nsh +++ b/app/src-tauri/nsis/hooks.nsh @@ -142,6 +142,17 @@ FunctionEnd DeleteRegKey HKCU "Software\Clients\StartMenuInternet\LinkUnbound" DeleteRegKey HKCU "Software\LinkUnbound" DeleteRegValue HKCU "Software\RegisteredApplications" "LinkUnbound" + + DeleteRegValue HKCU "Software\Classes\.htm\OpenWithProgIds" "LinkUnboundURL" + DeleteRegValue HKCU "Software\Classes\.html\OpenWithProgIds" "LinkUnboundURL" + DeleteRegValue HKCU "Software\Classes\.xhtml\OpenWithProgIds" "LinkUnboundURL" + DeleteRegValue HKCU "Software\Classes\.xht\OpenWithProgIds" "LinkUnboundURL" + DeleteRegValue HKCU "Software\Classes\.pdf\OpenWithProgIds" "LinkUnboundURL" + DeleteRegValue HKCU "Software\Classes\.svg\OpenWithProgIds" "LinkUnboundURL" + DeleteRegValue HKCU "Software\Classes\.mhtml\OpenWithProgIds" "LinkUnboundURL" + DeleteRegValue HKCU "Software\Classes\.mht\OpenWithProgIds" "LinkUnboundURL" + DeleteRegValue HKCU "Software\Classes\.shtml\OpenWithProgIds" "LinkUnboundURL" + DeleteRegValue HKCU "Software\Classes\.webp\OpenWithProgIds" "LinkUnboundURL" ${EndIf} ; The sign-in entry names the resident being removed; left behind, it runs nothing every diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs index 8b902d9..ca91e1d 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -1,11 +1,8 @@ mod shell; mod shop; -mod shortcut; mod system; mod update; -use std::sync::Mutex; - use linkunbound_core::{ Asking, Browser, Language, Preferences, Rule, Scope, Store, Strings, Target, host_of, merge, normalise, site_of, @@ -13,13 +10,6 @@ use linkunbound_core::{ use serde::Serialize; use tauri::{AppHandle, Emitter, Manager}; -/// The combination actually claimed, which the settings screen needs and only -/// the registration knows. -#[derive(Default)] -struct Held { - shortcut: Option, -} - #[cfg(any(windows, target_os = "macos"))] fn icons_dir() -> std::path::PathBuf { linkunbound_core::data_dir().join("icons") @@ -554,7 +544,6 @@ fn maintenance_reset() -> Result<(), String> { .map_err(|e| e.to_string()) } -/// The shortcut the app really holds, which is not always the one asked for. #[derive(Serialize)] struct Settings { prefs: Preferences, @@ -563,29 +552,17 @@ struct Settings { language: &'static str, } -fn claim(app: &AppHandle, prefs: &Preferences) -> Settings { - let shortcut_held = shortcut::install(app, prefs.shortcut.as_deref()); - if let Ok(mut held) = app.state::>().lock() { - held.shortcut.clone_from(&shortcut_held); - } +fn claim(prefs: &Preferences) -> Settings { Settings { language: spoken(prefs), prefs: prefs.clone(), - shortcut_held, + shortcut_held: store().shortcut_held(), } } -/// Reads without touching the registration: the picker asks for this too, and -/// re-claiming the combination on every link would be gratuitous. #[tauri::command] -fn prefs_get(state: tauri::State<'_, Mutex>) -> Settings { - let prefs = store().prefs(); - let shortcut_held = state.lock().ok().and_then(|h| h.shortcut.clone()); - Settings { - language: spoken(&prefs), - prefs, - shortcut_held, - } +fn prefs_get() -> Settings { + claim(&store().prefs()) } fn spoken(prefs: &Preferences) -> &'static str { @@ -607,7 +584,7 @@ fn prefs_set(app: AppHandle, prefs: Preferences) -> Result { }) .map_err(|e| e.to_string())?; shell::repaint(&app, settled.theme); - Ok(claim(&app, &settled)) + Ok(claim(&settled)) } #[tauri::command] @@ -681,21 +658,26 @@ fn maintenance_report() -> Result { ), ]; let store = store(); + let prefs = store.prefs(); + let words = Language::chosen(prefs.locale).strings(); let body = linkunbound_core::diagnostics( env!("CARGO_PKG_VERSION"), &facts, &store.rules().unwrap_or_default(), - &store.prefs(), + &prefs, + &words, ); + let named = format!("{}.md", words.report_file); let target = std::env::var_os("USERPROFILE") + .or_else(|| std::env::var_os("HOME")) .map_or_else(std::env::temp_dir, std::path::PathBuf::from) .join("Desktop") - .join("linkunbound-diagnostico.md"); + .join(&named); let target = if target.parent().is_some_and(std::path::Path::is_dir) { target } else { - std::env::temp_dir().join("linkunbound-diagnostico.md") + std::env::temp_dir().join(&named) }; std::fs::write(&target, body).map_err(|e| e.to_string())?; Ok(target.to_string_lossy().into_owned()) @@ -706,6 +688,21 @@ fn system_state() -> system::SystemState { system::state() } +#[tauri::command] +fn system_legacy() -> Option { + system::legacy() +} + +#[tauri::command(async)] +fn system_retire_legacy() -> Result<(), String> { + system::retire_legacy() +} + +#[tauri::command] +fn system_reveal_legacy() { + system::reveal_legacy(); +} + #[tauri::command(async)] fn system_set_registered(enabled: bool) -> Result { system::set_registered(enabled) @@ -1261,7 +1258,6 @@ pub fn run() { let errand = errand_in(&args); let builder = tauri::Builder::default() - .manage(Mutex::new(Held::default())) .manage(Updating::default()) .invoke_handler(tauri::generate_handler![ rules_list, @@ -1281,6 +1277,9 @@ pub fn run() { prefs_get, prefs_set, system_state, + system_legacy, + system_retire_legacy, + system_reveal_legacy, system_set_registered, system_set_startup, system_open_default_apps, @@ -1296,12 +1295,8 @@ pub fn run() { star_done ]) .plugin(tauri_plugin_opener::init()) - .plugin(tauri_plugin_updater::Builder::new().build()) - .plugin(tauri_plugin_global_shortcut::Builder::new().build()); + .plugin(tauri_plugin_updater::Builder::new().build()); - // Two tray clicks used to mean two processes, each writing the registry and each claiming - // the shortcut. The second now raises the first. An errand is not a second copy of the - // window, though: it must neither knock on the one that is open nor answer knocks itself. let builder = if errand.is_some() { builder } else { @@ -1333,9 +1328,6 @@ pub fn run() { } system::reconcile(); - // The resident owns the tray and the shortcut; this binary is only - // the settings window, opened and closed on demand. - claim(app.handle(), &store().prefs()); #[cfg(target_os = "macos")] relay_links(app.handle().clone()); shell::open_settings(app.handle(), store().prefs().theme); diff --git a/app/src-tauri/src/shortcut.rs b/app/src-tauri/src/shortcut.rs deleted file mode 100644 index a7bf8b5..0000000 --- a/app/src-tauri/src/shortcut.rs +++ /dev/null @@ -1,62 +0,0 @@ -use tauri::{AppHandle, Runtime}; -use tauri_plugin_global_shortcut::{GlobalShortcutExt, Shortcut, ShortcutState}; - -/// Returns what was actually claimed. `None` means the user turned it off, or -/// that something else already holds the combination — the plugin refuses it -/// rather than stealing it, and the settings screen has to say so. -pub fn install(app: &AppHandle, wanted: Option<&str>) -> Option { - let manager = app.global_shortcut(); - let _ = manager.unregister_all(); - - let combination = wanted?; - let parsed = combination.parse::().ok()?; - manager - .on_shortcut(parsed, |app, _shortcut, event| { - if event.state() == ShortcutState::Pressed { - crate::shell::open_settings(app, crate::store().prefs().theme); - } - }) - .ok()?; - Some(combination.to_owned()) -} - -#[cfg(test)] -mod tests { - use linkunbound_core::Preferences; - use tauri_plugin_global_shortcut::Shortcut; - - /// The default lives in the core, which cannot ask the plugin whether the - /// combination it ships is one the system will accept. - #[test] - fn the_default_the_core_ships_is_one_the_system_can_parse() { - let shipped = Preferences::default() - .shortcut - .expect("a fresh install claims one"); - assert!(shipped.parse::().is_ok()); - } - - /// What the settings screen sends comes from a key capture, so it has to - /// survive the same parser the registration uses. - #[test] - fn the_combinations_a_capture_can_produce_all_parse() { - for combination in [ - "Alt+Shift+L", - "Control+Alt+B", - "Control+Shift+Space", - "Super+L", - "Control+Alt+Digit1", - "F9", - ] { - assert!( - combination.parse::().is_ok(), - "{combination} should parse" - ); - } - } - - #[test] - fn nonsense_is_refused_instead_of_claiming_something_else() { - assert!("".parse::().is_err()); - assert!("Shift".parse::().is_err()); - } -} diff --git a/app/src-tauri/src/system.rs b/app/src-tauri/src/system.rs index f89fbf8..b13ad4c 100644 --- a/app/src-tauri/src/system.rs +++ b/app/src-tauri/src/system.rs @@ -49,6 +49,12 @@ pub struct Association { pub held: bool, } +#[derive(Debug, Clone, Serialize)] +pub struct Legacy { + pub path: String, + pub version: Option, +} + #[cfg(windows)] mod platform { use super::Health; @@ -233,6 +239,17 @@ mod platform { pub fn browsers() -> Vec { installed_browsers() } + + pub fn legacy() -> Option { + None + } + + pub fn retire_legacy() -> Result<(), String> { + Err("notOnThisPlatform".to_owned()) + } + + pub fn reveal_legacy() {} + #[cfg(test)] mod tests { use super::super::Health; @@ -410,6 +427,21 @@ mod platform { linkunbound_mac::installed_browsers() } + pub fn legacy() -> Option { + linkunbound_mac::legacy_installed().map(|found| super::Legacy { + path: found.path, + version: found.version, + }) + } + + pub fn retire_legacy() -> Result<(), String> { + linkunbound_mac::retire_legacy() + } + + pub fn reveal_legacy() { + linkunbound_mac::reveal_legacy(); + } + #[cfg(test)] mod tests { use super::super::Health; @@ -520,9 +552,19 @@ mod platform { pub fn open_default_apps() -> Result<(), String> { Err("notOnThisPlatform".to_owned()) } + + pub fn legacy() -> Option { + None + } + + pub fn retire_legacy() -> Result<(), String> { + Err("notOnThisPlatform".to_owned()) + } + + pub fn reveal_legacy() {} } pub use platform::{ - browsers, open_default_apps, reconcile, register_anyway, register_for_the_installer, - set_registered, set_starts_with_system, state, + browsers, legacy, open_default_apps, reconcile, register_anyway, register_for_the_installer, + retire_legacy, reveal_legacy, set_registered, set_starts_with_system, state, }; diff --git a/app/src-tauri/src/update.rs b/app/src-tauri/src/update.rs index 8bfab87..48e02fc 100644 --- a/app/src-tauri/src/update.rs +++ b/app/src-tauri/src/update.rs @@ -178,14 +178,10 @@ fn offered(version: String, kept: Kept) -> Ready { /// whatever was true when the answer was written down. #[must_use] pub fn remembered(now: &str, said: Option<&str>, kept: Kept, wants: Option) -> Option { - let here: semver::Version = now.parse().ok()?; - let kept_version: semver::Version = said?.parse().ok()?; - - if !tracking(now, wants) && !kept_version.pre.is_empty() { - return None; - } - - (kept_version > here).then(|| offered(kept_version.to_string(), kept)) + let said = said?; + let found: semver::Version = said.parse().ok()?; + linkunbound_core::update::worth_offering(said, now, wants) + .then(|| offered(found.to_string(), kept)) } /// A clock put back leaves the last look in the future, and a copy that only counts forward from diff --git a/app/src/Settings.test.tsx b/app/src/Settings.test.tsx index 3e1bfbb..40d0bc8 100644 --- a/app/src/Settings.test.tsx +++ b/app/src/Settings.test.tsx @@ -52,6 +52,7 @@ function answers( if (cmd === "rules_list") return Promise.resolve([]); if (cmd === "prefs_get" || cmd === "prefs_set") return Promise.resolve(PREFS); if (cmd === "update_ready") return Promise.resolve(null); + if (cmd === "system_legacy") return Promise.resolve(null); if (cmd === "about") return Promise.resolve(BUILD); return Promise.resolve(state); }); @@ -364,6 +365,49 @@ describe("settings", () => { await go("Aplicación"); expect(await screen.findByText(/Ítems de inicio/)).toBeInTheDocument(); }); + + it("offers to remove a 1.x still installed, and says where it is", async () => { + answers(BASE, { + system_legacy: () => + Promise.resolve({ path: "/Applications/LinkUnbound.app", version: "1.4.0" }), + }); + render(); + expect(await screen.findByText(/LinkUnbound 1\.4\.0 instalado/)).toBeInTheDocument(); + expect(screen.getByText(/Applications/)).toBeInTheDocument(); + await userEvent.click(screen.getByRole("button", { name: "Quitar la versión anterior" })); + await waitFor(() => expect(invoke).toHaveBeenCalledWith("system_retire_legacy")); + expect(await screen.findByText(/está en la papelera/)).toBeInTheDocument(); + }); + + it("sends to Finder when the trash refuses the old copy", async () => { + answers(BASE, { + system_legacy: () => + Promise.resolve({ path: "/Applications/LinkUnbound.app", version: "1.4.0" }), + system_retire_legacy: () => Promise.reject("no permission"), + }); + render(); + await userEvent.click( + await screen.findByRole("button", { name: "Quitar la versión anterior" }), + ); + await userEvent.click(await screen.findByRole("button", { name: "Mostrarla en el Finder" })); + expect(invoke).toHaveBeenCalledWith("system_reveal_legacy"); + }); + + it("says nothing about 1.x when there is none installed", async () => { + answers(BASE); + render(); + await screen.findByText(/recibe los enlaces/); + expect(screen.queryByText(/instalado/)).toBeNull(); + }); + }); + + it("never offers to remove 1.x on Windows", async () => { + answers(BASE, { + system_legacy: () => Promise.resolve({ path: "C:/x/LinkUnbound.exe", version: "1.4.0" }), + }); + render(); + await screen.findByRole("heading", { name: "Enlaces" }); + expect(invoke).not.toHaveBeenCalledWith("system_legacy"); }); it("counts the associations it holds against the ones a browser is asked to carry", async () => { diff --git a/app/src/Settings.tsx b/app/src/Settings.tsx index 0509dc7..25054c6 100644 --- a/app/src/Settings.tsx +++ b/app/src/Settings.tsx @@ -36,6 +36,8 @@ type Spoken = { language: string }; /// Only the one field this screen touches; the rest travels back untouched. type Noticed = { prefs: { edge_warning_dismissed: boolean } & Record }; +type Legacy = { path: string; version: string | null }; + const RELEASES = "https://github.com/rgdevment/LinkUnbound/releases/latest"; type Remedy = { label: Key; note?: Key } & { command: string; args?: Record }; @@ -145,6 +147,84 @@ const ABOUT: Entry = { ), }; +function Legacy1x({ onRetired }: { onRetired: () => void }) { + const t = useWords(); + const language = useSpoken(); + const [found, setFound] = useState(null); + const [problem, setProblem] = useState(null); + const [retired, setRetired] = useState(false); + const [away, setAway] = useState(false); + + useEffect(() => { + if (platform() !== "macos") return; + void invoke("system_legacy") + .then((said) => setFound(said?.path ? said : null)) + .catch(noop); + }, []); + + const retire = () => { + setProblem(null); + void invoke("system_retire_legacy") + .then(() => { + setFound(null); + setRetired(true); + onRetired(); + }) + .catch((e: unknown) => setProblem(saidPlainly(language, e))); + }; + + if (away) return null; + + return ( +
+ {found && ( +
+

+ {t("legacyTitle", found.version ?? "1.x")} +

+

+ {t("legacyBody")} +

+

+ +

+
+ + +
+ {problem && ( +
+

{problem}

+ +
+ )} +
+ )} + {retired && ( +

{t("legacyGone")}

+ )} +
+ ); +} + function Links({ state, change, @@ -177,6 +257,7 @@ function Links({ return ( <> + run("system_state")} /> {ailment && (

diff --git a/app/src/i18n.ts b/app/src/i18n.ts index b396a2f..5be6960 100644 --- a/app/src/i18n.ts +++ b/app/src/i18n.ts @@ -46,6 +46,13 @@ const ES = { edgeRelief: "Lo que sí hace: cuando uno de esos enlaces llega envuelto en un enlace protegido, LinkUnbound lo desenvuelve y te ofrece el destino real en lugar del intermediario.", edgeUnderstood: "Entendido, no volver a mostrarlo", + legacyTitle: "Tienes LinkUnbound {} instalado", + legacyBody: + "Arranca al iniciar sesión y se ofrece para los enlaces igual que esta versión, porque macOS las ve como dos aplicaciones distintas. Tus reglas y tus navegadores se conservan.", + legacyGo: "Quitar la versión anterior", + legacyLater: "Ahora no", + legacyReveal: "Mostrarla en el Finder", + legacyGone: "La versión anterior está en la papelera.", kindAny: "Cualquiera", kindUrl: "Esta URL", @@ -305,6 +312,13 @@ const EN: Record = { edgeRelief: "What it does do: when one of those links arrives wrapped in a protected link, LinkUnbound unwraps it and offers you the real destination instead of the middleman.", edgeUnderstood: "Got it, do not show this again", + legacyTitle: "LinkUnbound {} is still installed", + legacyBody: + "It starts at sign-in and offers itself for links just as this version does, because macOS sees the two as different applications. Your rules and your browsers are kept.", + legacyGo: "Remove the older version", + legacyLater: "Not now", + legacyReveal: "Show it in Finder", + legacyGone: "The older version is in the Trash.", kindAny: "Anything", kindUrl: "This URL", diff --git a/crates/linkunbound-core/src/i18n.rs b/crates/linkunbound-core/src/i18n.rs index 4df2ab9..9de4d12 100644 --- a/crates/linkunbound-core/src/i18n.rs +++ b/crates/linkunbound-core/src/i18n.rs @@ -62,6 +62,22 @@ catalogue! { fail_profile_gone: "El perfil {} ya no existe" | "The profile {} is no longer there", fail_private_impossible: "Este navegador no puede abrir una ventana privada" | "This browser cannot open a private window", fail_spawn: "El navegador no arrancó" | "The browser would not start", + report_system: "Sistema" | "System", + report_prefs: "Preferencias" | "Preferences", + report_rules: "Reglas" | "Rules", + report_theme: "tema" | "theme", + report_locale: "idioma" | "language", + report_shortcut: "atajo" | "shortcut", + report_notify: "avisa al aplicar una regla" | "says so when a rule decides", + report_nothing: "ninguno" | "none", + report_any_link: "cualquier enlace" | "any link", + report_the_link: "el enlace {}" | "the link {}", + report_the_host: "el host {}" | "the host {}", + report_the_site: "el sitio {}" | "the site {}", + report_from: " desde {}" | " from {}", + report_private: " en privado" | " privately", + report_redacted: "[redactado]" | "[redacted]", + report_file: "linkunbound-diagnostico" | "linkunbound-diagnostics", } #[derive(Debug, Clone, Copy, PartialEq, Eq)] diff --git a/crates/linkunbound-core/src/report.rs b/crates/linkunbound-core/src/report.rs index 9333063..af92905 100644 --- a/crates/linkunbound-core/src/report.rs +++ b/crates/linkunbound-core/src/report.rs @@ -1,11 +1,11 @@ -use crate::{Preferences, RuleSet, Scope}; +use crate::{Preferences, RuleSet, Scope, Strings}; /// A report is meant to be pasted into a public issue, so nothing that names a /// place the user has been may survive it: the host stays, the rest goes. #[must_use] -pub fn redact(url: &str) -> String { +pub fn redact(url: &str, instead: &str) -> String { let Some((scheme, rest)) = url.split_once("://") else { - return "[redactado]".to_owned(); + return instead.to_owned(); }; let host = rest .split(['/', '?', '#']) @@ -15,7 +15,7 @@ pub fn redact(url: &str) -> String { .next() .unwrap_or_default(); if host.is_empty() { - return "[redactado]".to_owned(); + return instead.to_owned(); } if rest.len() > host.len() { return format!("{scheme}://{host}/…"); @@ -23,12 +23,12 @@ pub fn redact(url: &str) -> String { format!("{scheme}://{host}") } -fn describe(scope: &Scope) -> String { +fn describe(scope: &Scope, words: &Strings) -> String { match scope { - Scope::Any => "cualquier enlace".to_owned(), - Scope::Url(u) => format!("el enlace {}", redact(u)), - Scope::Host(h) => format!("el host {h}"), - Scope::Site(d) => format!("el sitio {d}"), + Scope::Any => words.report_any_link.to_owned(), + Scope::Url(u) => Strings::fill(words.report_the_link, &redact(u, words.report_redacted)), + Scope::Host(h) => Strings::fill(words.report_the_host, h), + Scope::Site(d) => Strings::fill(words.report_the_site, d), } } @@ -40,30 +40,44 @@ pub fn diagnostics( system: &[(String, String)], rules: &RuleSet, prefs: &Preferences, + words: &Strings, ) -> String { - let mut out = format!("LinkUnbound {version}\n\n## Sistema\n"); + let mut out = format!("LinkUnbound {version}\n\n## {}\n", words.report_system); for (key, value) in system { out.push_str(&format!("- {key}: {value}\n")); } out.push_str(&format!( - "\n## Preferencias\n- tema: {:?}\n- idioma: {:?}\n- atajo: {}\n- avisa al aplicar una regla: {}\n", + "\n## {}\n- {}: {:?}\n- {}: {:?}\n- {}: {}\n- {}: {}\n", + words.report_prefs, + words.report_theme, prefs.theme, + words.report_locale, prefs.locale, - prefs.shortcut.as_deref().unwrap_or("ninguno"), + words.report_shortcut, + prefs.shortcut.as_deref().unwrap_or(words.report_nothing), + words.report_notify, prefs.notify_on_rule, )); - out.push_str(&format!("\n## Reglas ({})\n", rules.rules.len())); + out.push_str(&format!( + "\n## {} ({})\n", + words.report_rules, + rules.rules.len() + )); for rule in &rules.rules { let origin = rule .source_app .as_ref() - .map_or(String::new(), |a| format!(" desde {a}")); - let private = if rule.private { " en privado" } else { "" }; + .map_or(String::new(), |a| Strings::fill(words.report_from, a)); + let private = if rule.private { + words.report_private + } else { + "" + }; out.push_str(&format!( "- {}{origin} → {}{private}\n", - describe(&rule.scope), + describe(&rule.scope, words), rule.target.browser_id, )); } @@ -73,21 +87,23 @@ pub fn diagnostics( #[cfg(test)] mod tests { use super::{diagnostics, redact}; - use crate::{Preferences, Rule, RuleSet, Scope, Target}; + use crate::{Language, Preferences, Rule, RuleSet, Scope, Target}; + + const GONE: &str = "[redactado]"; #[test] fn a_link_keeps_its_host_and_loses_everywhere_it_leads() { assert_eq!( - redact("https://intranet.corp/informes/2026/sueldos?id=44"), + redact("https://intranet.corp/informes/2026/sueldos?id=44", GONE), "https://intranet.corp/…" ); - assert_eq!(redact("https://example.test"), "https://example.test"); + assert_eq!(redact("https://example.test", GONE), "https://example.test"); } /// Credentials in the authority would ride along with the host otherwise. #[test] fn a_password_in_the_url_never_reaches_the_report() { - let out = redact("https://ana:hunter2@intranet.corp/x"); + let out = redact("https://ana:hunter2@intranet.corp/x", GONE); assert!(!out.contains("hunter2")); assert!(!out.contains("ana")); assert!(out.contains("intranet.corp")); @@ -95,8 +111,8 @@ mod tests { #[test] fn something_that_is_not_a_link_is_dropped_whole() { - assert_eq!(redact("C:/Users/Ana/secreto.pdf"), "[redactado]"); - assert_eq!(redact("https://"), "[redactado]"); + assert_eq!(redact("C:/Users/Ana/secreto.pdf", GONE), GONE); + assert_eq!(redact("https://", GONE), GONE); } #[test] @@ -114,7 +130,13 @@ mod tests { private: true, }], }; - let out = diagnostics("2.0.0", &[], &rules, &Preferences::default()); + let out = diagnostics( + "2.0.0", + &[], + &rules, + &Preferences::default(), + &Language::Spanish.strings(), + ); assert!(out.contains("mail.corp")); assert!(!out.contains("token=abc")); assert!(!out.contains("inbox")); @@ -122,6 +144,37 @@ mod tests { assert!(out.contains("en privado")); } + #[test] + fn the_report_is_written_in_the_language_that_was_chosen() { + let rules = RuleSet { + schema_version: 2, + rules: vec![Rule { + id: "site:x".to_owned(), + scope: Scope::Site("example.test".to_owned()), + source_app: Some("teams".to_owned()), + target: Target { + browser_id: "firefox".to_owned(), + profile_id: None, + }, + private: true, + }], + }; + let out = diagnostics( + "2.0.0", + &[], + &rules, + &Preferences::default(), + &Language::English.strings(), + ); + assert!(out.contains("## System"), "{out}"); + assert!(out.contains("## Preferences")); + assert!(out.contains("## Rules (1)")); + assert!(out.contains("the site example.test from teams")); + assert!(out.contains("privately")); + assert!(!out.contains("desde")); + assert!(!out.contains("Sistema")); + } + #[test] fn the_report_carries_the_system_facts_it_was_given() { let facts = vec![("navegador predeterminado".to_owned(), "no".to_owned())]; @@ -130,6 +183,7 @@ mod tests { &facts, &RuleSet::default(), &Preferences::default(), + &Language::Spanish.strings(), ); assert!(out.contains("LinkUnbound 2.0.0")); assert!(out.contains("- navegador predeterminado: no")); diff --git a/crates/linkunbound-core/src/store.rs b/crates/linkunbound-core/src/store.rs index 8b1d9ae..011e6fa 100644 --- a/crates/linkunbound-core/src/store.rs +++ b/crates/linkunbound-core/src/store.rs @@ -220,6 +220,28 @@ impl Store { self.dir.join("preferences.json") } + fn held_path(&self) -> PathBuf { + self.dir.join("shortcut") + } + + #[must_use] + pub fn shortcut_held(&self) -> Option { + let said = fs::read_to_string(self.held_path()).ok()?; + let said = said.trim(); + (!said.is_empty()).then(|| said.to_owned()) + } + + pub fn hold_shortcut(&self, held: Option<&str>) { + match held { + Some(said) => { + let _ = save_atomically(&self.held_path(), said); + } + None => { + let _ = fs::remove_file(self.held_path()); + } + } + } + fn browsers_path(&self) -> PathBuf { self.dir.join("browsers.json") } @@ -281,17 +303,6 @@ impl Store { ) } - pub fn save_prefs(&self, prefs: &crate::Preferences) -> Result<(), StoreError> { - let body = serde_json::to_string_pretty(prefs).map_err(|source| StoreError::Content { - path: self.prefs_path(), - source: crate::ConfigError::Malformed(source), - })?; - guarded(&self.prefs_path(), || { - keep_the_unread(&self.prefs_path()); - save_atomically(&self.prefs_path(), &body) - }) - } - pub fn edit_prefs( &self, edit: impl FnOnce(&mut crate::Preferences) -> bool, @@ -453,7 +464,12 @@ mod tests { notify_on_rule: false, ..Default::default() }; - store.save_prefs(&prefs).expect("saved"); + store + .edit_prefs(|kept| { + *kept = prefs.clone(); + true + }) + .expect("saved"); let read = Store::at(&dir).prefs(); assert!(!read.notify_on_rule, "the file has to carry it"); assert_eq!(read.locale, crate::Locale::English); @@ -564,13 +580,13 @@ mod tests { "unreadable reads as the defaults" ); - store.save_prefs(&read).expect("saved"); + store.edit_prefs(|_| true).expect("saved"); assert!( std::fs::read_to_string(dir.join("preferences.unread.json")) .expect("kept") .contains("amoled") ); - store.save_prefs(&read).expect("saved again"); + store.edit_prefs(|_| true).expect("saved again"); assert!(dir.join("preferences.unread.json").exists()); } diff --git a/crates/linkunbound-core/src/update.rs b/crates/linkunbound-core/src/update.rs index b2d54dd..23731df 100644 --- a/crates/linkunbound-core/src/update.rs +++ b/crates/linkunbound-core/src/update.rs @@ -160,6 +160,23 @@ pub fn newer_than(found: &str, here: &str) -> bool { } } +#[must_use] +pub fn on_the_candidate_track(here: &str, wants: Option) -> bool { + wants.unwrap_or_else(|| { + semver::Version::parse(here.trim_start_matches('v')).is_ok_and(|here| !here.pre.is_empty()) + }) +} + +#[must_use] +pub fn worth_offering(found: &str, here: &str, wants: Option) -> bool { + if !newer_than(found, here) { + return false; + } + let candidate = semver::Version::parse(found.trim_start_matches('v')) + .is_ok_and(|found| !found.pre.is_empty()); + !candidate || on_the_candidate_track(here, wants) +} + #[cfg(test)] mod tests { use super::{ diff --git a/crates/linkunbound-mac/Cargo.toml b/crates/linkunbound-mac/Cargo.toml index 49aca65..50dc631 100644 --- a/crates/linkunbound-mac/Cargo.toml +++ b/crates/linkunbound-mac/Cargo.toml @@ -39,6 +39,7 @@ objc2-foundation = { version = "0.3", default-features = false, features = [ "NSDictionary", "NSError", "NSEnumerator", + "NSFileManager", "NSGeometry", "NSNotification", "NSObject", diff --git a/crates/linkunbound-mac/src/legacy.rs b/crates/linkunbound-mac/src/legacy.rs new file mode 100644 index 0000000..203f5d1 --- /dev/null +++ b/crates/linkunbound-mac/src/legacy.rs @@ -0,0 +1,77 @@ +use objc2::rc::Retained; +use objc2_app_kit::NSWorkspace; +use objc2_foundation::{NSArray, NSBundle, NSFileManager, NSString, NSURL}; + +const LEGACY_BUNDLE_ID: &str = "com.rgdevment.linkunbound"; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Legacy { + pub path: String, + pub version: Option, +} + +fn version_of(app: &NSURL) -> Option { + let bundle = NSBundle::bundleWithURL(app)?; + let said = + bundle.objectForInfoDictionaryKey(&NSString::from_str("CFBundleShortVersionString"))?; + said.downcast::().ok().map(|it| it.to_string()) +} + +fn the_old_copy() -> Option> { + let found = NSWorkspace::sharedWorkspace() + .URLForApplicationWithBundleIdentifier(&NSString::from_str(LEGACY_BUNDLE_ID))?; + let theirs = found.path()?.to_string(); + let ours = NSBundle::mainBundle() + .bundleURL() + .path() + .map(|it| it.to_string()); + (ours.as_deref() != Some(theirs.as_str())).then_some(found) +} + +#[must_use] +pub fn installed() -> Option { + objc2::rc::autoreleasepool(|_| { + let app = the_old_copy()?; + Some(Legacy { + path: app.path()?.to_string(), + version: version_of(&app), + }) + }) +} + +pub fn retire() -> Result<(), String> { + objc2::rc::autoreleasepool(|_| { + let app = the_old_copy().ok_or_else(|| "legacyGone".to_owned())?; + NSFileManager::defaultManager() + .trashItemAtURL_resultingItemURL_error(&app, None) + .map_err(|error| error.localizedDescription().to_string()) + }) +} + +pub fn reveal() { + objc2::rc::autoreleasepool(|_| { + let Some(app) = the_old_copy() else { + return; + }; + NSWorkspace::sharedWorkspace() + .activateFileViewerSelectingURLs(&NSArray::from_retained_slice(&[app])); + }); +} + +#[cfg(test)] +mod tests { + use super::{LEGACY_BUNDLE_ID, installed, retire}; + + #[test] + fn the_old_identifier_is_not_the_one_this_runs_under() { + assert!(!crate::OWN_BUNDLE_IDS[0].eq_ignore_ascii_case(LEGACY_BUNDLE_ID)); + assert_eq!(crate::OWN_BUNDLE_IDS[1], LEGACY_BUNDLE_ID); + } + + #[test] + fn with_no_old_copy_there_is_nothing_to_offer() { + if installed().is_none() { + assert_eq!(retire(), Err("legacyGone".to_owned())); + } + } +} diff --git a/crates/linkunbound-mac/src/lib.rs b/crates/linkunbound-mac/src/lib.rs index 6d81fd5..93a8605 100644 --- a/crates/linkunbound-mac/src/lib.rs +++ b/crates/linkunbound-mac/src/lib.rs @@ -5,6 +5,8 @@ mod events; #[cfg(target_os = "macos")] mod icons; #[cfg(target_os = "macos")] +mod legacy; +#[cfg(target_os = "macos")] mod native; #[cfg(target_os = "macos")] mod registration; @@ -29,6 +31,10 @@ pub use events::{Event, Listening, listen}; #[cfg(target_os = "macos")] pub use icons::icon_for; #[cfg(target_os = "macos")] +pub use legacy::{ + Legacy, installed as legacy_installed, retire as retire_legacy, reveal as reveal_legacy, +}; +#[cfg(target_os = "macos")] pub use native::{ Watching, copy_text, cursor, digit_behind, dress_window, front_application, front_is_ours, is_in_front, keep_off_the_taskbar, let_whoever_opens_next_come_forward, menu_bar_is_light, diff --git a/crates/linkunbound-shell/Cargo.toml b/crates/linkunbound-shell/Cargo.toml index c3408ed..25d1e57 100644 --- a/crates/linkunbound-shell/Cargo.toml +++ b/crates/linkunbound-shell/Cargo.toml @@ -14,6 +14,7 @@ linkunbound-core.workspace = true slint.workspace = true interprocess.workspace = true tray-icon.workspace = true +global-hotkey.workspace = true muda.workspace = true png.workspace = true raw-window-handle = "0.6" diff --git a/crates/linkunbound-shell/build.rs b/crates/linkunbound-shell/build.rs index b236084..8cc3776 100644 --- a/crates/linkunbound-shell/build.rs +++ b/crates/linkunbound-shell/build.rs @@ -2,7 +2,7 @@ fn main() { slint_build::compile("ui/shell.slint").expect("the interface must compile"); #[cfg(windows)] - { + if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("windows") { println!("cargo:rerun-if-changed=linkunbound-shell.manifest"); embed_manifest::embed_manifest_file("linkunbound-shell.manifest") .expect("the manifest must be embedded"); diff --git a/crates/linkunbound-shell/src/hotkey.rs b/crates/linkunbound-shell/src/hotkey.rs new file mode 100644 index 0000000..e029ebb --- /dev/null +++ b/crates/linkunbound-shell/src/hotkey.rs @@ -0,0 +1,72 @@ +use global_hotkey::hotkey::HotKey; +use global_hotkey::{GlobalHotKeyEvent, GlobalHotKeyManager, HotKeyState}; + +pub struct Hotkey { + manager: GlobalHotKeyManager, + held: Option, +} + +impl Hotkey { + #[must_use] + pub fn new() -> Option { + GlobalHotKeyManager::new().ok().map(|manager| Self { + manager, + held: None, + }) + } + + pub fn claim(&mut self, wanted: Option<&str>) -> Option { + if let Some(held) = self.held.take() { + let _ = self.manager.unregister(held); + } + let spelled = wanted?; + let parsed: HotKey = spelled.parse().ok()?; + self.manager.register(parsed).ok()?; + self.held = Some(parsed); + Some(spelled.to_owned()) + } + + pub fn pressed(&self) -> bool { + let mut asked = false; + while let Ok(event) = GlobalHotKeyEvent::receiver().try_recv() { + if event.state() == HotKeyState::Pressed + && self.held.is_some_and(|held| held.id() == event.id()) + { + asked = true; + } + } + asked + } +} + +impl Drop for Hotkey { + fn drop(&mut self) { + if let Some(held) = self.held.take() { + let _ = self.manager.unregister(held); + } + } +} + +#[cfg(test)] +mod tests { + use super::HotKey; + + #[test] + fn the_spellings_the_core_can_produce_all_parse() { + for said in [ + "Alt+Shift+L", + "Ctrl+Shift+L", + "Control+Alt+Digit1", + "F9", + "Super+L", + ] { + assert!(said.parse::().is_ok(), "{said} should parse"); + } + } + + #[test] + fn nonsense_is_refused_rather_than_claiming_something_else() { + assert!("".parse::().is_err()); + assert!("Shift".parse::().is_err()); + } +} diff --git a/crates/linkunbound-shell/src/lib.rs b/crates/linkunbound-shell/src/lib.rs index 4511316..eb2ce4b 100644 --- a/crates/linkunbound-shell/src/lib.rs +++ b/crates/linkunbound-shell/src/lib.rs @@ -2,13 +2,14 @@ slint::include_modules!(); +pub mod hotkey; pub mod place; pub mod single; pub mod tray; use std::rc::Rc; -use linkunbound_core::update::{Looked, Progress, newer_than}; +use linkunbound_core::update::{Looked, Progress}; use linkunbound_core::{Browser, Scope, Strings, local_file_parts, looks_unresolved, site_of}; /// Both windows read the same palette, so the choice is applied once per window @@ -241,7 +242,7 @@ pub fn strip_for( put_away: Option<&str>, ) -> Option { let found = looked.found_version.as_deref()?; - if !newer_than(found, here) { + if !linkunbound_core::update::worth_offering(found, here, looked.candidates) { return None; } let store = looked.found_route.as_deref() == Some("store"); diff --git a/crates/linkunbound-shell/src/main.rs b/crates/linkunbound-shell/src/main.rs index 12e6887..537dddf 100644 --- a/crates/linkunbound-shell/src/main.rs +++ b/crates/linkunbound-shell/src/main.rs @@ -6,6 +6,7 @@ use std::sync::mpsc::channel; use std::time::Duration; use linkunbound_core::{Language, Rule, Store, Strings, Target, data_dir, host_of, normalise}; +use linkunbound_shell::hotkey::Hotkey; use linkunbound_shell::tray::{Asked, Tray}; use linkunbound_shell::{ ICON_SIDE, Listed, Notice, Picker, Reaches, TILE_ICON_SIDE, dress, paint, place, single, @@ -338,6 +339,10 @@ fn flash(notice: &Notice, words: &Strings, fired: &Fired) { host::never_activates(handle); } in_the_corner(notice); + #[cfg(target_os = "macos")] + if !ui().is_some_and(|ui| ui.picker.window().is_visible()) { + host::let_whoever_opens_next_come_forward(); + } } const NOTICE_MARGIN: f32 = 16.0; @@ -664,6 +669,7 @@ struct Ui { taskbar_seen: Cell, /// The window the picker was shown over when it could not take the front. shown_over: Cell>, + hotkey: RefCell>, put_up: Cell>, #[cfg(target_os = "macos")] launch_decided: Cell, @@ -846,6 +852,13 @@ impl Ui { tray.show(true); tray.relabel(&self.words.get()); } + self.hold(prefs.shortcut.as_deref()); + } + + fn hold(&self, wanted: Option<&str>) { + let mut slot = self.hotkey.borrow_mut(); + let held = slot.as_mut().and_then(|hotkey| hotkey.claim(wanted)); + store().hold_shortcut(held.as_deref()); } /// Slint offers no focus-lost event, so this polls — but only while the @@ -1085,7 +1098,10 @@ fn wants_light(theme: linkunbound_core::Theme) -> bool { fn asked_for(what: Asked) { match what { Asked::Settings => open_settings(), - Asked::Quit => slint::quit_event_loop().unwrap_or(()), + Asked::Quit => { + store().hold_shortcut(None); + slint::quit_event_loop().unwrap_or(()); + } } } @@ -1141,6 +1157,7 @@ fn main() -> Result<(), slint::PlatformError> { #[cfg(target_os = "macos")] without_taking_the_front()?; let picker = Picker::new()?; + picker.set_on_mac(cfg!(target_os = "macos")); let shown = Rc::new(RefCell::new(Shown::default())); // Alt+F4 reaches the window as a close request, which Slint answers by hiding it: the queue @@ -1323,6 +1340,7 @@ fn main() -> Result<(), slint::PlatformError> { taskbar_seen: Cell::new(light_taskbar), shown_over: Cell::new(None), put_up: Cell::new(None), + hotkey: RefCell::new(Hotkey::new()), #[cfg(target_os = "macos")] launch_decided: Cell::new(false), }); @@ -1362,6 +1380,15 @@ fn main() -> Result<(), slint::PlatformError> { if let Some(tray) = state.tray.as_ref() { tray.drain(&asks); } + if state + .hotkey + .borrow() + .as_ref() + .is_some_and(linkunbound_shell::hotkey::Hotkey::pressed) + && asks.send(Asked::Settings).is_err() + { + return; + } while let Ok(what) = tray_inbox.try_recv() { if what == Asked::Settings { state.catch_up(); @@ -1371,7 +1398,9 @@ fn main() -> Result<(), slint::PlatformError> { }, ); - slint::run_event_loop_until_quit() + let ran = slint::run_event_loop_until_quit(); + store().hold_shortcut(None); + ran } #[cfg(test)] @@ -1480,6 +1509,7 @@ mod tests { taskbar_seen: Cell::new(false), shown_over: Cell::new(None), put_up: Cell::new(None), + hotkey: RefCell::new(None), #[cfg(target_os = "macos")] launch_decided: Cell::new(false), }); diff --git a/crates/linkunbound-shell/src/single.rs b/crates/linkunbound-shell/src/single.rs index 718cb7a..90937dc 100644 --- a/crates/linkunbound-shell/src/single.rs +++ b/crates/linkunbound-shell/src/single.rs @@ -178,25 +178,23 @@ fn hand_over_at(socket: &str, url: &str) -> bool { /// This user and the system, nobody else: the pipe's default descriptor lets every account on /// the machine read it. Labelled low so a sandboxed browser can still hand a link across. #[cfg(windows)] -fn only_this_user(options: ListenerOptions<'_>) -> ListenerOptions<'_> { +fn guarded<'a>(options: ListenerOptions<'a>, sid: Option) -> Option> { use interprocess::os::windows::local_socket::ListenerOptionsExt; use interprocess::os::windows::security_descriptor::SecurityDescriptor; - let Some(sid) = linkunbound_win::current_user_sid() else { - return options; - }; - let sddl = format!("D:(A;;GA;;;SY)(A;;GA;;;{sid})S:(ML;;NW;;;LW)"); - let Ok(wide) = widestring::U16CString::from_str(&sddl) else { - return options; - }; - match SecurityDescriptor::deserialize(&wide) { - Ok(descriptor) => options.security_descriptor(descriptor), - Err(_) => options, - } + let sddl = format!("D:(A;;GA;;;SY)(A;;GA;;;{})S:(ML;;NW;;;LW)", sid?); + let wide = widestring::U16CString::from_str(&sddl).ok()?; + let descriptor = SecurityDescriptor::deserialize(&wide).ok()?; + Some(options.security_descriptor(descriptor)) +} + +#[cfg(windows)] +fn only_this_user(options: ListenerOptions<'_>) -> Option> { + guarded(options, linkunbound_win::current_user_sid()) } #[cfg(not(windows))] -fn only_this_user(options: ListenerOptions<'_>) -> ListenerOptions<'_> { - options +fn only_this_user(options: ListenerOptions<'_>) -> Option> { + Some(options) } /// Hands each link to `arrived` on the listener thread. A callback rather than a @@ -230,7 +228,7 @@ fn claim_at( #[cfg(not(windows))] let _room = make_room(socket); let name = named(socket)?; - let listener = only_this_user(ListenerOptions::new().name(name)) + let listener = only_this_user(ListenerOptions::new().name(name))? .create_sync() .ok()?; @@ -289,6 +287,35 @@ mod tests { .into_owned() } + #[test] + #[cfg(windows)] + fn a_channel_that_cannot_be_guarded_is_not_opened() { + use interprocess::local_socket::{GenericNamespaced, ListenerOptions, ToNsName}; + + let name = scratch("guard"); + let named = name.to_ns_name::().expect("a name"); + assert!( + super::guarded(ListenerOptions::new().name(named.clone()), None).is_none(), + "no account to name means no channel" + ); + assert!( + super::guarded( + ListenerOptions::new().name(named.clone()), + Some("not-a-sid".to_owned()) + ) + .is_none(), + "a descriptor the system refuses means no channel" + ); + assert!( + super::guarded( + ListenerOptions::new().name(named), + linkunbound_win::current_user_sid() + ) + .is_some(), + "this account's own sid builds one" + ); + } + #[test] fn a_link_with_a_pipe_in_it_arrives_whole() { let socket = scratch("pipe"); diff --git a/crates/linkunbound-shell/ui/shell.slint b/crates/linkunbound-shell/ui/shell.slint index 644a945..6a0cc2e 100644 --- a/crates/linkunbound-shell/ui/shell.slint +++ b/crates/linkunbound-shell/ui/shell.slint @@ -377,6 +377,7 @@ export component Picker inherits Window { // Rows the way 1.x users know them, or the sheet of tiles behind a preference. in property classic: true; + in property on-mac: false; // Room around the sheet for the shadow it draws itself. Zero where the system draws one. in property halo: 0px; @@ -488,11 +489,11 @@ export component Picker inherits Window { root.more-open = false; return accept; } - if (event.text == "c" && event.modifiers.control) { + if (event.text == "c" && (root.on-mac ? event.modifiers.meta : event.modifiers.control)) { root.copy(); return accept; } - if (event.text == "p" && event.modifiers.control) { + if (event.text == "p" && (root.on-mac ? event.modifiers.meta : event.modifiers.control)) { root.pinned-private = !root.pinned-private; root.private-on = root.pinned-private; return accept; diff --git a/crates/linkunbound-win/src/registration.rs b/crates/linkunbound-win/src/registration.rs index 1317f4c..5ee0693 100644 --- a/crates/linkunbound-win/src/registration.rs +++ b/crates/linkunbound-win/src/registration.rs @@ -335,6 +335,19 @@ mod tests { assert_eq!(FILE_EXTENSIONS.to_vec(), opened); } + #[test] + fn the_installer_sweeps_every_type_the_registration_claims() { + let hooks = std::fs::read_to_string( + std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../app/src-tauri/nsis/hooks.nsh"), + ) + .expect("the uninstall hook"); + for ext in FILE_EXTENSIONS { + let swept = format!(r#""Software\Classes\{ext}\OpenWithProgIds" "{PROG_ID}""#); + assert!(hooks.contains(&swept), "hooks.nsh leaves {ext} behind"); + } + } + /// One root per test: they run in parallel and a shared root means each /// one's cleanup deletes the others' keys mid-assertion. fn scratch(name: &str) -> String { diff --git a/release-manifest.json b/release-manifest.json deleted file mode 100644 index 3f1e3c9..0000000 --- a/release-manifest.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "schema": 1, - "latest": "0.0.0" -} diff --git a/scripts/generations.py b/scripts/generations.py index 743c16a..fa3214d 100644 --- a/scripts/generations.py +++ b/scripts/generations.py @@ -21,7 +21,7 @@ def gh(*args): def listed(ref=None): - args = ["cache", "list", "--limit", "100", "--json", "key,ref,sizeInBytes,createdAt"] + args = ["cache", "list", "--limit", "1000", "--json", "key,ref,sizeInBytes,createdAt"] if ref: args += ["--ref", ref] return json.loads(gh(*args)) From 0f1f321cee7c13d10a0d0e1249246d97fd563c2e Mon Sep 17 00:00:00 2001 From: rgdevment Date: Fri, 25 Sep 2026 21:30:03 -0300 Subject: [PATCH 2/3] fix: the resident notices a shortcut changed with settings still open Moving the claim to the resident left it reading preferences.json only when a link arrived or the tray opened settings, so a combination changed in a window that stays open went on being the old one until something unrelated happened. The settings binary used to register synchronously and this was immediate. The pump now looks at the file about once a second and obeys what it finds, which is the same check catch_up() already made, split out so both can use it. The screen asks again shortly after a combination changes, so what it says is held is what the resident answered rather than what the file said before it had a chance to reply. tracking() defers to the core's on_the_candidate_track instead of repeating it: the two had already drifted, since only one of them trimmed a leading `v`. --- app/src-tauri/src/update.rs | 5 +---- app/src/settings/Application.test.tsx | 25 +++++++++++++++++++++++++ app/src/settings/Application.tsx | 9 +++++++++ crates/linkunbound-shell/src/main.rs | 20 +++++++++++++++++--- 4 files changed, 52 insertions(+), 7 deletions(-) diff --git a/app/src-tauri/src/update.rs b/app/src-tauri/src/update.rs index 48e02fc..c65249d 100644 --- a/app/src-tauri/src/update.rs +++ b/app/src-tauri/src/update.rs @@ -115,10 +115,7 @@ struct Manifest { /// otherwise, and saying otherwise is what walks it back to the stable track. #[must_use] pub fn tracking(now: &str, wants: Option) -> bool { - wants.unwrap_or_else(|| { - now.parse::() - .is_ok_and(|here| !here.pre.is_empty()) - }) + linkunbound_core::update::on_the_candidate_track(now, wants) } #[must_use] diff --git a/app/src/settings/Application.test.tsx b/app/src/settings/Application.test.tsx index ce6a3a6..a344eb3 100644 --- a/app/src/settings/Application.test.tsx +++ b/app/src/settings/Application.test.tsx @@ -157,4 +157,29 @@ describe("application settings", () => { await userEvent.click(await screen.findByRole("button", { name: "Quitar" })); expect(invoke).toHaveBeenCalledWith("prefs_set", { prefs: { ...PREFS, shortcut: null } }); }); + + it("asks again after a change of combination, once the resident has claimed it", async () => { + vi.useFakeTimers(); + try { + answers(PREFS, "Alt+Shift+L"); + mount(); + const button = await vi.waitFor(() => screen.getByRole("button", { name: "Alt+Shift+L" })); + + answers({ ...PREFS, shortcut: "Control+Alt+B" }, null); + fireEvent.click(button); + fireEvent.keyDown(button, { code: "KeyB", ctrlKey: true, altKey: true }); + await vi.waitFor(() => + expect(invoke).toHaveBeenCalledWith("prefs_set", { + prefs: { ...PREFS, shortcut: "Control+Alt+B" }, + }), + ); + invoke.mockClear(); + + await vi.advanceTimersByTimeAsync(1500); + expect(invoke).toHaveBeenCalledWith("prefs_get"); + await vi.waitFor(() => expect(screen.getByText(/Otra aplicación ya usa/)).toBeTruthy()); + } finally { + vi.useRealTimers(); + } + }); }); diff --git a/app/src/settings/Application.tsx b/app/src/settings/Application.tsx index 64490a2..5edef38 100644 --- a/app/src/settings/Application.tsx +++ b/app/src/settings/Application.tsx @@ -40,6 +40,8 @@ const PICKERS: { id: PickerStyle; label: Key }[] = [ const MODIFIERS = new Set(["Control", "Alt", "Shift", "Meta"]); +const RESIDENT_ANSWERS_WITHIN = 1400; + /// Tauri parses the combination, and it wants `Control`, `Super` and the /// physical key name rather than whatever the layout produced. function combinationOf(e: React.KeyboardEvent): string | null { @@ -122,11 +124,18 @@ export default function Application({ onLanguage(spoken(next.language)); refresh(); setProblem(null); + if ("shortcut" in patch) askAgainOnceTheResidentHasIt(); }) .catch((e: unknown) => setProblem(saidPlainly(language, e))) .finally(() => setCapturing(false)); }; + const askAgainOnceTheResidentHasIt = () => { + setTimeout(() => { + void invoke("prefs_get").then(setSettings).catch(noop); + }, RESIDENT_ANSWERS_WITHIN); + }; + useEffect(() => { void invoke("prefs_get").then(setSettings).catch(noop); }, []); diff --git a/crates/linkunbound-shell/src/main.rs b/crates/linkunbound-shell/src/main.rs index 537dddf..7e38589 100644 --- a/crates/linkunbound-shell/src/main.rs +++ b/crates/linkunbound-shell/src/main.rs @@ -325,6 +325,8 @@ fn answered_by_rule(url: &str, source: Option<&str>) -> Option { }) } +const TICKS_BETWEEN_LOOKS: u32 = 8; + const NOTICE_SECONDS: i32 = 6; /// Never focused: it must not take the keyboard from whatever is being done. @@ -696,13 +698,20 @@ impl Ui { /// Settings runs in another process: the file is the only channel between /// them. Read when something is about to be shown rather than on a clock. fn catch_up(&self) { + if self.settings_moved() { + self.obey(&store().prefs()); + } else { + self.follow_the_system(); + } + } + + fn settings_moved(&self) -> bool { let now = prefs_touched_at(); if now == self.prefs_seen.get() { - self.follow_the_system(); - return; + return false; } self.prefs_seen.set(now); - self.obey(&store().prefs()); + true } /// «System» is read when the picker is about to show, not once at start: Windows turns dark @@ -1373,6 +1382,7 @@ fn main() -> Result<(), slint::PlatformError> { // this is the one thing still on a clock — and only while the app is idle, // which is exactly when nothing else needs the CPU. let pump = slint::Timer::default(); + let mut ticks = 0u32; pump.start( slint::TimerMode::Repeated, Duration::from_millis(120), @@ -1380,6 +1390,10 @@ fn main() -> Result<(), slint::PlatformError> { if let Some(tray) = state.tray.as_ref() { tray.drain(&asks); } + ticks = ticks.wrapping_add(1); + if ticks.is_multiple_of(TICKS_BETWEEN_LOOKS) && state.settings_moved() { + state.obey(&store().prefs()); + } if state .hotkey .borrow() From 572426b4fc28857d623ae9ad8ba754ce1f99414d Mon Sep 17 00:00:00 2001 From: rgdevment Date: Fri, 25 Sep 2026 21:37:40 -0300 Subject: [PATCH 3/3] test: cover what the new code can be held to, and exclude what a runner cannot run The quality gate failed on coverage of new code (58.7% against 80), with everything else green. What was missing was covered rather than waived: - the shortcut the resident writes down and the window reads back, including a file of blanks, which must read as nobody holding anything - the one rule that decides whether a version is worth offering, the candidate track it rests on, and the leading `v` the two copies of that rule used to disagree about - the file-moved check behind the fix before this one, split from the call that reads the clock so it can be exercised without one - what the Mac lookup answers when there is no 1.x installed, which is the machine every runner is Two files are excluded from coverage instead, for the reason app/src-tauri/src already is: a global shortcut cannot be registered on a headless runner, and the resident's main.rs is the binary that talks to the system. --- .github/workflows/ci.yml | 2 +- crates/linkunbound-core/src/store.rs | 47 +++++++++++++++++++++++++++ crates/linkunbound-core/src/update.rs | 43 ++++++++++++++++++++++-- crates/linkunbound-mac/src/legacy.rs | 12 +++++++ crates/linkunbound-shell/src/main.rs | 30 ++++++++++++++++- 5 files changed, 130 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index eadc7ee..2bca5a9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -214,6 +214,6 @@ jobs: -Dsonar.javascript.lcov.reportPaths=app/coverage/lcov.info -Dsonar.cpd.exclusions=app/src/i18n.ts -Dsonar.rust.clippy.enabled=false - -Dsonar.coverage.exclusions=app/src/main.tsx,app/src-tauri/src/** + -Dsonar.coverage.exclusions=app/src/main.tsx,app/src-tauri/src/**,crates/linkunbound-shell/src/main.rs,crates/linkunbound-shell/src/hotkey.rs -Dsonar.qualitygate.wait=true -Dsonar.qualitygate.timeout=300 diff --git a/crates/linkunbound-core/src/store.rs b/crates/linkunbound-core/src/store.rs index 011e6fa..98bd2d8 100644 --- a/crates/linkunbound-core/src/store.rs +++ b/crates/linkunbound-core/src/store.rs @@ -744,6 +744,53 @@ mod tests { assert!(store.browsers().unwrap().browsers.is_empty()); } + #[test] + fn what_the_resident_holds_survives_the_process_that_wrote_it() { + let store = Store::at(scratch("held")); + assert_eq!(store.shortcut_held(), None, "nothing written, nobody holds"); + + store.hold_shortcut(Some("Alt+Shift+L")); + assert_eq!( + Store::at(store.dir()).shortcut_held(), + Some("Alt+Shift+L".to_owned()), + "the settings window reads what the resident wrote" + ); + + store.hold_shortcut(Some("Control+Alt+B")); + assert_eq!( + store.shortcut_held(), + Some("Control+Alt+B".to_owned()), + "a second claim replaces the first" + ); + + store.hold_shortcut(None); + assert_eq!(store.shortcut_held(), None, "letting go leaves nothing"); + store.hold_shortcut(None); + assert_eq!( + store.shortcut_held(), + None, + "letting go twice is not a fault" + ); + } + + #[test] + fn a_combination_of_blanks_is_nobody_holding_anything() { + let store = Store::at(scratch("blank-held")); + store.hold_shortcut(Some(" \n ")); + assert_eq!( + store.shortcut_held(), + None, + "a file with nothing in it must not read as a shortcut in force" + ); + + store.hold_shortcut(Some(" Alt+Shift+L\n")); + assert_eq!( + store.shortcut_held(), + Some("Alt+Shift+L".to_owned()), + "what a trailing newline surrounds is still the combination" + ); + } + #[test] fn what_is_saved_is_what_comes_back() { let store = Store::at(scratch("roundtrip")); diff --git a/crates/linkunbound-core/src/update.rs b/crates/linkunbound-core/src/update.rs index 23731df..e2bf79d 100644 --- a/crates/linkunbound-core/src/update.rs +++ b/crates/linkunbound-core/src/update.rs @@ -180,11 +180,50 @@ pub fn worth_offering(found: &str, here: &str, wants: Option) -> bool { #[cfg(test)] mod tests { use super::{ - Looked, Progress, gone_quiet, keep, looked, newer_than, progress, settle, tell, - under_way_for, + Looked, Progress, gone_quiet, keep, looked, newer_than, on_the_candidate_track, progress, + settle, tell, under_way_for, worth_offering, }; use std::time::Duration; + #[test] + fn a_copy_that_is_itself_a_candidate_stays_on_that_track_until_told_otherwise() { + assert!(on_the_candidate_track("2.1.0-rc.1", None)); + assert!(!on_the_candidate_track("2.1.0", None)); + assert!(on_the_candidate_track("2.1.0", Some(true))); + assert!(!on_the_candidate_track("2.1.0-rc.1", Some(false))); + assert!(!on_the_candidate_track("tomorrow", None)); + } + + #[test] + fn a_leading_v_is_the_same_version_either_way() { + assert!(on_the_candidate_track("v2.1.0-rc.1", None)); + assert!(worth_offering("v2.2.0", "v2.1.0", None)); + } + + #[test] + fn a_stable_copy_is_never_walked_onto_a_candidate() { + assert!( + !worth_offering("2.2.0-rc.1", "2.1.0", None), + "the strip offered candidates the errand behind the button then refused" + ); + assert!( + worth_offering("2.2.0-rc.1", "2.1.0", Some(true)), + "asked for" + ); + assert!( + worth_offering("2.2.0-rc.1", "2.2.0-rc.0", None), + "already on it" + ); + } + + #[test] + fn nothing_older_or_unreadable_is_ever_worth_offering() { + assert!(!worth_offering("2.0.0", "2.1.0", None)); + assert!(!worth_offering("2.1.0", "2.1.0", None)); + assert!(!worth_offering("tomorrow", "2.1.0", Some(true))); + assert!(worth_offering("2.2.0", "2.1.0", None)); + } + fn scratch(name: &str) -> std::path::PathBuf { let dir = std::env::temp_dir().join(format!("lu-core-update-{name}-{}", std::process::id())); diff --git a/crates/linkunbound-mac/src/legacy.rs b/crates/linkunbound-mac/src/legacy.rs index 203f5d1..89a1291 100644 --- a/crates/linkunbound-mac/src/legacy.rs +++ b/crates/linkunbound-mac/src/legacy.rs @@ -72,6 +72,18 @@ mod tests { fn with_no_old_copy_there_is_nothing_to_offer() { if installed().is_none() { assert_eq!(retire(), Err("legacyGone".to_owned())); + super::reveal(); + } + } + + #[test] + fn what_is_found_is_named_by_a_path_and_nothing_else_is_promised() { + if let Some(found) = installed() { + assert!( + !found.path.is_empty(), + "a copy with no path is not one to offer" + ); + assert!(found.path.ends_with(".app"), "{}", found.path); } } } diff --git a/crates/linkunbound-shell/src/main.rs b/crates/linkunbound-shell/src/main.rs index 7e38589..9899127 100644 --- a/crates/linkunbound-shell/src/main.rs +++ b/crates/linkunbound-shell/src/main.rs @@ -706,7 +706,10 @@ impl Ui { } fn settings_moved(&self) -> bool { - let now = prefs_touched_at(); + self.moved_since(prefs_touched_at()) + } + + fn moved_since(&self, now: Option) -> bool { if now == self.prefs_seen.get() { return false; } @@ -1531,6 +1534,31 @@ mod tests { ui } + #[test] + fn the_file_moving_is_what_says_settings_changed_something() { + let ui = headless_ui(); + let first = std::time::SystemTime::UNIX_EPOCH; + let later = first + std::time::Duration::from_secs(1); + + assert!( + ui.moved_since(Some(first)), + "nothing seen yet, so this is new" + ); + assert!( + !ui.moved_since(Some(first)), + "the same file is not a change" + ); + assert!( + ui.moved_since(Some(later)), + "a shortcut changed with the window open has to reach the resident" + ); + assert!(!ui.moved_since(Some(later))); + assert!( + ui.moved_since(None), + "a file that cannot be read is not the one already seen" + ); + } + #[test] fn a_link_over_a_picker_nobody_answers_takes_its_place_and_the_queue_follows() { let ui = headless_ui();