diff --git a/.github/workflows/commits.yml b/.github/workflows/commits.yml index 4d4126d..a10a801 100644 --- a/.github/workflows/commits.yml +++ b/.github/workflows/commits.yml @@ -30,7 +30,7 @@ jobs: TITLE: ${{ github.event.pull_request.title }} NUMBER: ${{ github.event.pull_request.number }} run: | - fits() { [ "$(printf '%s (#%s)' "$1" "$NUMBER" | wc -m)" -le 100 ]; } + fits() { [ "$(printf '%s (#%s)' "$1" "$NUMBER" | wc -m)" -le 120 ]; } short=$(printf '%s' "$TITLE" | sed -E 's/ across [0-9]+ director(y|ies)//') fits "$short" || short=$(printf '%s' "$short" | sed -E 's/ from [^ ]+ to [^ ]+//') fits "$short" || short=$(printf '%s' "$short" | sed -E 's/^([a-z]+: bump) .* in the ([^ ]+) group.*/\1 the \2 group/') diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3a2fa5e..794bdec 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -74,7 +74,7 @@ Two binaries, one package: - `linkunbound-shell` → the resident: tray or menu bar, the picker, the notice; Windows and macOS run it for every link, and a second copy hands its link to the one already running and exits - `linkunbound-settings` → the settings window, opened on demand, and the errands the resident sends it on with no window (`--look` asks the feed, `--update` installs) -**Windows.** A named pipe of the user's own (`linkunbound-.sock`) links second instances to the resident, and holding its name is what keeps a second resident from starting. The installer writes the app's own ProgId, `RegisteredApplications` and `StartMenuInternet` keys, and the `linkunbound` URL scheme other applications send links through; the settings window re-points them when the install moves and never recreates what the person took away; Windows itself owns the final choice through `UserChoice`, which no application may write. +**Windows.** A named pipe of the user's own (`linkunbound-.sock`) links second instances to the resident, and holding its name is what keeps a second resident from starting. The installer writes the app's own ProgId, `RegisteredApplications` and `StartMenuInternet` keys, and the `linkunbound` URL scheme other applications send links through; the settings window re-points them when the install moves and never recreates what the person took away; Windows itself owns the final choice through `UserChoice`, which no application may write, and which outlives an uninstall: a `UserChoice` naming our ProgId counts as ours only while that class still exists. **macOS.** The bundle's `CFBundleExecutable` is the resident, so Launch Services starts it — or talks to the running copy — for every link. Nothing arrives on the command line: links, documents, launches and reopens come in as Apple Events (`GURL`, `odoc`, `oapp`, `rapp`) — `linkunbound://` among the links, declared in `Info.plist` beside `http` and `https` — and the launch event says whether the session started the app as a login item, which is what keeps the settings window closed at sign-in. A Unix socket under `~/Library/Application Support/LinkUnbound/` carries links handed over from a terminal. Default-browser registration goes through `NSWorkspace.setDefaultApplication` for `http`, `https` and the web document types, which the system confirms with its own prompt; the browser that held the links before is remembered and gets them back on unregistering. Login items use `SMAppService`. A browser is started through `open` either way — plainly for a bare link, as an instance of its own when a private window or a profile rides along — so Launch Services starts it and it answers for its own permissions rather than for LinkUnbound's. The web document types are declared as an alternate opener: double-clicking an `.html` keeps opening wherever it did until the person chooses. The app runs as `LSUIElement`, so it lives in the menu bar instead of the Dock, and the picker floats above every Space, full-screen apps included. @@ -190,7 +190,7 @@ application and dies with the console it was started from. ### Commits and the checks that run before them Subjects follow [Conventional Commits](https://www.conventionalcommits.org/) -(`feat:`, `fix:`, `docs:`, `ci:`…), under 100 characters: CI refuses a longer one, +(`feat:`, `fix:`, `docs:`, `ci:`…), under 120 characters: CI refuses a longer one, and a squash keeps only the pull request's title, number included, so that is held to the same shape. diff --git a/README.md b/README.md index 66fe0ac..3a77495 100644 --- a/README.md +++ b/README.md @@ -197,7 +197,7 @@ Since LinkUnbound is an independent open source project, the installer is signed **Updates.** Every six hours the resident asks the release feed, without a window. A newer version shows up as a strip atop the picker and in About; **Update** downloads the signed installer and runs it, and the resident comes back on its own. A copy from the Microsoft Store updates through the Store; a Homebrew copy updates itself the same way as a downloaded one (the cask says `auto_updates`), and `brew upgrade` works as well. Turn the background check off under Application if you prefer to ask by hand. -**From another app.** An application can send a link to LinkUnbound even when it is not the default browser: open `linkunbound://open?url=` followed by the whole link encoded as one component — what `encodeURIComponent` does, or `utf8_percent_encode(url, NON_ALPHANUMERIC)` in Rust — `linkunbound://open?url=https%3A%2F%2Fexample.com%2Fa%3Fb%3D1`. Every `:`, `/`, `?`, `&`, `=`, `#`, `%` and `+` has to be encoded: an unencoded `&` or `#` cuts the link and an unencoded `+` arrives as a space. It goes through the same rules and picker as a click. Only `http` and `https` links are accepted; anything else is dropped. Ask the system whether the scheme exists before using it, and open the plain link when nothing answers: on Windows `AssocQueryStringW(ASSOCF_IS_PROTOCOL, ASSOCSTR_EXECUTABLE, L"linkunbound", L"open", …)` succeeds while LinkUnbound is installed and registered; on macOS `NSWorkspace.urlForApplication(toOpen: URL(string: "linkunbound:")!)` is not `nil` while the app is installed. +**From another app.** An application can send a link to LinkUnbound even when it is not the default browser: open `linkunbound://open?url=` followed by the whole link encoded as one component — what `encodeURIComponent` does, or `utf8_percent_encode(url, NON_ALPHANUMERIC)` in Rust — `linkunbound://open?url=https%3A%2F%2Fexample.com%2Fa%3Fb%3D1`. Every `:`, `/`, `?`, `&`, `=`, `#`, `%` and `+` has to be encoded: an unencoded `&` or `#` cuts the link and an unencoded `+` arrives as a space. It goes through the same rules and picker as a click. Only `http` and `https` links are accepted; anything else is dropped. Ask the system whether the scheme exists before using it, and open the plain link when nothing answers: on Windows ask `AssocQueryStringW(ASSOCF_IS_PROTOCOL, …, L"linkunbound", L"open", …)` twice: `ASSOCSTR_EXECUTABLE` answers for the downloaded copy, and `ASSOCSTR_APPID` for the Microsoft Store one, which has no executable to name. Treat `OpenWith.exe` as no answer: that is Windows offering to choose an app; on macOS `NSWorkspace.urlForApplication(toOpen: URL(string: "linkunbound:")!)` is not `nil` while the app is installed. --- diff --git a/crates/linkunbound-win/src/registration.rs b/crates/linkunbound-win/src/registration.rs index fb568d5..038ef2c 100644 --- a/crates/linkunbound-win/src/registration.rs +++ b/crates/linkunbound-win/src/registration.rs @@ -1,5 +1,5 @@ use winreg::RegKey; -use winreg::enums::{HKEY_CURRENT_USER, KEY_READ, KEY_WRITE}; +use winreg::enums::{HKEY_CLASSES_ROOT, HKEY_CURRENT_USER, KEY_READ, KEY_WRITE}; use linkunbound_core::OWN_SCHEME; @@ -325,25 +325,31 @@ pub fn sweep_legacy_edge_capture() { #[must_use] pub fn is_default_browser() -> bool { let hkcu = RegKey::predef(HKEY_CURRENT_USER); + let classes = RegKey::predef(HKEY_CLASSES_ROOT); USER_CHOICE_PATHS.iter().take(2).all(|path| { hkcu.open_subkey(path) .and_then(|k| k.get_value::("ProgId")) - .is_ok_and(|id| prog_id_is_ours(&id)) + .is_ok_and(|id| still_held(&classes, &id)) }) } +fn still_held(classes: &RegKey, prog_id: &str) -> bool { + prog_id_is_ours(prog_id) && classes.open_subkey(prog_id).is_ok() +} + /// Which associations the app holds and which another application took, so the /// interface can say what is wrong instead of just that something is. #[must_use] pub fn association_report() -> Vec<(String, bool)> { let hkcu = RegKey::predef(HKEY_CURRENT_USER); + let classes = RegKey::predef(HKEY_CLASSES_ROOT); USER_CHOICE_PATHS .iter() .map(|path| { let held = hkcu .open_subkey(path) .and_then(|k| k.get_value::("ProgId")) - .is_ok_and(|id| prog_id_is_ours(&id)); + .is_ok_and(|id| still_held(&classes, &id)); let name = path.rsplit('\\').nth(1).unwrap_or(path).to_owned(); (name, held) }) @@ -694,4 +700,22 @@ mod tests { assert!(!prog_id_is_ours("NotLinkUnboundURL")); assert!(!prog_id_is_ours("LinkUnboundURLPro")); } + + #[test] + fn a_user_choice_naming_a_class_that_is_gone_is_not_held() { + let root = scratch("dangling-prog-id"); + scrub(&root); + let reg = Registration::under(&root); + reg.register(r"C:\Program Files\LinkUnbound\linkunbound-shell.exe") + .expect("the registration is written"); + let classes = RegKey::predef(HKEY_CURRENT_USER) + .open_subkey(format!(r"{root}\Classes")) + .expect("the classes key"); + assert!(still_held(&classes, PROG_ID)); + + reg.unregister().unwrap(); + assert!(!still_held(&classes, PROG_ID)); + assert!(!still_held(&classes, "ChromeHTML")); + scrub(&root); + } } diff --git a/scripts/commits.sh b/scripts/commits.sh index 82b5a3b..0de248c 100755 --- a/scripts/commits.sh +++ b/scripts/commits.sh @@ -2,7 +2,7 @@ set -uo pipefail shape='^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(\([a-z0-9._-]+\))?!?: .+' -most=100 +most=120 status=0 amiss() {