Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/commits.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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/')
Expand Down
4 changes: 2 additions & 2 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-<user>.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-<user>.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.

Expand Down Expand Up @@ -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.

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

---

Expand Down
30 changes: 27 additions & 3 deletions crates/linkunbound-win/src/registration.rs
Original file line number Diff line number Diff line change
@@ -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;

Expand Down Expand Up @@ -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::<String, _>("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::<String, _>("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)
})
Expand Down Expand Up @@ -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);
}
}
2 changes: 1 addition & 1 deletion scripts/commits.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
Loading