From 7dffb9000eb655cfdbff12841c565be1f7184f12 Mon Sep 17 00:00:00 2001 From: iga566 Date: Sun, 30 Aug 2026 22:07:10 +0500 Subject: [PATCH 01/21] updates: after the swap, re-sign with the local identity or drop the stale TCC records MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The owner's two asks, both testable in the script's shape: with [updates].local_signing_identity set, the installer re-signs the swapped bundle (grants survive — proven by the dev-signing experiment), resetting the records only on the one transition where they cannot match; with it empty, the installer drops the two stale records so the Setup pane's Ask buttons genuinely work right after an update. (cherry picked from commit f0049bb79d6e672320dc43a48ca101bfbed9d051) --- crates/poltertype-app/src/main.rs | 4 +- crates/poltertype-app/src/updater.rs | 4 +- crates/poltertype-core/src/settings/types.rs | 10 +++ crates/poltertype-update/src/apply/macos.rs | 75 ++++++++++++++++++- .../src/apply/macos/tests.rs | 48 ++++++++++++ crates/poltertype-update/src/apply/mod.rs | 15 +++- 6 files changed, 149 insertions(+), 7 deletions(-) diff --git a/crates/poltertype-app/src/main.rs b/crates/poltertype-app/src/main.rs index 91aad1c..e28317a 100644 --- a/crates/poltertype-app/src/main.rs +++ b/crates/poltertype-app/src/main.rs @@ -850,7 +850,7 @@ fn main() -> Result<()> { // 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 +863,7 @@ 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) { + 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/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/settings/types.rs b/crates/poltertype-core/src/settings/types.rs index 5271b8e..ee94fdc 100644 --- a/crates/poltertype-core/src/settings/types.rs +++ b/crates/poltertype-core/src/settings/types.rs @@ -335,6 +335,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 +351,7 @@ impl Default for UpdateSettings { Self { enabled: true, check_interval_hours: 24, + local_signing_identity: String::new(), } } } diff --git a/crates/poltertype-update/src/apply/macos.rs b/crates/poltertype-update/src/apply/macos.rs index b0c4173..e881dd3 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,6 +80,7 @@ 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 @@ -85,6 +91,61 @@ fn script_body( 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 +175,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\ @@ -141,6 +203,15 @@ fn script_body( \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 +220,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..cf71e57 100644 --- a/crates/poltertype-update/src/apply/macos/tests.rs +++ b/crates/poltertype-update/src/apply/macos/tests.rs @@ -9,6 +9,10 @@ const BUNDLE: &str = "/Applications/PolterType.app"; const RELAUNCH: &str = "open '/Applications/PolterType.app' || true"; 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, ) } @@ -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("mv \"$NEW\"").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("mv \"$NEW\"").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 Date: Sun, 30 Aug 2026 22:19:58 +0500 Subject: [PATCH 02/21] setup pane: keep-permissions-across-updates and notifications steps (macOS) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The owner asked for both to live where the Ask buttons live. The signing step creates (or adopts) a private keychain identity with the system LibreSSL — PEM-by-PEM import, since security(1) refuses LibreSSL PKCS#12 and -legacy is an OpenSSL-3-ism — writes its name to [updates].local_signing_identity, and the updater re-signs every swapped bundle with it from then on. The notifications step says what can honestly be said: whether Focus is silencing banners right now (read from the session DND assertions), and where the per-app switches live, since macOS offers no public query for allow-plus-style. (cherry picked from commit 5eed85f66bb72d466ae202536ddcb4d49f2b04ee) --- .../poltertype-app/src/settings_ui/enums.rs | 4 + .../poltertype-app/src/settings_ui/state.rs | 5 +- .../poltertype-app/src/settings_ui/update.rs | 48 ++++- .../src/settings_ui/view_setup.rs | 4 + crates/poltertype-input/src/setup/consts.rs | 4 + crates/poltertype-input/src/setup/enums.rs | 4 + crates/poltertype-input/src/setup/macos.rs | 193 +++++++++++++++++- crates/poltertype-input/src/setup/mod.rs | 33 ++- 8 files changed, 288 insertions(+), 7 deletions(-) diff --git a/crates/poltertype-app/src/settings_ui/enums.rs b/crates/poltertype-app/src/settings_ui/enums.rs index e1f2543..014149e 100644 --- a/crates/poltertype-app/src/settings_ui/enums.rs +++ b/crates/poltertype-app/src/settings_ui/enums.rs @@ -320,6 +320,10 @@ 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, /// 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..e67a913 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,55 @@ 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(); + 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::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..859349a 100644 --- a/crates/poltertype-app/src/settings_ui/view_setup.rs +++ b/crates/poltertype-app/src/settings_ui/view_setup.rs @@ -232,6 +232,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-input/src/setup/consts.rs b/crates/poltertype-input/src/setup/consts.rs index 89ca926..8316616 100644 --- a/crates/poltertype-input/src/setup/consts.rs +++ b/crates/poltertype-input/src/setup/consts.rs @@ -27,3 +27,7 @@ pub(super) const ACCESSIBILITY_PANE_URL: &str = #[cfg(target_os = "macos")] pub(super) const INPUT_MONITORING_PANE_URL: &str = "x-apple.systempreferences:com.apple.preference.security?Privacy_ListenEvent"; + +/// System Settings → Notifications (the app list lives inside). +pub(super) const NOTIFICATIONS_PANE_URL: &str = + "x-apple.systempreferences:com.apple.Notifications-Settings.extension"; diff --git a/crates/poltertype-input/src/setup/enums.rs b/crates/poltertype-input/src/setup/enums.rs index abeace9..c3f3535 100644 --- a/crates/poltertype-input/src/setup/enums.rs +++ b/crates/poltertype-input/src/setup/enums.rs @@ -46,6 +46,10 @@ pub enum StepAction { /// Accessibility / Input Monitoring). Only ever the *system* /// dialog — we never imitate one. RequestPermission(Permission), + /// Create (or adopt) the local code-signing identity that lets + /// updates keep the TCC grants — see + /// [`setup_local_signing`](super::setup_local_signing). + SetupLocalSigning, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] diff --git a/crates/poltertype-input/src/setup/macos.rs b/crates/poltertype-input/src/setup/macos.rs index f256c85..f143d8c 100644 --- a/crates/poltertype-input/src/setup/macos.rs +++ b/crates/poltertype-input/src/setup/macos.rs @@ -15,7 +15,7 @@ use core_foundation::base::TCFType; use core_foundation::dictionary::{CFDictionary, CFDictionaryRef}; use core_foundation::string::CFStringRef; -use super::consts::{ACCESSIBILITY_PANE_URL, INPUT_MONITORING_PANE_URL}; +use super::consts::{ACCESSIBILITY_PANE_URL, INPUT_MONITORING_PANE_URL, NOTIFICATIONS_PANE_URL}; use super::enums::{Permission, StepAction, StepState}; use super::types::{SetupReport, SetupStep}; @@ -51,7 +51,7 @@ const K_IOHID_ACCESS_TYPE_GRANTED: u32 = 0; const K_IOHID_ACCESS_TYPE_DENIED: u32 = 1; const K_IOHID_ACCESS_TYPE_UNKNOWN: u32 = 2; -pub(super) fn probe() -> SetupReport { +pub(super) fn probe(local_signing_identity: &str) -> SetupReport { let listen = input_monitoring_state(); let accessibility = accessibility_state(listen); SetupReport { @@ -83,6 +83,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 +165,190 @@ 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." + .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", "-v", "-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()); + } + 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; From 024c2185ab9e0bc43c735b80f90bb05aed9b98dd Mon Sep 17 00:00:00 2001 From: iga566 Date: Sun, 30 Aug 2026 22:21:05 +0500 Subject: [PATCH 03/21] setup tests: probe_setup grew an argument (cherry picked from commit e27b0dab45a09e6f8416ba82d03f48fcc39f8c43) --- crates/poltertype-input/src/setup/tests.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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() From c581a882c8286083349ab5ad23e412659c58f874 Mon Sep 17 00:00:00 2001 From: iga566 Date: Sun, 30 Aug 2026 22:22:50 +0500 Subject: [PATCH 04/21] =?UTF-8?q?setup:=20list=20identities=20without=20-v?= =?UTF-8?q?=20=E2=80=94=20a=20self-signed=20one=20is=20untrusted=20by=20po?= =?UTF-8?q?licy=20yet=20signs=20fine,=20and=20-v=20hid=20it=20(the=20step?= =?UTF-8?q?=20then=20offered=20to=20create=20a=20duplicate)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit (cherry picked from commit eb0b91ef4cfd555e01f9288ed28abb0fdb2b5c2b) --- crates/poltertype-input/src/setup/macos.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/poltertype-input/src/setup/macos.rs b/crates/poltertype-input/src/setup/macos.rs index f143d8c..d5e6078 100644 --- a/crates/poltertype-input/src/setup/macos.rs +++ b/crates/poltertype-input/src/setup/macos.rs @@ -222,7 +222,7 @@ fn signing_step(identity: &str) -> SetupStep { /// `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", "-v", "-p", "codesigning"]) + .args(["find-identity", "-p", "codesigning"]) .output() .ok()?; let listing = String::from_utf8_lossy(&out.stdout); From 8df30199ac0b3503a4eca48d943f9c8ea44b08dc Mon Sep 17 00:00:00 2001 From: iga566 Date: Sun, 30 Aug 2026 23:11:05 +0500 Subject: [PATCH 05/21] updates: keep a staged artifact the feed has not caught up with MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-staging used to fire whenever staged != available, which also covered staged AHEAD of the feed — a local build, a rollback, a pre-release. The worker silently replaced such an artifact with whatever GitHub served, and the next Restart-to-update installed something the user never staged (measured twice today, the hard way). Now only a feed version strictly newer than the staged one re-stages. (cherry picked from commit 2d849f54eb18fdee5466476bec32f6092f3690e6) --- crates/poltertype-update/src/check.rs | 30 +++++++++++++++++++++------ 1 file changed, 24 insertions(+), 6 deletions(-) diff --git a/crates/poltertype-update/src/check.rs b/crates/poltertype-update/src/check.rs index 0547eed..2fd02f7 100644 --- a/crates/poltertype-update/src/check.rs +++ b/crates/poltertype-update/src/check.rs @@ -55,12 +55,30 @@ pub fn check_and_stage() -> 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(); From 685b26d1a7ca9d38365fb84f4253c2a10195a07b Mon Sep 17 00:00:00 2001 From: iga566 Date: Sun, 30 Aug 2026 22:55:55 +0500 Subject: [PATCH 06/21] setup: say the keychain prompt is coming and trigger it at button time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 'codesign wants to access key' password prompt is the keychain confirming first use of the fresh key — and it used to fire in the middle of the first background update, where an unexplained system dialog about a key reads as malware. The step's text now says exactly what will appear and what to press, and the creation flow signs a scratch copy of /usr/bin/true immediately, so the dialog lands the moment the user pressed the button and expects to be asked. (cherry picked from commit 8ea89881eb24dc7dede5ef8b67a05a97a51dcf64) --- crates/poltertype-input/src/setup/macos.rs | 23 +++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/crates/poltertype-input/src/setup/macos.rs b/crates/poltertype-input/src/setup/macos.rs index d5e6078..de61f2a 100644 --- a/crates/poltertype-input/src/setup/macos.rs +++ b/crates/poltertype-input/src/setup/macos.rs @@ -184,7 +184,10 @@ fn signing_step(identity: &str) -> SetupStep { "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." + 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 { @@ -296,6 +299,24 @@ pub(super) fn setup_local_signing(name: &str) -> Result<(), String> { 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(()) })(); From eef15c929e07092f792c0b0dd89301314436ac6c Mon Sep 17 00:00:00 2001 From: iga566 Date: Sun, 30 Aug 2026 23:15:37 +0500 Subject: [PATCH 07/21] setup: an Ask that cannot raise a dialog must not be a silent no-op MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After a TCC reset the permission lists hold no PolterType row at all, and the user is left staring at an empty list guessing (the owner's words, deservedly sharper). Ask now also opens the exact pane — the dialog, when it comes, appears over it; when it does not, the user is standing where the '+' button lives — and both step texts spell out the add-by-hand fallback. (cherry picked from commit d909cee378fc6392366c85daf9f17372739be285) --- crates/poltertype-app/src/settings_ui/update.rs | 8 ++++++++ crates/poltertype-input/src/setup/macos.rs | 8 ++++++-- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/crates/poltertype-app/src/settings_ui/update.rs b/crates/poltertype-app/src/settings_ui/update.rs index e67a913..056ab65 100644 --- a/crates/poltertype-app/src/settings_ui/update.rs +++ b/crates/poltertype-app/src/settings_ui/update.rs @@ -612,6 +612,14 @@ impl SettingsApp { // return value is not an answer — re-probe instead of // believing it. poltertype_input::setup::request_permission(permission); + // 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, ); diff --git a/crates/poltertype-input/src/setup/macos.rs b/crates/poltertype-input/src/setup/macos.rs index de61f2a..3a7f4ab 100644 --- a/crates/poltertype-input/src/setup/macos.rs +++ b/crates/poltertype-input/src/setup/macos.rs @@ -61,7 +61,9 @@ pub(super) fn probe(local_signing_identity: &str) -> 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, @@ -70,7 +72,9 @@ pub(super) fn probe(local_signing_identity: &str) -> SetupReport { 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." + only one of the two granted the app starts but never sees a \ + keystroke. 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(listen, Permission::InputMonitoring)), state: listen, From 36070cc7f7d67fec72ebbe5696cd42f48bc41397 Mon Sep 17 00:00:00 2001 From: iga566 Date: Sun, 30 Aug 2026 23:37:50 +0500 Subject: [PATCH 08/21] =?UTF-8?q?updates:=20retry=20the=20relaunch=20open?= =?UTF-8?q?=20=E2=80=94=20measured=20starting=20nothing=20right=20after=20?= =?UTF-8?q?the=20swap-and-resign;=20second=20try=20by=20hand=20always=20wo?= =?UTF-8?q?rked?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit (cherry picked from commit 1d503ada5692385475ed4e96e2e3da1c3326975b) --- crates/poltertype-update/src/apply/macos.rs | 11 ++++++++++- crates/poltertype-update/src/apply/macos/tests.rs | 2 +- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/crates/poltertype-update/src/apply/macos.rs b/crates/poltertype-update/src/apply/macos.rs index e881dd3..a2a3561 100644 --- a/crates/poltertype-update/src/apply/macos.rs +++ b/crates/poltertype-update/src/apply/macos.rs @@ -85,8 +85,17 @@ fn script_body( // 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() }; diff --git a/crates/poltertype-update/src/apply/macos/tests.rs b/crates/poltertype-update/src/apply/macos/tests.rs index cf71e57..aee3db6 100644 --- a/crates/poltertype-update/src/apply/macos/tests.rs +++ b/crates/poltertype-update/src/apply/macos/tests.rs @@ -6,7 +6,7 @@ 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, "") From 09dfaef5bf137805d956f2ee41bc6ab0a04af19a Mon Sep 17 00:00:00 2001 From: iga566 Date: Mon, 31 Aug 2026 00:53:32 +0500 Subject: [PATCH 09/21] updates: replace the bundle CONTENTS in place, never the .app itself MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Measured on macOS 26 (2026-08-31): with the same untrusted signing identity, an in-place content change keeps the TCC grants and the whole-bundle mv dance loses them — which is why every update killed the permissions even after the re-signing work. The bundle directory now stays put and its Contents is what swaps, with the same rollback (Contents.old inside the bundle). (cherry picked from commit 11ef82e7180e414cef235bfc44f4800c2d049329) --- crates/poltertype-update/src/apply/macos.rs | 21 ++++++++++++------- .../src/apply/macos/tests.rs | 10 ++++----- 2 files changed, 18 insertions(+), 13 deletions(-) diff --git a/crates/poltertype-update/src/apply/macos.rs b/crates/poltertype-update/src/apply/macos.rs index a2a3561..4106843 100644 --- a/crates/poltertype-update/src/apply/macos.rs +++ b/crates/poltertype-update/src/apply/macos.rs @@ -195,17 +195,22 @@ 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\ diff --git a/crates/poltertype-update/src/apply/macos/tests.rs b/crates/poltertype-update/src/apply/macos/tests.rs index aee3db6..3068e1b 100644 --- a/crates/poltertype-update/src/apply/macos/tests.rs +++ b/crates/poltertype-update/src/apply/macos/tests.rs @@ -38,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] @@ -82,7 +82,7 @@ fn without_an_identity_the_stale_grants_are_dropped_after_the_swap() { 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("mv \"$NEW\"").expect("swap present"); + 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"); @@ -102,7 +102,7 @@ fn with_an_identity_the_bundle_is_resigned_and_reset_is_conditional() { "reset must be gated on the signature transition" ); let probe = s.find("SIGNED_SAME=0").expect("probe present"); - let swap = s.find("mv \"$NEW\"").expect("swap 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. From ead0e1734d28c2db832e878c4c989e6ac0ef982d Mon Sep 17 00:00:00 2001 From: iga566 Date: Mon, 31 Aug 2026 00:25:11 +0500 Subject: [PATCH 10/21] main: take the settings window along on every exit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Settings GUI is a subprocess of the same executable. Left alive past the main process it is worse than a leak: the updater's relaunch open(1) sees the app as already running, reports success, and brings the orphaned old-version settings window to the front — the updated app never starts (measured twice; also explains the ghost settings window after a plain Quit). The main process now terminates it on the update handoff and on Quit; the waiter thread reaps as usual. (cherry picked from commit 871ef03b7314b33e10ef9cb4764ea4a2a6bd30d0) --- crates/poltertype-app/src/main.rs | 2 + .../poltertype-app/src/settings_proc/mod.rs | 2 +- .../poltertype-app/src/settings_proc/spawn.rs | 42 ++++++++++++++++++- 3 files changed, 44 insertions(+), 2 deletions(-) diff --git a/crates/poltertype-app/src/main.rs b/crates/poltertype-app/src/main.rs index e28317a..2da552f 100644 --- a/crates/poltertype-app/src/main.rs +++ b/crates/poltertype-app/src/main.rs @@ -846,6 +846,7 @@ 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. @@ -863,6 +864,7 @@ fn main() -> Result<()> { // started, and quitting anyway is what // turned a failed update into a machine // with no PolterType running on it. + 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..af6b283 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,39 @@ 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 { + return; + } + info!(pid, "closing the settings window before exit"); + #[cfg(unix)] + { + // SIGTERM via /bin/kill: the child is ours, and the waiter + // thread reaps it as usual. + let _ = std::process::Command::new("kill") + .arg(pid.to_string()) + .status(); + } + #[cfg(windows)] + { + let _ = std::process::Command::new("taskkill") + .args(["/PID", &pid.to_string(), "/F"]) + .status(); + } +} From 1515f8ed390cc3d2d235403f00b1fffbca8b2fc2 Mon Sep 17 00:00:00 2001 From: iga566 Date: Mon, 31 Aug 2026 02:13:32 +0500 Subject: [PATCH 11/21] =?UTF-8?q?settings:=20the=20Restart=20button=20open?= =?UTF-8?q?s=20the=20bundle=20from=20a=20detached=20helper=20after=20this?= =?UTF-8?q?=20process=20exits=20=E2=80=94=20while=20the=20settings=20proce?= =?UTF-8?q?ss=20lives,=20LaunchServices=20reads=20the=20app=20as=20running?= =?UTF-8?q?=20and=20open()=20merely=20activates=20it=20(the=20ghost=20agai?= =?UTF-8?q?n);=20shipped=20as=200.31.2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit (cherry picked from commit ad2815e39e39c7265e199d30bf09e7160a809f3b) --- .../poltertype-app/src/settings_ui/update.rs | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/crates/poltertype-app/src/settings_ui/update.rs b/crates/poltertype-app/src/settings_ui/update.rs index 056ab65..a5ac636 100644 --- a/crates/poltertype-app/src/settings_ui/update.rs +++ b/crates/poltertype-app/src/settings_ui/update.rs @@ -669,6 +669,46 @@ impl SettingsApp { ); } + 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 From 23f452a39903bc43b66386c0890a05ce0f6de0c9 Mon Sep 17 00:00:00 2001 From: iga566 Date: Mon, 31 Aug 2026 01:46:58 +0500 Subject: [PATCH 12/21] setup pane: one Ask covers both on current macOS, and a Restart button MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Measured twice on macOS 26: the Accessibility grant satisfies the Input Monitoring probe too — two Ask buttons for one decision read as two decisions, so the second shows only in the old-macOS case where Accessibility is done and the listen probe still says no (the project supports Big Sur, where the switches are genuinely separate). And the pane grows the button the owner asked for three times: Restart PolterType — permissions are read at startup, and hunting Quit in the tray after every grant was the complaint. (cherry picked from commit a39f237e3bb3fd3043388d544ceb8c9c5a73f3db) --- .../poltertype-app/src/settings_ui/enums.rs | 4 +++ .../src/settings_ui/view_setup.rs | 11 +++++++ crates/poltertype-input/src/setup/macos.rs | 30 ++++++++++++++----- 3 files changed, 38 insertions(+), 7 deletions(-) diff --git a/crates/poltertype-app/src/settings_ui/enums.rs b/crates/poltertype-app/src/settings_ui/enums.rs index 014149e..3226cb1 100644 --- a/crates/poltertype-app/src/settings_ui/enums.rs +++ b/crates/poltertype-app/src/settings_ui/enums.rs @@ -324,6 +324,10 @@ pub enum Message { /// `[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/view_setup.rs b/crates/poltertype-app/src/settings_ui/view_setup.rs index 859349a..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())) diff --git a/crates/poltertype-input/src/setup/macos.rs b/crates/poltertype-input/src/setup/macos.rs index 3a7f4ab..7d64c63 100644 --- a/crates/poltertype-input/src/setup/macos.rs +++ b/crates/poltertype-input/src/setup/macos.rs @@ -70,13 +70,29 @@ pub(super) fn probe(local_signing_identity: &str) -> SetupReport { }, 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. 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(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 { From 9a0c30ec011f4207b9ce44bd5803cbbbd2af2e64 Mon Sep 17 00:00:00 2001 From: iga566 Date: Mon, 31 Aug 2026 02:35:47 +0500 Subject: [PATCH 13/21] settings: take EVERY child window along on exit, tracked or not MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This 0.28-based fork has no second-window guard, so a Settings window and a Setup-alert window coexist while the pid slot remembers only the latest. The survivor is what kept LaunchServices treating the app as running — the updater relaunch then merely raised an orphaned old-version window (measured live: poltertype --setup outliving the swap). kill_settings_ui now also pkills every process of this executable with a window flag. Shipped as 0.31.4. (cherry picked from commit 5b0d9e2ae8fc592d680e0f4fdb197b527659e0d3) --- .../poltertype-app/src/settings_proc/spawn.rs | 29 ++++++++++++++----- 1 file changed, 21 insertions(+), 8 deletions(-) diff --git a/crates/poltertype-app/src/settings_proc/spawn.rs b/crates/poltertype-app/src/settings_proc/spawn.rs index af6b283..7ffb969 100644 --- a/crates/poltertype-app/src/settings_proc/spawn.rs +++ b/crates/poltertype-app/src/settings_proc/spawn.rs @@ -211,17 +211,30 @@ static SETTINGS_CHILD_PID: std::sync::atomic::AtomicU32 = std::sync::atomic::Ato /// same leak without the relaunch twist. pub(crate) fn kill_settings_ui() { let pid = SETTINGS_CHILD_PID.swap(0, Ordering::AcqRel); - if pid == 0 { - return; + if pid != 0 { + info!(pid, "closing the settings window before exit"); } - info!(pid, "closing the settings window before exit"); #[cfg(unix)] { - // SIGTERM via /bin/kill: the child is ours, and the waiter - // thread reaps it as usual. - let _ = std::process::Command::new("kill") - .arg(pid.to_string()) - .status(); + 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)] { From 458477f993209a87ffb3b3d7e296c8c065f8ae79 Mon Sep 17 00:00:00 2001 From: iga566 Date: Sun, 30 Aug 2026 19:28:17 +0500 Subject: [PATCH 14/21] macos: implement send_chord and open selection conversion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The emitter posts the chord's key with the modifiers as CGEventFlags on the key events themselves — how macOS matches menu shortcuts, and nothing is left held. The chord arrives in SC-1 space, so codes.rs grows the reverse table (main block only, no identity fallback: the two keycode spaces overlap with different meanings). COPY/PASTE chords pick Cmd over Ctrl on macOS in the one constant that owns them, and the selection_support() gate falls back to the clipboard probe now that every desktop emitter can press a chord. (cherry picked from commit ba1a1312e31deef54105d3cba13b12b47cd2d29d) (cherry picked from commit 48022b66a4f90790ad28a6ecc55f88d498f4cfc2) --- crates/poltertype-core/src/engine/consts.rs | 21 +++--- crates/poltertype-input/src/clipboard.rs | 9 +-- crates/poltertype-input/src/macos/codes.rs | 76 ++++++++++++++++++++ crates/poltertype-input/src/macos/emitter.rs | 47 +++++++++++- crates/poltertype-input/src/macos/tests.rs | 41 +++++++++++ 5 files changed, 176 insertions(+), 18 deletions(-) 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-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/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/emitter.rs b/crates/poltertype-input/src/macos/emitter.rs index 5f5aef3..09a13cc 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. /// @@ -105,6 +105,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/tests.rs b/crates/poltertype-input/src/macos/tests.rs index eb8ef85..d427c63 100644 --- a/crates/poltertype-input/src/macos/tests.rs +++ b/crates/poltertype-input/src/macos/tests.rs @@ -158,3 +158,44 @@ fn flag_constants_match_core_graphics() { assert_eq!(FLAG_ALTERNATE, CGEventFlags::CGEventFlagAlternate.bits()); assert_eq!(FLAG_COMMAND, CGEventFlags::CGEventFlagCommand.bits()); } + +#[test] +fn sc1_to_mac_keycode_round_trips_through_the_forward_table() { + // The reverse table exists for `send_chord`; a mismatch with the + // forward table means the chord we post is not the key the engine + // named. Walk the whole SC-1 main block: wherever the reverse map + // answers, the forward map must take that keycode straight back. + let mut covered = 0; + for sc1 in 0x01..=0x39u32 { + if let Some(kvk) = sc1_to_mac_keycode(sc1) { + assert_eq!( + mac_keycode_to_sc1(kvk), + sc1, + "SC-1 {sc1:#04x} → Apple {kvk:#04x} does not round-trip" + ); + covered += 1; + } + } + // The main block minus the keys a chord cannot name (modifiers, + // Caps Lock): the count catches a silently shrunken table. + assert_eq!(covered, 52); +} + +#[test] +fn chord_keys_the_engine_actually_presses_are_mapped() { + // The two chords the engine sends today. If either ever answers + // `None`, selection conversion dies at runtime with an + // `Unsupported` — this pins it at test time instead. + assert_eq!(sc1_to_mac_keycode(0x2E), Some(0x08)); // C — the copy chord + assert_eq!(sc1_to_mac_keycode(0x2F), Some(0x09)); // V — the paste chord +} + +#[test] +fn sc1_to_mac_keycode_refuses_what_it_cannot_name() { + // No identity fallback: SC-1 and Apple keycodes overlap with + // different meanings, and a wrong key pressed with Cmd held is a + // real action in someone's application. + for sc1 in [0x1D, 0x2A, 0x36, 0x38, 0x3A, 0x3B, 0x48, 0x5B, 0xFF] { + assert_eq!(sc1_to_mac_keycode(sc1), None, "SC-1 {sc1:#04x}"); + } +} From 312c20e1ceb2f381707f4ec39e42bfe1107f6d31 Mon Sep 17 00:00:00 2001 From: iga566 Date: Sun, 30 Aug 2026 19:40:48 +0500 Subject: [PATCH 15/21] macos: watch caret-moving clicks, so the switch-last stash drops like everywhere else MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tap listened to the keyboard only — the one listener of the three that did. A click never invalidated the stash, so it lived out its full 60-second TTL and the hotkey kept taking the word path; with a selection active that path backspaces straight through the selection. Mouse downs (left, right, other; scroll untouched, same reasoning as X11's button filter) now translate to the pointer marker the engine already knows. (cherry picked from commit a21dacb741b0116219574270f8e4a02ff1f4402c) (cherry picked from commit 9d903217df973a722c5fd446f0f9d6cd283aa753) --- crates/poltertype-input/src/macos/consts.rs | 4 ++ crates/poltertype-input/src/macos/listener.rs | 44 ++++++++++++++++++- 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/crates/poltertype-input/src/macos/consts.rs b/crates/poltertype-input/src/macos/consts.rs index 1227515..2d80bd0 100644 --- a/crates/poltertype-input/src/macos/consts.rs +++ b/crates/poltertype-input/src/macos/consts.rs @@ -18,3 +18,7 @@ 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; diff --git a/crates/poltertype-input/src/macos/listener.rs b/crates/poltertype-input/src/macos/listener.rs index 5eaa5b2..13ed89c 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, @@ -259,6 +292,13 @@ fn run_tap_thread(gate: Option>, ready_tx: Sender Date: Sun, 30 Aug 2026 20:06:08 +0500 Subject: [PATCH 16/21] notifications: register the sender bundle id before the first toast; transliterate refuses text the source layout never typed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two live findings from the same session. mac-notification-sys, when nobody called set_application, resolves an app literally named 'use_default' and macOS answers with a modal 'Where is use_default?' application chooser — and the updater's error toasts bypass the show_notifications gate, so the dialog could appear with notifications off. The bridge now registers our own CFBundleIdentifier once, and skips the toast when there is no bundle to speak as. transliterate_to now requires at least one letter the source layout owns: without it, a wrong-direction press passed letters through but 'converted' the punctuation — ru→en on 'ghjdthz.' turned the period (ex-ю) into a slash through ru's own period key, and no later pass could bring the letter back. (cherry picked from commit de85354b1b1f6174d1f803300cbea8a71fd435c3) (cherry picked from commit e69c5c78c793343c6480d90312a6c25c2f5e2d1a) --- Cargo.lock | 1 + crates/poltertype-app/Cargo.toml | 5 ++ crates/poltertype-app/src/bridges.rs | 61 +++++++++++++++++++++ crates/poltertype-core/src/layouts/tests.rs | 35 ++++++++++++ crates/poltertype-core/src/layouts/types.rs | 23 +++++++- 5 files changed, 124 insertions(+), 1 deletion(-) 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..b2dde1c 100644 --- a/crates/poltertype-app/src/bridges.rs +++ b/crates/poltertype-app/src/bridges.rs @@ -79,6 +79,55 @@ 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(()) => 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 +135,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 +178,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 +210,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 +236,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-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() { From f320cb7a0ec433e5265f6ddfcad0f8a1e393c142 Mon Sep 17 00:00:00 2001 From: iga566 Date: Sun, 30 Aug 2026 20:28:03 +0500 Subject: [PATCH 17/21] =?UTF-8?q?notifications:=20log=20the=20successful?= =?UTF-8?q?=20sender=20registration=20too=20=E2=80=94=20needed=20to=20sequ?= =?UTF-8?q?ence=20the=20AlreadySet(Finder)=20failure?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit (cherry picked from commit dd49bed68a492bfb88dcbbf28714fad2f1293409) (cherry picked from commit a53a1294952c10120e33286438967ca3631a9142) --- crates/poltertype-app/src/bridges.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/poltertype-app/src/bridges.rs b/crates/poltertype-app/src/bridges.rs index b2dde1c..beeac41 100644 --- a/crates/poltertype-app/src/bridges.rs +++ b/crates/poltertype-app/src/bridges.rs @@ -103,7 +103,10 @@ fn notification_sender_ready() -> bool { return false; }; match notify_rust::set_application(&bundle_id) { - Ok(()) => true, + Ok(()) => { + info!(bundle = %bundle_id, "registered as notification sender"); + true + } Err(e) => { warn!(?e, bundle = %bundle_id, "could not register as notification sender"); false From 9fc4f4d6d579b1d30236dea35113dca9305d3ff8 Mon Sep 17 00:00:00 2001 From: iga566 Date: Sun, 30 Aug 2026 20:59:56 +0500 Subject: [PATCH 18/21] macos: pace the backspace burst by tap echoes, not the clock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A fixed KEY_STEP was measured enough for ordinary fields, but Spotlight re-queries on every keystroke and still dropped deletes posted on a timer — the word's first letter survived with the correction glued to it. The emitter now waits for each delete's own echo at the session tap (the one in-process proof the window server sequenced it), with a 25 ms timeout per key that degrades to the old timer pacing when the tap is dead or listen-only. (cherry picked from commit 7f5fbe71a1c1c52d9078d0861ee8839c69670a3f) (cherry picked from commit 23bea8a228e35bf0feadd3f8cf12212a728b61fb) --- crates/poltertype-input/src/macos/consts.rs | 12 +++++++ crates/poltertype-input/src/macos/emitter.rs | 31 +++++++++++++++++++ crates/poltertype-input/src/macos/listener.rs | 8 +++++ 3 files changed, 51 insertions(+) diff --git a/crates/poltertype-input/src/macos/consts.rs b/crates/poltertype-input/src/macos/consts.rs index 2d80bd0..7c9af4b 100644 --- a/crates/poltertype-input/src/macos/consts.rs +++ b/crates/poltertype-input/src/macos/consts.rs @@ -22,3 +22,15 @@ 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 09a13cc..0e56dbe 100644 --- a/crates/poltertype-input/src/macos/emitter.rs +++ b/crates/poltertype-input/src/macos/emitter.rs @@ -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); diff --git a/crates/poltertype-input/src/macos/listener.rs b/crates/poltertype-input/src/macos/listener.rs index 13ed89c..53f7560 100644 --- a/crates/poltertype-input/src/macos/listener.rs +++ b/crates/poltertype-input/src/macos/listener.rs @@ -234,6 +234,14 @@ fn run_tap_thread(gate: Option>, ready_tx: Sender Date: Sun, 30 Aug 2026 21:55:51 +0500 Subject: [PATCH 19/21] engine: [engine].hold_keys config key, macOS wiring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The gate switch moves where the user can see it: a config key, default on in this fork, with the env var demoted to an emergency override in either direction. Wired to the macOS gate only — the Windows gate keeps its env-only switch until the change can go through a Windows test run. (cherry picked from commit 09a5943e74d7c8a1db74029a3caf8f71f3409fa7) (cherry picked from commit f06e36fa02571644193287f688ad803fabf9aa26) --- crates/poltertype-app/src/main.rs | 2 +- crates/poltertype-core/src/settings/types.rs | 9 ++++ crates/poltertype-input/src/factory.rs | 10 +++- crates/poltertype-input/src/macos/gate.rs | 51 ++++++++++++-------- 4 files changed, 48 insertions(+), 24 deletions(-) diff --git a/crates/poltertype-app/src/main.rs b/crates/poltertype-app/src/main.rs index 2da552f..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); diff --git a/crates/poltertype-core/src/settings/types.rs b/crates/poltertype-core/src/settings/types.rs index ee94fdc..c3496f9 100644 --- a/crates/poltertype-core/src/settings/types.rs +++ b/crates/poltertype-core/src/settings/types.rs @@ -183,6 +183,14 @@ 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. Default: on in this fork. `POLTERTYPE_HOLD_KEYS` + /// (`1`/`0`) still overrides for emergencies; the gate is built at + /// startup, so changing this needs a restart. + pub hold_keys: bool, } impl Default for EngineSettings { @@ -194,6 +202,7 @@ impl Default for EngineSettings { idle_timeout_ms: 2000, suppress_in_identifiers: true, suppress_for_all_caps: true, + hold_keys: true, } } } 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/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"); } } From f6f155b0311d325932da41ef96a29f24d5cd8594 Mon Sep 17 00:00:00 2001 From: iga566 Date: Mon, 31 Aug 2026 03:02:39 +0500 Subject: [PATCH 20/21] =?UTF-8?q?engine:=20hold=5Fkeys=20defaults=20off=20?= =?UTF-8?q?upstream=20=E2=80=94=20the=20latency=20trade=20is=20the=20proje?= =?UTF-8?q?ct's=20documented=20stance;=20the=20config=20key=20is=20the=20n?= =?UTF-8?q?ew=20part?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit (cherry picked from commit dbc2753c4130482ee2ca92c6fe2ce9611c9aeb19) --- crates/poltertype-core/src/settings/types.rs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/crates/poltertype-core/src/settings/types.rs b/crates/poltertype-core/src/settings/types.rs index c3496f9..8563620 100644 --- a/crates/poltertype-core/src/settings/types.rs +++ b/crates/poltertype-core/src/settings/types.rs @@ -187,9 +187,10 @@ pub struct EngineSettings { /// 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. Default: on in this fork. `POLTERTYPE_HOLD_KEYS` - /// (`1`/`0`) still overrides for emergencies; the gate is built at - /// startup, so changing this needs a restart. + /// 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, } @@ -202,7 +203,7 @@ impl Default for EngineSettings { idle_timeout_ms: 2000, suppress_in_identifiers: true, suppress_for_all_caps: true, - hold_keys: true, + hold_keys: false, } } } From 9eb33b47cc7efb3634698bdc9507091396af0d29 Mon Sep 17 00:00:00 2001 From: iga566 Date: Mon, 31 Aug 2026 03:11:17 +0500 Subject: [PATCH 21/21] settings tests: UpdateSettings initializer grew the signing-identity field --- crates/poltertype-core/src/settings/tests.rs | 1 + 1 file changed, 1 insertion(+) 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)); }