Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
7dffb90
updates: after the swap, re-sign with the local identity or drop the …
iga566 Aug 30, 2026
1ae5215
setup pane: keep-permissions-across-updates and notifications steps (…
iga566 Aug 30, 2026
024c218
setup tests: probe_setup grew an argument
iga566 Aug 30, 2026
c581a88
setup: list identities without -v — a self-signed one is untrusted by…
iga566 Aug 30, 2026
8df3019
updates: keep a staged artifact the feed has not caught up with
iga566 Aug 30, 2026
685b26d
setup: say the keychain prompt is coming and trigger it at button time
iga566 Aug 30, 2026
eef15c9
setup: an Ask that cannot raise a dialog must not be a silent no-op
iga566 Aug 30, 2026
36070cc
updates: retry the relaunch open — measured starting nothing right af…
iga566 Aug 30, 2026
09dfaef
updates: replace the bundle CONTENTS in place, never the .app itself
iga566 Aug 30, 2026
ead0e17
main: take the settings window along on every exit
iga566 Aug 30, 2026
1515f8e
settings: the Restart button opens the bundle from a detached helper …
iga566 Aug 30, 2026
23f452a
setup pane: one Ask covers both on current macOS, and a Restart button
iga566 Aug 30, 2026
9a0c30e
settings: take EVERY child window along on exit, tracked or not
iga566 Aug 30, 2026
458477f
macos: implement send_chord and open selection conversion
iga566 Aug 30, 2026
312c20e
macos: watch caret-moving clicks, so the switch-last stash drops like…
iga566 Aug 30, 2026
cea8d9c
notifications: register the sender bundle id before the first toast; …
iga566 Aug 30, 2026
f320cb7
notifications: log the successful sender registration too — needed to…
iga566 Aug 30, 2026
9fc4f4d
macos: pace the backspace burst by tap echoes, not the clock
iga566 Aug 30, 2026
1cba18f
engine: [engine].hold_keys config key, macOS wiring
iga566 Aug 30, 2026
f6f155b
engine: hold_keys defaults off upstream — the latency trade is the pr…
iga566 Aug 30, 2026
9eb33b4
settings tests: UpdateSettings initializer grew the signing-identity …
iga566 Aug 30, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions crates/poltertype-app/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
64 changes: 64 additions & 0 deletions crates/poltertype-app/src/bridges.rs
Original file line number Diff line number Diff line change
Expand Up @@ -79,13 +79,68 @@ 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<bool> = OnceLock::new();
*READY.get_or_init(|| {
let Some(bundle_id) = main_bundle_identifier() else {
info!("not running from an .app bundle; system notifications stay off");
return false;
};
match notify_rust::set_application(&bundle_id) {
Ok(()) => {
info!(bundle = %bundle_id, "registered as notification sender");
true
}
Err(e) => {
warn!(?e, bundle = %bundle_id, "could not register as notification sender");
false
}
}
})
}

#[cfg(not(target_os = "macos"))]
fn notification_sender_ready() -> bool {
true
}

