diff --git a/Cargo.lock b/Cargo.lock index 600ce7b..0922c20 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3835,6 +3835,7 @@ name = "poltertype-app" version = "0.29.0" dependencies = [ "anyhow", + "core-foundation 0.10.1", "crossbeam-channel", "global-hotkey", "iced", diff --git a/crates/poltertype-app/Cargo.toml b/crates/poltertype-app/Cargo.toml index 994e5aa..4292feb 100644 --- a/crates/poltertype-app/Cargo.toml +++ b/crates/poltertype-app/Cargo.toml @@ -66,3 +66,8 @@ iced = { workspace = true } # default). Cross-platform: Win10+ Toast, macOS NSUserNotification, # Linux Desktop Notifications spec via DBus. notify-rust = { workspace = true } + +[target.'cfg(target_os = "macos")'.dependencies] +# To read our own CFBundleIdentifier: the notification bridge must be +# told who is sending before the first toast (see bridges.rs). +core-foundation = "0.10" diff --git a/crates/poltertype-app/src/bridges.rs b/crates/poltertype-app/src/bridges.rs index e0b2af5..beeac41 100644 --- a/crates/poltertype-app/src/bridges.rs +++ b/crates/poltertype-app/src/bridges.rs @@ -79,6 +79,58 @@ pub(crate) fn handle_engine_event( } } +/// macOS: register who is sending, once, before the first notification. +/// +/// `mac-notification-sys` needs a sender bundle id. When nobody set +/// one, its `ensure_application_set` asks LaunchServices for an app +/// literally named `use_default` (lib.rs:116 in 0.6.12) — and macOS +/// answers an unresolvable name with a modal **"Where is use_default?" +/// application chooser** over whatever the user was doing. Observed +/// live; and because the updater's error toasts bypass the +/// `show_notifications` gate, the dialog can appear even with +/// notifications switched off in the config. +/// +/// `false` means there is no bundle to speak as — a bare binary run +/// from `cargo run` — and the caller skips its toast: a missing +/// notification is cheaper than that dialog. +#[cfg(target_os = "macos")] +fn notification_sender_ready() -> bool { + use std::sync::OnceLock; + static READY: OnceLock = OnceLock::new(); + *READY.get_or_init(|| { + let Some(bundle_id) = main_bundle_identifier() else { + info!("not running from an .app bundle; system notifications stay off"); + return false; + }; + match notify_rust::set_application(&bundle_id) { + Ok(()) => { + info!(bundle = %bundle_id, "registered as notification sender"); + true + } + Err(e) => { + warn!(?e, bundle = %bundle_id, "could not register as notification sender"); + false + } + } + }) +} + +#[cfg(not(target_os = "macos"))] +fn notification_sender_ready() -> bool { + true +} + +/// Our own `CFBundleIdentifier`, or `None` outside an `.app` bundle. +#[cfg(target_os = "macos")] +fn main_bundle_identifier() -> Option { + use core_foundation::string::CFString; + let dict = core_foundation::bundle::CFBundle::main_bundle().info_dictionary(); + let key = CFString::from_static_string("CFBundleIdentifier"); + dict.find(&key) + .and_then(|v| v.downcast::()) + .map(|s| s.to_string()) +} + /// Show a 2-second toast that the engine auto-switched layout. /// /// On a worker thread because `notify-rust`'s `show()` is synchronous: @@ -86,6 +138,9 @@ pub(crate) fn handle_engine_event( /// are logged and swallowed — a missing notification daemon must not /// propagate up. pub(crate) fn spawn_layout_change_notification(layouts: &Arc, to_layout: &LayoutId) { + if !notification_sender_ready() { + return; + } let pretty = layouts .get(to_layout) .map(|m| m.name.clone()) @@ -126,6 +181,9 @@ pub(crate) fn spawn_dictionary_add_notification( layout: &LayoutId, word: &str, ) { + if !notification_sender_ready() { + return; + } let pretty = layouts .get(layout) .map(|m| m.name.clone()) @@ -155,6 +213,9 @@ pub(crate) fn spawn_dictionary_add_notification( /// /// Longer timeout than the others, because this text has to be read. pub(crate) fn spawn_error_notification(body: String) { + if !notification_sender_ready() { + return; + } std::thread::Builder::new() .name("poltertype-notify-error".into()) .spawn(move || { @@ -178,6 +239,9 @@ pub(crate) fn spawn_error_notification(body: String) { /// version, and it is the only thing that tells a user who never opens /// the tray menu that an update is waiting. pub(crate) fn spawn_update_notification(version: &str) { + if !notification_sender_ready() { + return; + } let body = format!( "Version {version} is downloaded and ready.\n\ It will be installed the next time you restart PolterType — \ diff --git a/crates/poltertype-app/src/main.rs b/crates/poltertype-app/src/main.rs index 91aad1c..0ba011d 100644 --- a/crates/poltertype-app/src/main.rs +++ b/crates/poltertype-app/src/main.rs @@ -320,7 +320,7 @@ fn main() -> Result<()> { // Created before the listener because on Linux/evdev the two share // the thread that owns the devices. Whether it can do anything is // decided once the listener starts — see `KeyGate::available`. - let key_gate = create_key_gate(); + let key_gate = create_key_gate(settings.snapshot().engine.hold_keys); let audio = Arc::new(AudioPlayer::new()); audio.refresh_from(&settings); @@ -846,11 +846,12 @@ fn main() -> Result<()> { // service still running through an update would be // a process whose binary moved under it. supervisor.stop_all(); + settings_proc::kill_settings_ui(); // The one safe moment: the user is done typing, the // hook is down, and nothing we replace is in use. No // relaunch — they asked for the app to go away. if let Some(pending) = update_pending.as_ref() { - apply_now(pending, false); + apply_now(pending, false, &settings.snapshot().updates.local_signing_identity); } *control_flow = ControlFlow::Exit; } else if Some(&id) == update_id.as_ref() { @@ -863,7 +864,8 @@ fn main() -> Result<()> { // started, and quitting anyway is what // turned a failed update into a machine // with no PolterType running on it. - if apply_now(pending, true) { + settings_proc::kill_settings_ui(); + if apply_now(pending, true, &settings.snapshot().updates.local_signing_identity) { if let Some(mut listener) = input_listener.take() { listener.stop(); } diff --git a/crates/poltertype-app/src/settings_proc/mod.rs b/crates/poltertype-app/src/settings_proc/mod.rs index 74514ed..6f2466b 100644 --- a/crates/poltertype-app/src/settings_proc/mod.rs +++ b/crates/poltertype-app/src/settings_proc/mod.rs @@ -10,7 +10,7 @@ mod enums; mod exe; mod spawn; -pub(crate) use spawn::{spawn_settings_ui, spawn_setup_ui}; +pub(crate) use spawn::{kill_settings_ui, spawn_settings_ui, spawn_setup_ui}; #[cfg(test)] mod tests; diff --git a/crates/poltertype-app/src/settings_proc/spawn.rs b/crates/poltertype-app/src/settings_proc/spawn.rs index ffe766a..7ffb969 100644 --- a/crates/poltertype-app/src/settings_proc/spawn.rs +++ b/crates/poltertype-app/src/settings_proc/spawn.rs @@ -70,6 +70,8 @@ fn spawn_settings_ui_on(deps: SettingsCloseDeps, entry: SettingsEntry) { } }; + SETTINGS_CHILD_PID.store(child.id(), Ordering::Release); + // Waited on in a worker thread so the tray does not block. The // refreshes run whether or not the user clicked Save — the GUI // writes files outside its own state too. @@ -77,7 +79,9 @@ fn spawn_settings_ui_on(deps: SettingsCloseDeps, entry: SettingsEntry) { .name("poltertype-settings-waiter".into()) .spawn(move || { let mut child = child; - match child.wait() { + let waited = child.wait(); + SETTINGS_CHILD_PID.store(0, Ordering::Release); + match waited { Ok(status) => info!(?status, "settings UI exited"), Err(e) => warn!(?e, "could not wait on settings UI child"), } @@ -190,3 +194,52 @@ fn settings_ui_exe() -> Option { } } } + +/// Pid of the live Settings window, 0 when none. One window at a time +/// is already the rule; this is how the main process can take it along +/// when it exits. +static SETTINGS_CHILD_PID: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0); + +/// Close the Settings window before the main process exits. +/// +/// The window is a subprocess running the same executable, and left +/// alive it does worse than linger: on macOS the updater's relaunch +/// `open` sees "this app is already running", reports success, and +/// merely brings the ORPHANED OLD-VERSION settings window to the +/// front — the updated app never starts and the user is looking at +/// the previous version's Settings (measured, twice). Quit has the +/// same leak without the relaunch twist. +pub(crate) fn kill_settings_ui() { + let pid = SETTINGS_CHILD_PID.swap(0, Ordering::AcqRel); + if pid != 0 { + info!(pid, "closing the settings window before exit"); + } + #[cfg(unix)] + { + if pid != 0 { + let _ = std::process::Command::new("kill") + .arg(pid.to_string()) + .status(); + } + // And every other window of this executable, tracked or not: + // this base has no second-window guard, so a Settings window + // and a Setup-alert window can coexist while one pid slot + // remembers only the latest. The one that survived the main + // process is what made LaunchServices treat the app as still + // running and turned the updater relaunch into a no-op that + // merely raised an orphaned old-version window (measured: + // `poltertype --setup`, 2026-08-31). + if let Ok(exe) = std::env::current_exe() { + let _ = std::process::Command::new("pkill") + .arg("-f") + .arg(format!("{} --", exe.display())) + .status(); + } + } + #[cfg(windows)] + { + let _ = std::process::Command::new("taskkill") + .args(["/PID", &pid.to_string(), "/F"]) + .status(); + } +} diff --git a/crates/poltertype-app/src/settings_ui/enums.rs b/crates/poltertype-app/src/settings_ui/enums.rs index e1f2543..3226cb1 100644 --- a/crates/poltertype-app/src/settings_ui/enums.rs +++ b/crates/poltertype-app/src/settings_ui/enums.rs @@ -320,6 +320,14 @@ pub enum Message { /// Ask the OS for a permission — macOS only, and always the /// system's own dialog. SetupRequestPermission(poltertype_input::setup::Permission), + /// Create/adopt the local signing identity and write its name into + /// `[updates].local_signing_identity` — the Setup pane answer to + /// permissions dying on every update. + SetupLocalSigning, + /// Quit the main tray process and relaunch the bundle: permissions + /// are read at startup, and sending the user to hunt Quit in the + /// tray after every grant was the complaint. + RestartApp, /// Intercepted so an unsaved wordlist edit is auto-saved before the /// window closes. Carries the `window::Id` to close the right one. diff --git a/crates/poltertype-app/src/settings_ui/state.rs b/crates/poltertype-app/src/settings_ui/state.rs index 65f910b..d69b7c9 100644 --- a/crates/poltertype-app/src/settings_ui/state.rs +++ b/crates/poltertype-app/src/settings_ui/state.rs @@ -164,13 +164,16 @@ impl SettingsApp { .map(std::path::Path::to_path_buf) .unwrap_or_default(); + let setup = poltertype_input::setup::probe_setup( + &store.snapshot().updates.local_signing_identity, + ); Self { settings, os_layouts, config_path, store, pane: initial_pane, - setup: poltertype_input::setup::probe_setup(), + setup, layout_backend, setup_status: None, system_prefers_dark: super::system_theme::system_prefers_dark(), diff --git a/crates/poltertype-app/src/settings_ui/update.rs b/crates/poltertype-app/src/settings_ui/update.rs index 6343106..a5ac636 100644 --- a/crates/poltertype-app/src/settings_ui/update.rs +++ b/crates/poltertype-app/src/settings_ui/update.rs @@ -564,7 +564,9 @@ impl SettingsApp { // ── Setup pane ───────────────────────────────────────── Message::SetupRecheck => { let before = self.setup.clone(); - self.setup = poltertype_input::setup::probe_setup(); + self.setup = poltertype_input::setup::probe_setup( + &self.store.snapshot().updates.local_signing_identity, + ); // Say something either way: a button that silently // redraws the same screen reads as broken, and "still // not granted" is what the user most needs to hear. @@ -610,13 +612,103 @@ impl SettingsApp { // return value is not an answer — re-probe instead of // believing it. poltertype_input::setup::request_permission(permission); - self.setup = poltertype_input::setup::probe_setup(); + // And open the pane regardless: when TCC has no record + // the dialog appears over it, and when the app is not + // in the list at all the user is already where the + // "+" button lives — never a silent no-op + // in front of an empty list. + if let Some(url) = poltertype_input::setup::permission_settings_url(permission) { + let _ = opener::open(url); + } + self.setup = poltertype_input::setup::probe_setup( + &self.store.snapshot().updates.local_signing_identity, + ); self.setup_status = Some(SaveBanner { text: "Asked the system. Approve it there, then press Check again.".to_owned(), is_error: false, }); } + Message::SetupLocalSigning => { + // Adopt-or-create in the keychain, then remember the name: + // the updater reads it at swap time, and the re-probe below + // is what flips the step to Done. + let name = { + let configured = self.store.snapshot().updates.local_signing_identity; + if configured.is_empty() { + poltertype_input::setup::DEFAULT_LOCAL_SIGNING_IDENTITY.to_owned() + } else { + configured + } + }; + match poltertype_input::setup::setup_local_signing(&name) { + Ok(()) => { + if let Err(e) = + self.store.update(|s| s.updates.local_signing_identity = name.clone()) + { + warn!(?e, "could not remember the signing identity"); + } + self.setup_status = Some(SaveBanner { + text: format!( + "Identity “{name}” is in your keychain — updates will keep \ + the permissions from now on." + ), + is_error: false, + }); + } + Err(e) => { + warn!(%e, "local signing setup failed"); + self.setup_status = Some(SaveBanner { + text: format!("Could not set up signing: {e}"), + is_error: true, + }); + } + } + self.setup = poltertype_input::setup::probe_setup( + &self.store.snapshot().updates.local_signing_identity, + ); + } + + Message::RestartApp => { + // The settings window is a child of the tray process: + // terminate the parent, start the bundle afresh, and + // leave — keeping this window alive past the parent is + // exactly the ghost that broke the updater relaunch. + #[cfg(unix)] + { + let parent = std::os::unix::process::parent_id(); + if parent > 1 { + let _ = std::process::Command::new("kill") + .arg(parent.to_string()) + .status(); + } + if let Some(bundle) = std::env::current_exe().ok().and_then(|e| { + e.parent()?.parent()?.parent().map(std::path::Path::to_path_buf) + }) { + if bundle.extension().is_some_and(|x| x == "app") { + // Detached, and the open happens after WE + // are gone: while this settings process + // lives, LaunchServices reads the app as + // already running and open() merely + // activates this window — the exact ghost + // that used to eat the updater relaunch. + let _ = std::process::Command::new("sh") + .arg("-c") + .arg(format!( + "sleep 2; open '{}'", + bundle.display().to_string().replace('\'', "'\\''") + )) + .spawn(); + } + } + std::process::exit(0); + } + #[cfg(not(unix))] + { + warn!("restart from the pane is wired for unix only so far"); + } + } + Message::WindowCloseRequested(id) => { // Last chance to flush an unsaved wordlist edit. // Failures are logged but do not block the close: a diff --git a/crates/poltertype-app/src/settings_ui/view_setup.rs b/crates/poltertype-app/src/settings_ui/view_setup.rs index fc046ea..a19dbd2 100644 --- a/crates/poltertype-app/src/settings_ui/view_setup.rs +++ b/crates/poltertype-app/src/settings_ui/view_setup.rs @@ -144,6 +144,17 @@ impl SettingsApp { left: 16.0, }), ); + footer = footer.push( + Button::new(Text::new(tr("setup.restart_app", "Restart PolterType")).size(13)) + .on_press(Message::RestartApp) + .style(theme::primary) + .padding(Padding { + top: 7.0, + right: 16.0, + bottom: 7.0, + left: 16.0, + }), + ); footer = footer.push( Button::new(Text::new(tr("setup.full_setup_guide", "Full setup guide")).size(12)) .on_press(Message::SetupOpen(PERMISSIONS_DOC_URL.to_owned())) @@ -232,6 +243,10 @@ fn action_button(action: &StepAction) -> Element<'static, Message> { ), StepAction::Open(url) => ("Read the guide".to_owned(), Message::SetupOpen(url.clone())), StepAction::Copy(cmd) => (format!("Copy `{cmd}`"), Message::SetupCopy(cmd.clone())), + StepAction::SetupLocalSigning => ( + "Create signing identity".to_owned(), + Message::SetupLocalSigning, + ), StepAction::RequestPermission(p) => ( "Ask macOS now".to_owned(), Message::SetupRequestPermission(*p), diff --git a/crates/poltertype-app/src/updater.rs b/crates/poltertype-app/src/updater.rs index f81c94c..52a12fe 100644 --- a/crates/poltertype-app/src/updater.rs +++ b/crates/poltertype-app/src/updater.rs @@ -119,8 +119,8 @@ pub(crate) fn report_previous_install_failure() { /// never happens is an app the user has to go and start by hand, which /// is precisely how a failing updater turned into a machine with no /// PolterType on it. -pub(crate) fn apply_now(pending: &PendingUpdate, relaunch: bool) -> bool { - match poltertype_update::apply(pending, relaunch) { +pub(crate) fn apply_now(pending: &PendingUpdate, relaunch: bool, sign_identity: &str) -> bool { + match poltertype_update::apply(pending, relaunch, sign_identity) { Ok(Applied::HandedOff) => { info!( version = %pending.version, diff --git a/crates/poltertype-core/src/engine/consts.rs b/crates/poltertype-core/src/engine/consts.rs index c701a12..1afd202 100644 --- a/crates/poltertype-core/src/engine/consts.rs +++ b/crates/poltertype-core/src/engine/consts.rs @@ -34,19 +34,19 @@ pub const PASTE_GUARD: Duration = Duration::from_millis(1200); pub const LAST_WORD_TTL: Duration = Duration::from_secs(60); /// The copy chord selection conversion presses into the focused -/// application: `Ctrl+C` on the platforms that have one. +/// application: `Ctrl+C` everywhere except macOS, which wants `Cmd+C`. /// -/// macOS wants `Cmd+C`, which is why this is a constant to be corrected -/// rather than a literal buried in the flow — and why the macOS -/// emitter's `send_chord` still answering `Unsupported` is what keeps -/// the feature honestly off there rather than pressing the wrong keys. +/// The platform split lives here, in the one constant, rather than in +/// the emitters: an emitter that quietly rewrote Ctrl into Cmd would +/// also rewrite a user's explicit Ctrl hotkey, and that is not its +/// call to make. pub const COPY_CHORD: poltertype_types::SwitchChord = poltertype_types::SwitchChord { // `C` in Win SC Set-1, which coincides with evdev's `KEY_C`. scancode: 0x2E, - ctrl: true, + ctrl: cfg!(not(target_os = "macos")), shift: false, alt: false, - meta: false, + meta: cfg!(target_os = "macos"), }; /// Pause between releasing the hotkey's own modifiers and pressing the @@ -76,14 +76,15 @@ pub const CHORD_RELEASE_SETTLE: Duration = Duration::from_millis(40); /// worse. pub const CHORD_RELEASE_WAIT: Duration = Duration::from_millis(5000); -/// The paste chord that puts the converted selection back. +/// The paste chord that puts the converted selection back. Same +/// platform split as [`COPY_CHORD`], for the same reason. pub const PASTE_CHORD: poltertype_types::SwitchChord = poltertype_types::SwitchChord { // `V` in Win SC Set-1, which coincides with evdev's `KEY_V`. scancode: 0x2F, - ctrl: true, + ctrl: cfg!(not(target_os = "macos")), shift: false, alt: false, - meta: false, + meta: cfg!(target_os = "macos"), }; /// How long the converted text stays on the clipboard after the paste diff --git a/crates/poltertype-core/src/layouts/tests.rs b/crates/poltertype-core/src/layouts/tests.rs index 1ed5b09..ee2bd52 100644 --- a/crates/poltertype-core/src/layouts/tests.rs +++ b/crates/poltertype-core/src/layouts/tests.rs @@ -1068,3 +1068,38 @@ fn transliterate_reports_no_change() { assert_eq!(en.transliterate_to("hello", en), None); assert_eq!(en.transliterate_to(" ", en), None); } + +/// The round-trip destroyer (found live, 2026-08-30): text with no +/// letter the source layout owns was not typed under that layout, and +/// converting it mangles exactly the characters that sit on different +/// punctuation across the two layouts. +/// +/// `проверяю` (ru→en) → `ghjdthz.` is correct: `ю` lives on the +/// period key. But a second press with Russian still active used to +/// convert that Latin string *from* ru — letters passed through, while +/// `.` went through ru's own period key to `/`, cutting its link to +/// `ю` for good. After a few round trips the user's `ю` was a period. +#[test] +fn transliterate_refuses_text_the_source_layout_never_typed() { + let db = LayoutDb::load_embedded(); + let en = db.get(&LayoutId::from("en-US")).expect("en-US"); + let ru = db.get(&LayoutId::from("ru-RU")).expect("ru-RU"); + + // The honest direction still works, punctuation keys included. + assert_eq!( + ru.transliterate_to("проверяю", en).as_deref(), + Some("ghjdthz.") + ); + assert_eq!( + en.transliterate_to("ghjdthz.", ru).as_deref(), + Some("проверяю") + ); + + // The wrong-direction press is refused instead of eating the `.`: + // `ghjdthz.` holds no Cyrillic, so it was not typed under ru. + assert_eq!(ru.transliterate_to("ghjdthz.", en), None); + // And the mirror case: `проверя.` holds no Latin. + assert_eq!(en.transliterate_to("проверя.", ru), None); + // Pure punctuation travels with no letters at all — refused too. + assert_eq!(ru.transliterate_to(".,-!?", en), None); +} diff --git a/crates/poltertype-core/src/layouts/types.rs b/crates/poltertype-core/src/layouts/types.rs index dffadbf..82cb4c5 100644 --- a/crates/poltertype-core/src/layouts/types.rs +++ b/crates/poltertype-core/src/layouts/types.rs @@ -183,8 +183,29 @@ impl LayoutMapping { /// /// `None` when nothing at all changed, which is the caller's cue /// that this selection was not wrong-layout text and should be put - /// back untouched. + /// back untouched — and also when the text holds no letter this + /// layout owns, for the reason below. pub fn transliterate_to(&self, text: &str, to: &Self) -> Option { + // At least one letter of this layout's own alphabet, or the + // text was not typed under this layout and mapping it would + // only corrupt it. The failure this prevents is one-way: + // convert `проверяю` (ru→en) and you get `ghjdthz.` — the `ю` + // correctly came out as `.` through its key. Press the hotkey + // again with Russian still active and the Latin letters pass + // through untouched (ru produces none of them), but `.` DOES + // exist on ru — on its own key — so it alone gets "converted", + // to `/`. The period's link to the `ю` key is gone, and no + // later pass brings the letter back: every character that + // sits on different punctuation across the two layouts drifts + // like this, one wrong-direction press at a time. Letters + // survive such a press; punctuation must not travel without + // them. + if !text + .chars() + .any(|ch| ch.is_alphabetic() && self.key_for_char(ch).is_some()) + { + return None; + } let mut out = String::with_capacity(text.len()); let mut changed = false; for ch in text.chars() { diff --git a/crates/poltertype-core/src/settings/tests.rs b/crates/poltertype-core/src/settings/tests.rs index a20a8e9..26957b5 100644 --- a/crates/poltertype-core/src/settings/tests.rs +++ b/crates/poltertype-core/src/settings/tests.rs @@ -72,6 +72,7 @@ fn a_sane_check_interval_is_honoured() { let s = UpdateSettings { enabled: true, check_interval_hours: 12, + local_signing_identity: String::new(), }; assert_eq!(s.interval(), std::time::Duration::from_secs(12 * 3600)); } diff --git a/crates/poltertype-core/src/settings/types.rs b/crates/poltertype-core/src/settings/types.rs index 5271b8e..8563620 100644 --- a/crates/poltertype-core/src/settings/types.rs +++ b/crates/poltertype-core/src/settings/types.rs @@ -183,6 +183,15 @@ pub struct EngineSettings { /// deliberately. The manual hotkey still works, since `last_word` is /// stashed before any filter. Default: on. pub suppress_for_all_caps: bool, + /// Hold the user's keystrokes back while a correction burst is on + /// the wire, and replay them after it — the deterministic guard + /// against a letter typed mid-correction landing inside the + /// corrected word. Costs a small input delay right after each + /// correction, which is why it defaults off — see the trade in + /// docs/PERMISSIONS.md. `POLTERTYPE_HOLD_KEYS` (`1`/`0`) overrides + /// in either direction; the gate is built at startup, so changing + /// this needs a restart. + pub hold_keys: bool, } impl Default for EngineSettings { @@ -194,6 +203,7 @@ impl Default for EngineSettings { idle_timeout_ms: 2000, suppress_in_identifiers: true, suppress_for_all_caps: true, + hold_keys: false, } } } @@ -335,6 +345,15 @@ pub struct UpdateSettings { /// (see [`UpdateSettings::interval`]) so a hand-edited `0` cannot /// turn the updater into a request loop against GitHub. pub check_interval_hours: u64, + /// macOS: common name of a codesign identity in the login keychain. + /// Non-empty, and the updater re-signs every swapped bundle with it, + /// so TCC keeps the Accessibility / Input Monitoring grants across + /// updates (they key on certificate + identifier instead of the + /// bundle hash). Empty — ad-hoc updates as before, and the installer + /// resets the two stale TCC records instead, so the Ask buttons + /// work right after the swap. Set up once with + /// `poltertype --setup-local-signing`. + pub local_signing_identity: String, } impl Default for UpdateSettings { @@ -342,6 +361,7 @@ impl Default for UpdateSettings { Self { enabled: true, check_interval_hours: 24, + local_signing_identity: String::new(), } } } diff --git a/crates/poltertype-input/src/clipboard.rs b/crates/poltertype-input/src/clipboard.rs index 384a2be..bca5c91 100644 --- a/crates/poltertype-input/src/clipboard.rs +++ b/crates/poltertype-input/src/clipboard.rs @@ -138,17 +138,14 @@ pub fn clipboard() -> Result, ClipboardGap> { /// has to be reachable without taking focus, which is a property of the /// session and is probed. And the emitter has to be able to hold /// modifiers around a key, to press the copy chord in the first place — -/// which is a property of the platform, and on macOS is still false: -/// `send_chord` there answers `Unsupported`, so a toggle offered on -/// macOS would switch on a feature that then did nothing. +/// which every desktop platform's emitter now can: macOS was the last +/// holdout, gated here until its `send_chord` existed, and posts the +/// chord as `Cmd`-flagged key events since it does. /// /// Kept beside the clipboard rather than in the Settings window so /// there is one answer, and the window and the engine cannot disagree /// about it. pub fn selection_support() -> Result<(), ClipboardGap> { - if cfg!(target_os = "macos") { - return Err(ClipboardGap::NoCopyChord); - } clipboard().map(|_| ()) } diff --git a/crates/poltertype-input/src/factory.rs b/crates/poltertype-input/src/factory.rs index 64960a9..2ce1481 100644 --- a/crates/poltertype-input/src/factory.rs +++ b/crates/poltertype-input/src/factory.rs @@ -10,7 +10,13 @@ use crate::*; /// /// Whether it can actually hold anything is only known once the /// listener has started — see [`KeyGate::available`]. -pub fn create_key_gate() -> KeyGate { +/// +/// `hold_keys` is the `[engine].hold_keys` config value. Wired to the +/// macOS gate only for now: the Windows gate keeps its env-only switch +/// until the change can go through a Windows test run, and Linux/evdev +/// holds by construction. +pub fn create_key_gate(hold_keys: bool) -> KeyGate { + let _ = hold_keys; #[cfg(target_os = "linux")] { linux::create_key_gate() @@ -21,7 +27,7 @@ pub fn create_key_gate() -> KeyGate { } #[cfg(target_os = "macos")] { - KeyGate::macos(std::sync::Arc::new(macos::MacosGate::new())) + KeyGate::macos(std::sync::Arc::new(macos::MacosGate::new(hold_keys))) } #[cfg(not(any(target_os = "linux", windows, target_os = "macos")))] { diff --git a/crates/poltertype-input/src/macos/codes.rs b/crates/poltertype-input/src/macos/codes.rs index 9dbbd98..358fbfb 100644 --- a/crates/poltertype-input/src/macos/codes.rs +++ b/crates/poltertype-input/src/macos/codes.rs @@ -185,3 +185,79 @@ pub(crate) fn mac_keycode_to_sc1(kvk: u16) -> u32 { _ => kvk as u32, } } + +// ─── Win SC Set-1 → Apple keycode, for chords we *press* ───────────── +// +// The inverse of [`mac_keycode_to_sc1`], for the emitter's +// `send_chord`: the engine hands it a chord in SC-1 space (the +// engine's only keycode space) and the `CGEvent` it must post wants an +// Apple virtual keycode. Covers the main block a chord's key can +// realistically be — letters, digits, punctuation, the boundary keys — +// and answers `None` past it, so an unmappable chord fails loudly in +// the emitter instead of posting a wrong key. +// +// No identity fallback on purpose: the two spaces overlap with +// different meanings (SC-1 0x2E is `C`, Apple 0x2E is `M`), and a +// chord pressed on the wrong key is exactly the silent failure this +// module's tables exist to prevent. + +pub(crate) fn sc1_to_mac_keycode(sc1: u32) -> Option { + Some(match sc1 { + // Letters + 0x1E => 0x00, // A + 0x1F => 0x01, // S + 0x20 => 0x02, // D + 0x21 => 0x03, // F + 0x23 => 0x04, // H + 0x22 => 0x05, // G + 0x2C => 0x06, // Z + 0x2D => 0x07, // X + 0x2E => 0x08, // C + 0x2F => 0x09, // V + 0x30 => 0x0B, // B + 0x10 => 0x0C, // Q + 0x11 => 0x0D, // W + 0x12 => 0x0E, // E + 0x13 => 0x0F, // R + 0x15 => 0x10, // Y + 0x14 => 0x11, // T + 0x18 => 0x1F, // O + 0x16 => 0x20, // U + 0x17 => 0x22, // I + 0x19 => 0x23, // P + 0x26 => 0x25, // L + 0x24 => 0x26, // J + 0x25 => 0x28, // K + 0x31 => 0x2D, // N + 0x32 => 0x2E, // M + // Numbers + 0x02 => 0x12, // 1 + 0x03 => 0x13, // 2 + 0x04 => 0x14, // 3 + 0x05 => 0x15, // 4 + 0x06 => 0x17, // 5 + 0x07 => 0x16, // 6 + 0x08 => 0x1A, // 7 + 0x09 => 0x1C, // 8 + 0x0A => 0x19, // 9 + 0x0B => 0x1D, // 0 + // Boundaries / punctuation + 0x1C => 0x24, // Return + 0x0F => 0x30, // Tab + 0x39 => 0x31, // Space + 0x0E => KVK_DELETE, // Backspace + 0x01 => 0x35, // Esc + 0x33 => 0x2B, // Comma + 0x34 => 0x2F, // Period + 0x35 => 0x2C, // Slash + 0x27 => 0x29, // ; + 0x28 => 0x27, // ' + 0x1A => 0x21, // [ + 0x1B => 0x1E, // ] + 0x2B => 0x2A, // backslash + 0x29 => 0x32, // backtick + 0x0D => 0x18, // = + 0x0C => 0x1B, // - + _ => return None, + }) +} diff --git a/crates/poltertype-input/src/macos/consts.rs b/crates/poltertype-input/src/macos/consts.rs index 1227515..7c9af4b 100644 --- a/crates/poltertype-input/src/macos/consts.rs +++ b/crates/poltertype-input/src/macos/consts.rs @@ -18,3 +18,19 @@ pub(super) const K_CG_EVENT_SOURCE_USER_DATA: u32 = 42; /// "real" input: the backspace burst poisons the word buffer right /// after a correction, and every second word gets skipped as tainted. pub(super) const EMITTER_TAG: i64 = 0x504F_4C54; // "POLT" + +/// `kCGMouseEventButtonNumber` — which button a mouse event is about +/// (0 = left, 1 = right, 2+ = the extras). +pub(super) const K_CG_MOUSE_EVENT_BUTTON_NUMBER: u32 = 23; + +/// Count of our own injected key-downs seen back by the event tap. +/// +/// `CGEventPost` is fire-and-forget; the only in-process proof that the +/// window server accepted an event is its echo arriving at our own +/// session tap (stamped with [`EMITTER_TAG`]). The emitter paces a +/// backspace burst against this counter instead of a fixed sleep — +/// fields that re-query on every keystroke (Spotlight) drop deletes +/// posted on a timer, and a lost delete leaves the first letter of the +/// word standing (`ьmahou`, measured 2026-08-30). +pub(super) static INJECTED_KEYDOWN_ECHOES: std::sync::atomic::AtomicU64 = + std::sync::atomic::AtomicU64::new(0); diff --git a/crates/poltertype-input/src/macos/emitter.rs b/crates/poltertype-input/src/macos/emitter.rs index 5f5aef3..0e56dbe 100644 --- a/crates/poltertype-input/src/macos/emitter.rs +++ b/crates/poltertype-input/src/macos/emitter.rs @@ -13,10 +13,10 @@ use tracing::debug; use super::codes::{ FLAG_ALTERNATE, FLAG_COMMAND, FLAG_CONTROL, FLAG_SHIFT, KVK_COMMAND, KVK_CONTROL, KVK_DELETE, - KVK_OPTION, KVK_SHIFT, + KVK_OPTION, KVK_SHIFT, sc1_to_mac_keycode, }; use super::consts::{EMITTER_TAG, K_CG_EVENT_SOURCE_USER_DATA}; -use crate::{InputError, KeyEmitter, Modifiers}; +use crate::{InputError, KeyEmitter, Modifiers, SwitchChord}; /// Gap between the modifier releases and whatever we type next. /// @@ -39,6 +39,15 @@ const MODIFIER_SETTLE: Duration = Duration::from_millis(4); /// before the word). 2 ms matches the X11 emitter's `KEY_STEP`. const KEY_STEP: Duration = Duration::from_millis(2); +/// How long a backspace waits for its own tap echo before giving up +/// and falling back to timer pacing. Generous against a busy window +/// server, negligible against a human: even all-timeouts on a +/// ten-letter word is a quarter second. +const ECHO_WAIT: Duration = Duration::from_millis(25); + +/// Poll step while waiting for the echo. +const ECHO_POLL: Duration = Duration::from_micros(500); + pub struct MacosEmitter; impl MacosEmitter { @@ -80,7 +89,29 @@ impl KeyEmitter for MacosEmitter { } let src = event_source()?; for _ in 0..n { + // Paced against the tap echo, not the clock. A fixed + // KEY_STEP was measured enough for ordinary fields, but a + // field that re-queries on every keystroke — Spotlight — + // still dropped deletes posted on a timer, leaving the + // word's first letter standing with the correction glued + // to it (`ьmahou`, 2026-08-30). The echo of our own + // key-down arriving back at the session tap is the one + // in-process proof the window server has sequenced the + // event; waiting for it spaces the burst by how fast the + // system actually drains it. The timeout covers a dead or + // listen-degraded tap: pacing falls back to the old sleep + // and the burst still completes. + let seen = super::consts::INJECTED_KEYDOWN_ECHOES + .load(std::sync::atomic::Ordering::Acquire); keyboard_event(&src, KVK_DELETE, true)?.post(CGEventTapLocation::HID); + let deadline = std::time::Instant::now() + ECHO_WAIT; + while super::consts::INJECTED_KEYDOWN_ECHOES + .load(std::sync::atomic::Ordering::Acquire) + <= seen + && std::time::Instant::now() < deadline + { + std::thread::sleep(ECHO_POLL); + } std::thread::sleep(KEY_STEP); keyboard_event(&src, KVK_DELETE, false)?.post(CGEventTapLocation::HID); std::thread::sleep(KEY_STEP); @@ -105,6 +136,49 @@ impl KeyEmitter for MacosEmitter { Ok(()) } + fn send_chord(&self, chord: SwitchChord) -> Result<(), InputError> { + // The chord arrives in SC-1 space, like everything the engine + // says; the reverse table answers `None` for a key it cannot + // name, and pressing a *wrong* key with Cmd held is a real + // action in someone's application — so refuse, loudly. + let Some(kvk) = sc1_to_mac_keycode(chord.scancode) else { + return Err(InputError::Unsupported(format!( + "no Apple keycode for SC-1 {:#04x}", + chord.scancode + ))); + }; + + let mut flags = 0u64; + for (on, bit) in [ + (chord.ctrl, FLAG_CONTROL), + (chord.shift, FLAG_SHIFT), + (chord.alt, FLAG_ALTERNATE), + (chord.meta, FLAG_COMMAND), + ] { + if on { + flags |= bit; + } + } + + // The modifiers travel as flags *on the key events* rather than + // as their own press/release pair. That is how macOS itself + // matches menu shortcuts — the receiver reads the event's + // flags, not the modifier keys' physical state — and it leaves + // nothing to get stuck: no down was posted, so no up is owed. + // `keyboard_event` has already stripped the hardware flags, so + // what the app sees is exactly the chord and not the chord plus + // whatever the user's fingers are doing. + let src = event_source()?; + for key_down in [true, false] { + let ev = keyboard_event(&src, CGKeyCode::from(kvk), key_down)?; + ev.set_flags(CGEventFlags::from_bits_truncate(flags)); + ev.post(CGEventTapLocation::HID); + std::thread::sleep(KEY_STEP); + } + debug!(scancode = chord.scancode, kvk, flags, "posted macOS chord"); + Ok(()) + } + fn release_modifiers(&self, held: Modifiers) -> Result<(), InputError> { // macOS has no key-up for a modifier: press and release are // both `kCGEventFlagsChanged` events whose *flags* say what is diff --git a/crates/poltertype-input/src/macos/gate.rs b/crates/poltertype-input/src/macos/gate.rs index 5f1c6c5..eceff63 100644 --- a/crates/poltertype-input/src/macos/gate.rs +++ b/crates/poltertype-input/src/macos/gate.rs @@ -13,13 +13,11 @@ use tracing::{debug, info}; use crate::hold::HoldState; /// Environment override for the key gate, read once at startup: -/// `POLTERTYPE_HOLD_KEYS=1` on, `=0` off. -/// -/// **Default off on macOS, as on Windows, and for the same reason: not -/// fear, but latency.** Held keys are withheld from the application for -/// the length of the flush, which reads as the caret lagging behind -/// your typing after every correction. Switch it on if you type fast -/// enough to hit the race; `docs/PERMISSIONS.md` states the trade. +/// `POLTERTYPE_HOLD_KEYS=1` forces it on, `=0` forces it off — +/// overriding `[engine].hold_keys` in either direction. The trade is +/// latency: held keys are withheld from the application for the length +/// of the flush, which reads as the caret lagging right after a +/// correction; `docs/PERMISSIONS.md` states it. pub(crate) const HOLD_KEYS_ENV: &str = "POLTERTYPE_HOLD_KEYS"; pub struct MacosGate { @@ -37,17 +35,28 @@ pub struct MacosGate { impl Default for MacosGate { fn default() -> Self { - Self::new() + Self::new(true) } } impl MacosGate { - pub(crate) fn new() -> Self { - let enabled = std::env::var(HOLD_KEYS_ENV).as_deref() == Ok("1"); + pub(crate) fn new(config_hold_keys: bool) -> Self { + // `[engine].hold_keys` decides; the env var stays as an + // emergency override in either direction. Without the gate, a + // letter typed in the gap between the backspace burst and the + // retype lands in front of the corrected word (measured + // 2026-08-30), and the probe-and-repair fallback is + // probabilistic — see docs/PERMISSIONS.md for the latency + // trade that keeps the default off. + let enabled = match std::env::var(HOLD_KEYS_ENV).as_deref() { + Ok("1") => true, + Ok("0") => false, + _ => config_hold_keys, + }; if enabled { info!( - "key gate enabled by {HOLD_KEYS_ENV}=1 — keystrokes are held back during \ - corrections, at a small delay after each one (see docs/PERMISSIONS.md)" + "key gate on ([engine].hold_keys / {HOLD_KEYS_ENV}) — keystrokes are held \ + back during corrections, at a small delay after each one" ); } Self { @@ -130,7 +139,7 @@ mod tests { let _env = ENV.lock().unwrap_or_else(|e| e.into_inner()); // Enabled via env so the test is independent of the default. unsafe { std::env::set_var(HOLD_KEYS_ENV, "1") }; - let g = MacosGate::new(); + let g = MacosGate::new(false); assert!(!g.available(), "no tap yet — must not claim to hold"); assert!(!g.hold(), "hold without a tap reports unheld"); g.set_tap_running(true); @@ -145,7 +154,7 @@ mod tests { fn env_zero_disables_even_with_a_running_tap() { let _env = ENV.lock().unwrap_or_else(|e| e.into_inner()); unsafe { std::env::set_var(HOLD_KEYS_ENV, "0") }; - let g = MacosGate::new(); + let g = MacosGate::new(true); g.set_tap_running(true); assert!(!g.available()); assert!(!g.hold()); @@ -153,14 +162,14 @@ mod tests { } #[test] - fn default_is_opt_in() { + fn config_decides_when_env_is_unset() { let _env = ENV.lock().unwrap_or_else(|e| e.into_inner()); unsafe { std::env::remove_var(HOLD_KEYS_ENV) }; - let g = MacosGate::new(); - g.set_tap_running(true); - assert!( - !g.available(), - "default must be opt-in (latency trade — see docs/PERMISSIONS.md)" - ); + let on = MacosGate::new(true); + on.set_tap_running(true); + assert!(on.available(), "config on, env unset — gate holds"); + let off = MacosGate::new(false); + off.set_tap_running(true); + assert!(!off.available(), "config off, env unset — gate stays out"); } } diff --git a/crates/poltertype-input/src/macos/listener.rs b/crates/poltertype-input/src/macos/listener.rs index 5eaa5b2..53f7560 100644 --- a/crates/poltertype-input/src/macos/listener.rs +++ b/crates/poltertype-input/src/macos/listener.rs @@ -19,9 +19,13 @@ use crossbeam_channel::Sender; use tracing::{debug, info, trace}; use super::codes::{flags_changed_direction, mac_keycode_to_sc1}; -use super::consts::{EMITTER_TAG, K_CG_EVENT_SOURCE_USER_DATA, K_CG_KEYBOARD_EVENT_KEYCODE}; +use super::consts::{ + EMITTER_TAG, K_CG_EVENT_SOURCE_USER_DATA, K_CG_KEYBOARD_EVENT_KEYCODE, + K_CG_MOUSE_EVENT_BUTTON_NUMBER, +}; use super::gate::MacosGate; use crate::{InputError, InputListener, KeyDirection, KeyEvent, Modifiers}; +use poltertype_types::SC_POINTER_BUTTON; // ─── Accessibility permission prompt ───────────────────────────────── // @@ -129,10 +133,39 @@ impl InputListener for MacosListener { /// the engine has no use for. Runs inside the tap callback, which must /// do nothing but this and a `try_send` — see [`TAP_PORT`]. fn to_key_event(ev_type: CGEventType, event: &CGEvent) -> Option { + let flags = event.get_flags(); + + // A click usually moves the caret — same contract as the X11 and + // Wayland listeners, which report every caret-moving button as the + // pointer marker so the engine abandons a word the user has + // clicked away from (and drops the switch-last stash with it — + // without this, a Mac kept the stash for its full TTL and the + // hotkey kept correcting a word the caret left a click ago). + // Down only, releases are noise; the scroll wheel is a different + // event type here and stays untapped, so no filtering is needed. + if matches!( + ev_type, + CGEventType::LeftMouseDown | CGEventType::RightMouseDown | CGEventType::OtherMouseDown + ) { + return Some(KeyEvent { + vk: event.get_integer_value_field(K_CG_MOUSE_EVENT_BUTTON_NUMBER) as u32, + scancode: SC_POINTER_BUTTON, + direction: KeyDirection::Press, + modifiers: Modifiers { + shift: flags.contains(CGEventFlags::CGEventFlagShift), + control: flags.contains(CGEventFlags::CGEventFlagControl), + alt: flags.contains(CGEventFlags::CGEventFlagAlternate), + meta: flags.contains(CGEventFlags::CGEventFlagCommand), + caps: flags.contains(CGEventFlags::CGEventFlagAlphaShift), + }, + injected: event.get_integer_value_field(K_CG_EVENT_SOURCE_USER_DATA) != 0, + timestamp_ms: 0, + }); + } + // `CGEventField` is a `u32` type-alias in core-graphics 0.24, so the // documented Apple constants go straight through. let vk = event.get_integer_value_field(K_CG_KEYBOARD_EVENT_KEYCODE) as u32; - let flags = event.get_flags(); let direction = match ev_type { CGEventType::KeyDown => KeyDirection::Press, @@ -201,6 +234,14 @@ fn run_tap_thread(gate: Option>, ready_tx: Sender>, ready_tx: Sender SetupReport { +pub(super) fn probe(local_signing_identity: &str) -> SetupReport { let listen = input_monitoring_state(); let accessibility = accessibility_state(listen); SetupReport { @@ -61,18 +61,38 @@ pub(super) fn probe() -> SetupReport { title: "Grant Accessibility".to_owned(), detail: "System Settings → Privacy & Security → Accessibility, then switch \ PolterType on. This is what lets the app watch for a wrong-layout \ - word and type the corrected one back." + word and type the corrected one back. If PolterType is not in the \ + list at all, press \u{201c}+\u{201d} under the list and add it from \ + Applications." .to_owned(), action: Some(step_action(accessibility, Permission::Accessibility)), state: accessibility, }, SetupStep { title: "Grant Input Monitoring".to_owned(), - detail: "System Settings → Privacy & Security → Input Monitoring, then switch \ - PolterType on. Separate from Accessibility and easy to miss — with \ - only one of the two granted the app starts but never sees a keystroke." - .to_owned(), - action: Some(step_action(listen, Permission::InputMonitoring)), + // On current macOS the Accessibility grant covers this + // too — measured on 26: one system prompt, and both + // probes answer granted. Two Ask buttons for one + // decision read as two decisions, so the button shows + // only when Accessibility is done and this is somehow + // still not — the older-macOS case this project also + // supports. + detail: if listen == StepState::Done { + "Granted — on current macOS this comes with the Accessibility grant above." + .to_owned() + } else if accessibility == StepState::Done { + "Usually granted together with Accessibility, but this system still says \ + no. Use the button; if PolterType is not in the list at all, press \ + \u{201c}+\u{201d} under the list and add it from Applications." + .to_owned() + } else { + "Covered by the Accessibility grant above on current macOS — do that one \ + first and this row turns Ready by itself. A separate switch exists only \ + on older systems." + .to_owned() + }, + action: (accessibility == StepState::Done && listen != StepState::Done) + .then(|| step_action(listen, Permission::InputMonitoring)), state: listen, }, SetupStep { @@ -83,6 +103,8 @@ pub(super) fn probe() -> SetupReport { state: StepState::Unknown, action: Some(StepAction::Open(ACCESSIBILITY_PANE_URL.to_owned())), }, + signing_step(local_signing_identity), + notifications_step(), ], } } @@ -163,3 +185,211 @@ pub(super) fn settings_pane_url(permission: Permission) -> &'static str { Permission::InputMonitoring => INPUT_MONITORING_PANE_URL, } } + +// ─── Local update signing ───────────────────────────────────────────── +// +// The permission loss this prevents: an ad-hoc bundle's TCC grants key +// on the hash of its bytes, so every self-update kills both grants +// (issue #42). Signed with a *stable* identity — any identity, it does +// not need Apple behind it — the grants key on certificate + +// identifier instead and survive every update. Measured on an M1 Pro: +// a bundle re-signed with a self-made keychain identity kept both +// grants across repeated rebuilds and one full staged update. + +/// The step the Setup pane shows about it. +fn signing_step(identity: &str) -> SetupStep { + let (state, detail) = if identity.is_empty() { + ( + StepState::Todo, + "Every update currently costs both permissions above, because macOS ties them \ + to the exact copy of the app. One click creates a private signing identity in \ + your keychain; every update is then re-signed with it and the permissions \ + survive. Nothing leaves your machine. macOS will show one password prompt — \ + \u{201c}codesign wants to access key\u{201d}: that is your new key being used for \ + the first time. Enter your login password and press \u{201c}Always Allow\u{201d}, \ + and it never appears again." + .to_owned(), + ) + } else { + match identity_in_keychain(identity) { + Some(true) => ( + StepState::Done, + format!( + "Updates are re-signed with “{identity}” from your keychain, so the \ + permissions above survive them." + ), + ), + Some(false) => ( + StepState::Todo, + format!( + "The config names “{identity}”, but no such identity is in your \ + keychain — updates fall back to resetting the permissions. The button \ + recreates it." + ), + ), + None => ( + StepState::Unknown, + "Could not read the keychain to check the signing identity.".to_owned(), + ), + } + }; + SetupStep { + title: "Keep permissions across updates".to_owned(), + detail, + action: (state != StepState::Done).then_some(StepAction::SetupLocalSigning), + state, + } +} + +/// Does the login keychain hold a codesigning identity by this name? +/// `None` when `security` itself failed — an answer we must not guess. +fn identity_in_keychain(name: &str) -> Option { + let out = std::process::Command::new("/usr/bin/security") + .args(["find-identity", "-p", "codesigning"]) + .output() + .ok()?; + let listing = String::from_utf8_lossy(&out.stdout); + Some(listing.contains(&format!("\"{name}\""))) +} + +/// Create the identity, or adopt one already present under this name. +/// +/// The key and certificate are generated with the system LibreSSL and +/// imported into the login keychain with `codesign` pre-authorised +/// (`-T`), so signing never has to prompt. The certificate is +/// self-signed and trusted by nobody — which is exactly enough: TCC +/// matching wants a *stable* certificate, not a trusted one, and +/// Gatekeeper never sees an app that was built or updated locally +/// without a quarantine flag. +pub(super) fn setup_local_signing(name: &str) -> Result<(), String> { + if identity_in_keychain(name) == Some(true) { + return Ok(()); // adopt, never duplicate + } + if name.contains(['"', '\'', '\\', '/']) { + return Err("the identity name must not contain quotes or slashes".to_owned()); + } + + let dir = std::env::temp_dir().join(format!("poltertype-signing-{}", std::process::id())); + std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?; + let key = dir.join("key.pem"); + let cert = dir.join("cert.pem"); + let run = |cmd: &str, args: &[&str]| -> Result<(), String> { + let out = std::process::Command::new(cmd) + .args(args) + .output() + .map_err(|e| format!("{cmd}: {e}"))?; + if out.status.success() { + Ok(()) + } else { + Err(format!( + "{cmd} failed: {}", + String::from_utf8_lossy(&out.stderr).trim() + )) + } + }; + + let result = (|| { + run( + "/usr/bin/openssl", + &[ + "req", "-x509", "-newkey", "rsa:2048", + "-keyout", key.to_str().ok_or("bad tmp path")?, + "-out", cert.to_str().ok_or("bad tmp path")?, + "-days", "3650", "-nodes", + "-subj", &format!("/CN={name}"), + "-addext", "keyUsage=critical,digitalSignature", + "-addext", "extendedKeyUsage=critical,codeSigning", + "-addext", "basicConstraints=critical,CA:FALSE", + ], + )?; + // Two imports, PEM by PEM: LibreSSL's PKCS#12 defaults are not + // accepted by `security import` (MAC verification failure), and + // the -legacy escape hatch is an OpenSSL-3-ism it lacks. + run( + "/usr/bin/security", + &[ + "import", key.to_str().ok_or("bad tmp path")?, + "-T", "/usr/bin/codesign", + ], + )?; + run( + "/usr/bin/security", + &["import", cert.to_str().ok_or("bad tmp path")?], + )?; + if identity_in_keychain(name) != Some(true) { + return Err("imported, but the identity did not appear in the keychain".to_owned()); + } + // Use the key once, right now, on a scratch copy of a system + // binary. The keychain confirms first use of a fresh key with + // its "codesign wants to access key" password prompt, and the + // moment for that dialog is HERE — the user just pressed the + // button and is told to expect it — not in the middle of the + // first background update, where it reads as malware. After + // "Always Allow" it never returns. + let scratch = dir.join("scratch-sign"); + std::fs::copy("/usr/bin/true", &scratch).map_err(|e| e.to_string())?; + run( + "/usr/bin/codesign", + &[ + "--force", + "--sign", + name, + scratch.to_str().ok_or("bad tmp path")?, + ], + )?; + Ok(()) + })(); + + let _ = std::fs::remove_dir_all(&dir); + result +} + +// ─── Notifications ─────────────────────────────────────────────────── +// +// Three independent layers decide whether a toast is seen, and every +// one of them fails silently: the app's own switch, the per-app +// "Allow" + alert style in System Settings, and Focus/Do Not Disturb +// over everything. The first is ours; the middle one macOS offers no +// public query for; the last is readable from the session's DND +// assertions file. So this step says what can be said: whether Focus +// is muting banners right now, and where the per-app switches live. + +/// Is a Focus mode (Do Not Disturb) holding banners back right now? +/// `None` when the assertions file cannot be read — possible, it sits +/// behind TCC on some setups — in which case we say nothing rather +/// than guess. +fn focus_is_on() -> Option { + let home = std::env::var_os("HOME")?; + let path = std::path::Path::new(&home).join("Library/DoNotDisturb/DB/Assertions.json"); + let text = std::fs::read_to_string(path).ok()?; + Some(text.contains("assertionDetailsIdentifier")) +} + +/// The Setup step about it. +fn notifications_step() -> SetupStep { + let (state, detail) = match focus_is_on() { + Some(true) => ( + StepState::Todo, + "A Focus mode (Do Not Disturb) is on right now: notifications go silently to \ + Notification Center and no banner appears. Turn it off in Control Centre (the \ + moon in the menu bar). Separately, the app must be allowed under System \ + Settings → Notifications, with an alert style other than “None”." + .to_owned(), + ), + _ => ( + StepState::Unknown, + "macOS offers no way to check this from here, so after the first notification \ + ever sent, look for PolterType under System Settings → Notifications: “Allow \ + notifications” on, and an alert style other than “None”. If banners still do \ + not appear, check Focus (Do Not Disturb) in Control Centre — it silences \ + banners while everything reads as enabled." + .to_owned(), + ), + }; + SetupStep { + title: "Let notifications through".to_owned(), + detail, + state, + action: Some(StepAction::Open(NOTIFICATIONS_PANE_URL.to_owned())), + } +} diff --git a/crates/poltertype-input/src/setup/mod.rs b/crates/poltertype-input/src/setup/mod.rs index 7b839c2..ea7d9b2 100644 --- a/crates/poltertype-input/src/setup/mod.rs +++ b/crates/poltertype-input/src/setup/mod.rs @@ -29,14 +29,19 @@ pub use types::{SetupReport, SetupStep}; /// again* click — a handful of `stat`s and one framework call — and /// deliberately not cached, since the entire point is to notice that /// the user just flipped a switch. -pub fn probe_setup() -> SetupReport { +/// `local_signing_identity` is `[updates].local_signing_identity` — the +/// macOS pane adds a step about keeping permissions across updates and +/// needs to know whether the machinery is already configured. The +/// other platforms ignore it. +pub fn probe_setup(local_signing_identity: &str) -> SetupReport { + let _ = local_signing_identity; #[cfg(target_os = "linux")] { linux::probe() } #[cfg(target_os = "macos")] { - macos::probe() + macos::probe(local_signing_identity) } #[cfg(windows)] { @@ -96,5 +101,29 @@ pub fn permission_settings_url(permission: Permission) -> Option<&'static str> { } } + +/// The identity name [`setup_local_signing`] creates when the config +/// does not name one. +pub const DEFAULT_LOCAL_SIGNING_IDENTITY: &str = "PolterType Local Signing"; + +/// Create (or adopt) the local code-signing identity the updater +/// re-signs swapped bundles with, so the TCC grants survive updates. +/// +/// macOS only. Idempotent: an identity of that name already in the +/// keychain is adopted rather than duplicated. The caller writes the +/// name into `[updates].local_signing_identity` on `Ok`. +pub fn setup_local_signing(name: &str) -> Result<(), String> { + #[cfg(target_os = "macos")] + { + macos::setup_local_signing(name) + } + #[cfg(not(target_os = "macos"))] + { + let _ = name; + Err("local update signing is a macOS mechanism".to_owned()) + } +} + + #[cfg(test)] mod tests; diff --git a/crates/poltertype-input/src/setup/tests.rs b/crates/poltertype-input/src/setup/tests.rs index 3296f75..d2e97ea 100644 --- a/crates/poltertype-input/src/setup/tests.rs +++ b/crates/poltertype-input/src/setup/tests.rs @@ -11,7 +11,7 @@ use super::*; #[test] fn the_probe_always_produces_something_renderable() { - let report = probe_setup(); + let report = probe_setup(""); for step in &report.steps { assert!(!step.title.is_empty(), "a step with no title renders blank"); assert!( @@ -27,7 +27,7 @@ fn the_probe_always_produces_something_renderable() { /// its "open the pane" button either way. #[test] fn every_actionable_step_says_what_to_do() { - for step in probe_setup().steps { + for step in probe_setup("").steps { if matches!( step.state, StepState::Todo | StepState::NeedsRelogin | StepState::NeedsReset @@ -43,7 +43,7 @@ fn every_actionable_step_says_what_to_do() { #[test] fn needs_attention_tracks_the_steps() { - let report = probe_setup(); + let report = probe_setup(""); let unresolved = report .steps .iter() diff --git a/crates/poltertype-update/src/apply/macos.rs b/crates/poltertype-update/src/apply/macos.rs index b0c4173..4106843 100644 --- a/crates/poltertype-update/src/apply/macos.rs +++ b/crates/poltertype-update/src/apply/macos.rs @@ -47,7 +47,11 @@ fn running_bundle() -> Result { } #[cfg(target_os = "macos")] -pub(super) fn apply(pending: &PendingUpdate, relaunch: bool) -> Result<(), UpdateError> { +pub(super) fn apply( + pending: &PendingUpdate, + relaunch: bool, + sign_identity: &str, +) -> Result<(), UpdateError> { let bundle = running_bundle()?; let staging = crate::staging::staging_dir()?; @@ -58,6 +62,7 @@ pub(super) fn apply(pending: &PendingUpdate, relaunch: bool) -> Result<(), Updat &staging, std::process::id(), relaunch, + sign_identity, ); let script = write_script("install.sh", &body)?; @@ -75,16 +80,81 @@ fn script_body( staging: &Path, pid: u32, relaunch: bool, + sign_identity: &str, ) -> String { // Unconditional, like the other two backends: an update that could // not be unpacked leaves the installed bundle exactly as it was, so // the user who asked for a restart still gets one. + // With one retry: measured 2026-08-30, a single `open` straight + // after the swap-and-resign sometimes starts nothing — same + // command by hand a moment later works. Two seconds and a second + // try cost nobody anything; the exit codes go to installer.log + // either way. let relaunch_line = if relaunch { - format!("open {} || true\n", sh_quote(bundle)) + format!( + "open {b} || {{ echo \"open failed ($?), retrying\"; sleep 2; open {b} || echo \"open failed again ($?)\"; }} +", + b = sh_quote(bundle) + ) } else { String::new() }; + // What happens to the TCC grants after the swap. An ad-hoc bundle's + // grants key on the hash of its bytes, so every update leaves both + // permissions dead while the toggles still read "on" — and, worse, + // with a stale record on file macOS refuses to show the permission + // dialog again, which is what made the Setup pane's Ask buttons + // dead after updates. Two ways out, chosen by config: + // + // * `[updates].local_signing_identity` set — re-sign the swapped + // bundle with that keychain identity. TCC then keys the grants on + // certificate + identifier, and they survive this and every later + // update. Proven on this machine: a dev bundle signed with a + // self-made identity kept both grants across repeated rebuilds. + // The reset still runs on the *first* signed update, because the + // grants on file belong to the old ad-hoc hash. + // * empty — drop the two stale records (`tccutil reset` needs no + // privileges), so the app comes back in the "never asked" state + // where the Ask buttons genuinely raise the system prompts. Two + // prompts instead of the remove-and-re-add hunt. + let sq = |s: &str| format!("'{}'", s.replace('\'', r"'\''")); + let tcc_block = if sign_identity.is_empty() { + "\ttccutil reset Accessibility \"$BID\" || true\n\ + \ttccutil reset ListenEvent \"$BID\" || true\n" + .to_string() + } else { + format!( + "\tif codesign --force --sign {ident} --identifier \"$BID\" {bundle}; then\n\ + \t\tif [ \"$SIGNED_SAME\" = 0 ]; then\n\ + \t\t\ttccutil reset Accessibility \"$BID\" || true\n\ + \t\t\ttccutil reset ListenEvent \"$BID\" || true\n\ + \t\tfi\n\ + \telse\n\ + \t\ttccutil reset Accessibility \"$BID\" || true\n\ + \t\ttccutil reset ListenEvent \"$BID\" || true\n\ + \tfi\n", + ident = sq(sign_identity), + bundle = sh_quote(bundle), + ) + }; + // Whether the outgoing bundle already carries the same identity — + // decided before the swap, on the old bundle: if it does, the + // grants on file already match the certificate and must be left + // alone. + let signed_same_line = if sign_identity.is_empty() { + String::new() + } else { + format!( + "SIGNED_SAME=0\n\ + if codesign -dvv {bundle} 2>&1 | grep -q \"Authority={ident_raw}\"; then\n\ + \tSIGNED_SAME=1\n\ + fi\n", + bundle = sh_quote(bundle), + ident_raw = sign_identity, + ) + }; + // `ditto` rather than `cp -R`: it is Apple's own bundle-aware copy // and preserves resource forks, extended attributes and code-sign // metadata, which `cp` mangles in ways that surface later as a @@ -114,6 +184,7 @@ fn script_body( # abort the script, and the abort would take the relaunch with\n\ # it — leaving a user who clicked \"Restart to update\" with no\n\ # running app and no reason why.\n\ + {signed_same_line}\ ok=0\n\ MNT=$(mktemp -d /tmp/poltertype-update.XXXXXX)\n\ NEW={bundle}.new\n\ @@ -124,23 +195,37 @@ fn script_body( fi\n\ rmdir \"$MNT\" 2>/dev/null || true\n\ \n\ - # The installed bundle is moved aside rather than deleted, so a\n\ - # swap that fails half way can put back something that works\n\ - # instead of leaving an empty /Applications entry.\n\ + # The bundle directory itself is never moved or replaced — its\n\ + # CONTENTS are. Swapping the whole .app (mv aside, mv new in)\n\ + # cost the TCC grants on every update even under a stable\n\ + # signature, while an in-place content change plus re-sign\n\ + # keeps them — measured on macOS 26, 2026-08-31. The outgoing\n\ + # Contents goes aside inside the bundle, for the same rollback\n\ + # guarantee as before.\n\ if [ \"$ok\" = 1 ]; then\n\ - \trm -rf {bundle}.old\n\ - \tmv {bundle} {bundle}.old\n\ - \tif mv \"$NEW\" {bundle}; then\n\ - \t\trm -rf {bundle}.old\n\ + \trm -rf {bundle}/Contents.old\n\ + \tmv {bundle}/Contents {bundle}/Contents.old\n\ + \tif ditto \"$NEW/Contents\" {bundle}/Contents; then\n\ + \t\trm -rf {bundle}/Contents.old \"$NEW\"\n\ \t\txattr -dr com.apple.quarantine {bundle} || true\n\ \telse\n\ - \t\tmv {bundle}.old {bundle}\n\ + \t\trm -rf {bundle}/Contents\n\ + \t\tmv {bundle}/Contents.old {bundle}/Contents\n\ \t\tok=0\n\ \tfi\n\ else\n\ \trm -rf \"$NEW\"\n\ fi\n\ \n\ + # See the TCC comment in script_body: re-sign with the local\n\ + # identity so the grants survive, or drop the stale records so\n\ + # the Ask buttons work. Only after a successful swap — the old\n\ + # bundle keeps its grants when nothing changed.\n\ + if [ \"$ok\" = 1 ]; then\n\ + \tBID=$(/usr/libexec/PlistBuddy -c \"Print :CFBundleIdentifier\" {bundle}/Contents/Info.plist)\n\ + {tcc_block}\ + fi\n\ + \n\ {relaunch_line}\ if [ \"$ok\" = 1 ]; then\n\ \trm -rf {staging}\n\ @@ -149,6 +234,8 @@ fn script_body( fi\n", version = crate::current_version(), hello = HELLO, + signed_same_line = signed_same_line, + tcc_block = tcc_block, dmg = sh_quote(artifact), bundle = sh_quote(bundle), staging = sh_quote(staging), diff --git a/crates/poltertype-update/src/apply/macos/tests.rs b/crates/poltertype-update/src/apply/macos/tests.rs index fa68024..3068e1b 100644 --- a/crates/poltertype-update/src/apply/macos/tests.rs +++ b/crates/poltertype-update/src/apply/macos/tests.rs @@ -6,9 +6,13 @@ use super::*; const BUNDLE: &str = "/Applications/PolterType.app"; -const RELAUNCH: &str = "open '/Applications/PolterType.app' || true"; +const RELAUNCH: &str = "open '/Applications/PolterType.app' ||"; fn body(relaunch: bool) -> String { + body_signed(relaunch, "") +} + +fn body_signed(relaunch: bool, identity: &str) -> String { script_body( "0.99.0", Path::new("/Users/a b/Library/Application Support/poltertype/updates/p.dmg"), @@ -16,6 +20,7 @@ fn body(relaunch: bool) -> String { Path::new("/Users/a b/Library/Application Support/poltertype/updates"), 4242, relaunch, + identity, ) } @@ -33,14 +38,14 @@ fn the_installed_bundle_is_only_touched_once_a_replacement_exists() { let s = body(true); let copied = s.find("ditto").unwrap_or(usize::MAX); let removed = s - .find("rm -rf '/Applications/PolterType.app'.old") + .find("rm -rf '/Applications/PolterType.app'/Contents.old") .unwrap_or(0); assert!(copied < removed, "the app is deleted before it is replaced"); // Moved aside, not deleted outright, so a swap that fails half way // can put back something that runs. - assert!(s.contains("mv '/Applications/PolterType.app' '/Applications/PolterType.app'.old")); - assert!(s.contains("mv '/Applications/PolterType.app'.old '/Applications/PolterType.app'")); + assert!(s.contains("mv '/Applications/PolterType.app'/Contents '/Applications/PolterType.app'/Contents.old")); + assert!(s.contains("mv '/Applications/PolterType.app'/Contents.old '/Applications/PolterType.app'/Contents")); } #[test] @@ -67,3 +72,46 @@ fn a_shell_accepts_the_script() { super::super::tests_util::assert_sh_parses(&body(true)); super::super::tests_util::assert_sh_parses(&body(false)); } + +/// Without a signing identity the installer drops the two stale TCC +/// records, and only after a successful swap: an update that never +/// replaced the bundle must leave the working grants alone. +#[test] +fn without_an_identity_the_stale_grants_are_dropped_after_the_swap() { + let s = body(true); + assert!(s.contains("tccutil reset Accessibility \"$BID\"")); + assert!(s.contains("tccutil reset ListenEvent \"$BID\"")); + assert!(!s.contains("codesign --force"), "nothing to sign with"); + let swap = s.find("ditto \"$NEW/Contents\"").expect("swap present"); + let reset = s.find("tccutil reset").expect("reset present"); + assert!(swap < reset, "grants die only after the bundle changed"); + let guard = s.find("if [ \"$ok\" = 1 ]; then\n\tBID=").expect("ok guard"); + assert!(guard < reset, "reset sits inside the ok guard"); +} + +/// With an identity the swapped bundle is re-signed, and the reset runs +/// only when the outgoing bundle carried a different signature — the +/// one transition where the grants on file cannot match. +#[test] +fn with_an_identity_the_bundle_is_resigned_and_reset_is_conditional() { + let s = body_signed(true, "PolterType Local Signing"); + assert!(s.contains("codesign --force --sign 'PolterType Local Signing'")); + assert!(s.contains("Authority=PolterType Local Signing")); + assert!( + s.contains("if [ \"$SIGNED_SAME\" = 0 ]; then"), + "reset must be gated on the signature transition" + ); + let probe = s.find("SIGNED_SAME=0").expect("probe present"); + let swap = s.find("ditto \"$NEW/Contents\"").expect("swap present"); + assert!(probe < swap, "the old signature is read before the swap"); + // A failed codesign still clears the records — a bundle that + // changed hash with no working signature must not keep dead grants. + assert!(s.contains("else\n\t\ttccutil reset Accessibility")); +} + +/// An identity with a quote in it cannot break out of the script. +#[test] +fn a_hostile_identity_name_stays_quoted() { + let s = body_signed(true, "x' ; rm -rf / ; '"); + assert!(s.contains(r"'x'\'' ; rm -rf / ; '\'''")); +} diff --git a/crates/poltertype-update/src/apply/mod.rs b/crates/poltertype-update/src/apply/mod.rs index 1f9b613..b3a25ea 100644 --- a/crates/poltertype-update/src/apply/mod.rs +++ b/crates/poltertype-update/src/apply/mod.rs @@ -50,7 +50,18 @@ use crate::types::PendingUpdate; /// other two outcomes both mean *keep running*, and are the difference /// between an update that is not coming and one that has arrived but /// cannot restart us. -pub fn apply(pending: &PendingUpdate, relaunch: bool) -> Result { +/// +/// `macos_sign_identity` is `[updates].local_signing_identity`: on +/// macOS the installer re-signs the swapped bundle with it (TCC grants +/// then survive the update), or, when empty, resets the two stale TCC +/// records so the Setup pane can re-ask cleanly. The other platforms +/// ignore it. +pub fn apply( + pending: &PendingUpdate, + relaunch: bool, + macos_sign_identity: &str, +) -> Result { + let _ = macos_sign_identity; if !staging::attempts_left(pending) { return Ok(Applied::Discarded); } @@ -72,7 +83,7 @@ pub fn apply(pending: &PendingUpdate, relaunch: bool) -> Result Result, UpdateError> { debug!(version = %pending.version, "update already staged"); return Ok(Some(pending)); } - info!( - staged = %pending.version, - available = %manifest.version, - "a newer release superseded the staged update; re-staging" - ); - staging::clear_pending(); + // Replace the staged artifact only when the feed moved *ahead* + // of it. A staged version the feed does not know — a local + // build, a rollback, a pre-release — is not the feed's to + // discard: re-staging over it silently replaced a hand-staged + // build with whatever GitHub served, and the next + // "Restart to update" installed something the user never staged. + match crate::is_newer(&manifest.version, &pending.version) { + Ok(true) => { + info!( + staged = %pending.version, + available = %manifest.version, + "a newer release superseded the staged update; re-staging" + ); + staging::clear_pending(); + } + _ => { + info!( + staged = %pending.version, + available = %manifest.version, + "the staged update is ahead of the feed; keeping it" + ); + return Ok(Some(pending)); + } + } } let key = manifest::platform_key();