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/.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..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] @@ -178,14 +175,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/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-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..98bd2d8 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()); } @@ -728,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 b2d54dd..e2bf79d 100644 --- a/crates/linkunbound-core/src/update.rs +++ b/crates/linkunbound-core/src/update.rs @@ -160,14 +160,70 @@ 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::{ - 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/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..89a1291 --- /dev/null +++ b/crates/linkunbound-mac/src/legacy.rs @@ -0,0 +1,89 @@ +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())); + 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-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..9899127 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, @@ -324,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. @@ -338,6 +341,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 +671,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, @@ -690,13 +698,23 @@ 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) { - let now = prefs_touched_at(); - if now == self.prefs_seen.get() { + if self.settings_moved() { + self.obey(&store().prefs()); + } else { self.follow_the_system(); - return; + } + } + + fn settings_moved(&self) -> bool { + self.moved_since(prefs_touched_at()) + } + + fn moved_since(&self, now: Option) -> bool { + if now == self.prefs_seen.get() { + 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 @@ -846,6 +864,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 +1110,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 +1169,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 +1352,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), }); @@ -1355,6 +1385,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), @@ -1362,6 +1393,19 @@ 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() + .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 +1415,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 +1526,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), }); @@ -1487,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(); 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))