/// Our own `CFBundleIdentifier`, or `None` outside an `.app` bundle.
#[cfg(target_os = "macos")]
fn main_bundle_identifier() -> Option<String> {
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::<CFString>())
.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:
/// nothing cosmetic should add latency to the tray's event loop. Failures
/// are logged and swallowed — a missing notification daemon must not
/// propagate up.
pub(crate) fn spawn_layout_change_notification(layouts: &Arc<LayoutDb>, to_layout: &LayoutId) {
if !notification_sender_ready() {
return;
}
let pretty = layouts
.get(to_layout)
.map(|m| m.name.clone())
Expand Down Expand Up @@ -126,6 +181,9 @@ pub(crate) fn spawn_dictionary_add_notification(
layout: &LayoutId,
word: &str,
) {
if !notification_sender_ready() {
return;
}
let pretty = layouts
.get(layout)
.map(|m| m.name.clone())
Expand Down Expand Up @@ -155,6 +213,9 @@ pub(crate) fn spawn_dictionary_add_notification(
///
/// Longer timeout than the others, because this text has to be read.
pub(crate) fn spawn_error_notification(body: String) {
if !notification_sender_ready() {
return;
}
std::thread::Builder::new()
.name("poltertype-notify-error".into())
.spawn(move || {
Expand All @@ -178,6 +239,9 @@ pub(crate) fn spawn_error_notification(body: String) {
/// version, and it is the only thing that tells a user who never opens
/// the tray menu that an update is waiting.
pub(crate) fn spawn_update_notification(version: &str) {
if !notification_sender_ready() {
return;
}
let body = format!(
"Version {version} is downloaded and ready.\n\
It will be installed the next time you restart PolterType — \
Expand Down
8 changes: 5 additions & 3 deletions crates/poltertype-app/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -846,11 +846,12 @@ fn main() -> Result<()> {
// service still running through an update would be
// a process whose binary moved under it.
supervisor.stop_all();
settings_proc::kill_settings_ui();
// The one safe moment: the user is done typing, the
// hook is down, and nothing we replace is in use. No
// relaunch — they asked for the app to go away.
if let Some(pending) = update_pending.as_ref() {
apply_now(pending, false);
apply_now(pending, false, &settings.snapshot().updates.local_signing_identity);
}
*control_flow = ControlFlow::Exit;
} else if Some(&id) == update_id.as_ref() {
Expand All @@ -863,7 +864,8 @@ fn main() -> Result<()> {
// started, and quitting anyway is what
// turned a failed update into a machine
// with no PolterType running on it.
if apply_now(pending, true) {
settings_proc::kill_settings_ui();
if apply_now(pending, true, &settings.snapshot().updates.local_signing_identity) {
if let Some(mut listener) = input_listener.take() {
listener.stop();
}
Expand Down
2 changes: 1 addition & 1 deletion crates/poltertype-app/src/settings_proc/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
55 changes: 54 additions & 1 deletion crates/poltertype-app/src/settings_proc/spawn.rs
Original file line number Diff line number Diff line change
Expand Up @@ -70,14 +70,18 @@ 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.
std::thread::Builder::new()
.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"),
}
Expand Down Expand Up @@ -190,3 +194,52 @@ fn settings_ui_exe() -> Option<PathBuf> {
}
}
}

/// Pid of the live Settings window, 0 when none. One window at a time
/// is already the rule; this is how the main process can take it along
/// when it exits.
static SETTINGS_CHILD_PID: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);

/// Close the Settings window before the main process exits.
///
/// The window is a subprocess running the same executable, and left
/// alive it does worse than linger: on macOS the updater's relaunch
/// `open` sees "this app is already running", reports success, and
/// merely brings the ORPHANED OLD-VERSION settings window to the
/// front — the updated app never starts and the user is looking at
/// the previous version's Settings (measured, twice). Quit has the
/// same leak without the relaunch twist.
pub(crate) fn kill_settings_ui() {
let pid = SETTINGS_CHILD_PID.swap(0, Ordering::AcqRel);
if pid != 0 {
info!(pid, "closing the settings window before exit");
}
#[cfg(unix)]
{
if pid != 0 {
let _ = std::process::Command::new("kill")
.arg(pid.to_string())
.status();
}
// And every other window of this executable, tracked or not:
// this base has no second-window guard, so a Settings window
// and a Setup-alert window can coexist while one pid slot
// remembers only the latest. The one that survived the main
// process is what made LaunchServices treat the app as still
// running and turned the updater relaunch into a no-op that
// merely raised an orphaned old-version window (measured:
// `poltertype --setup`, 2026-08-31).
if let Ok(exe) = std::env::current_exe() {
let _ = std::process::Command::new("pkill")
.arg("-f")
.arg(format!("{} --", exe.display()))
.status();
}
}
#[cfg(windows)]
{
let _ = std::process::Command::new("taskkill")
.args(["/PID", &pid.to_string(), "/F"])
.status();
}
}
8 changes: 8 additions & 0 deletions crates/poltertype-app/src/settings_ui/enums.rs
Original file line number Diff line number Diff line change
Expand Up @@ -320,6 +320,14 @@ pub enum Message {
/// Ask the OS for a permission — macOS only, and always the
/// system's own dialog.
SetupRequestPermission(poltertype_input::setup::Permission),
/// Create/adopt the local signing identity and write its name into
/// `[updates].local_signing_identity` — the Setup pane answer to
/// permissions dying on every update.
SetupLocalSigning,
/// Quit the main tray process and relaunch the bundle: permissions
/// are read at startup, and sending the user to hunt Quit in the
/// tray after every grant was the complaint.
RestartApp,

/// Intercepted so an unsaved wordlist edit is auto-saved before the
/// window closes. Carries the `window::Id` to close the right one.
Expand Down
5 changes: 4 additions & 1 deletion crates/poltertype-app/src/settings_ui/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
96 changes: 94 additions & 2 deletions crates/poltertype-app/src/settings_ui/update.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -610,13 +612,103 @@ impl SettingsApp {
// return value is not an answer — re-probe instead of
// believing it.
poltertype_input::setup::request_permission(permission);
self.setup = poltertype_input::setup::probe_setup();
// And open the pane regardless: when TCC has no record
// the dialog appears over it, and when the app is not
// in the list at all the user is already where the
// "+" button lives — never a silent no-op
// in front of an empty list.
if let Some(url) = poltertype_input::setup::permission_settings_url(permission) {
let _ = opener::open(url);
}
self.setup = poltertype_input::setup::probe_setup(
&self.store.snapshot().updates.local_signing_identity,
);
self.setup_status = Some(SaveBanner {
text: "Asked the system. Approve it there, then press Check again.".to_owned(),
is_error: false,
});
}

Message::SetupLocalSigning => {
// Adopt-or-create in the keychain, then remember the name:
// the updater reads it at swap time, and the re-probe below
// is what flips the step to Done.
let name = {
let configured = self.store.snapshot().updates.local_signing_identity;
if configured.is_empty() {
poltertype_input::setup::DEFAULT_LOCAL_SIGNING_IDENTITY.to_owned()
} else {
configured
}
};
match poltertype_input::setup::setup_local_signing(&name) {
Ok(()) => {
if let Err(e) =
self.store.update(|s| s.updates.local_signing_identity = name.clone())
{
warn!(?e, "could not remember the signing identity");
}
self.setup_status = Some(SaveBanner {
text: format!(
"Identity “{name}” is in your keychain — updates will keep \
the permissions from now on."
),
is_error: false,
});
}
Err(e) => {
warn!(%e, "local signing setup failed");
self.setup_status = Some(SaveBanner {
text: format!("Could not set up signing: {e}"),
is_error: true,
});
}
}
self.setup = poltertype_input::setup::probe_setup(
&self.store.snapshot().updates.local_signing_identity,
);
}

Message::RestartApp => {
// The settings window is a child of the tray process:
// terminate the parent, start the bundle afresh, and
// leave — keeping this window alive past the parent is
// exactly the ghost that broke the updater relaunch.
#[cfg(unix)]
{
let parent = std::os::unix::process::parent_id();
if parent > 1 {
let _ = std::process::Command::new("kill")
.arg(parent.to_string())
.status();
}
if let Some(bundle) = std::env::current_exe().ok().and_then(|e| {
e.parent()?.parent()?.parent().map(std::path::Path::to_path_buf)
}) {
if bundle.extension().is_some_and(|x| x == "app") {
// Detached, and the open happens after WE
// are gone: while this settings process
// lives, LaunchServices reads the app as
// already running and open() merely
// activates this window — the exact ghost
// that used to eat the updater relaunch.
let _ = std::process::Command::new("sh")
.arg("-c")
.arg(format!(
"sleep 2; open '{}'",
bundle.display().to_string().replace('\'', "'\\''")
))
.spawn();
}
}
std::process::exit(0);
}
#[cfg(not(unix))]
{
warn!("restart from the pane is wired for unix only so far");
}
}

Message::WindowCloseRequested(id) => {
// Last chance to flush an unsaved wordlist edit.
// Failures are logged but do not block the close: a
Expand Down
Loading
Loading