From beeed14504322a9a3d62f7c04760e44aa8a08171 Mon Sep 17 00:00:00 2001 From: OneNoted Date: Mon, 16 Mar 2026 23:16:51 +0100 Subject: [PATCH 01/40] feat: lay foundation for macOS support --- Cargo.lock | 38 ++ Cargo.toml | 5 + crates/taskers-app/Cargo.toml | 2 + crates/taskers-app/src/app_state.rs | 77 ---- crates/taskers-app/src/main.rs | 43 +- crates/taskers-app/src/settings_store.rs | 20 +- crates/taskers-app/src/theme.rs | 8 +- crates/taskers-control/Cargo.toml | 1 + crates/taskers-control/src/paths.rs | 4 +- crates/taskers-core/Cargo.toml | 22 + crates/taskers-core/src/app_state.rs | 161 +++++++ crates/taskers-core/src/lib.rs | 7 + .../src/pane_runtime.rs | 31 +- .../src/session_store.rs | 23 +- crates/taskers-ghostty/Cargo.toml | 1 + crates/taskers-ghostty/src/runtime.rs | 14 +- crates/taskers-macos-ffi/Cargo.toml | 25 ++ .../include/taskers_macos_ffi.h | 29 ++ crates/taskers-macos-ffi/src/lib.rs | 413 +++++++++++++++++ crates/taskers-paths/Cargo.toml | 11 + crates/taskers-paths/src/lib.rs | 418 ++++++++++++++++++ crates/taskers-runtime/Cargo.toml | 1 + crates/taskers-runtime/src/shell.rs | 16 +- 23 files changed, 1172 insertions(+), 198 deletions(-) delete mode 100644 crates/taskers-app/src/app_state.rs create mode 100644 crates/taskers-core/Cargo.toml create mode 100644 crates/taskers-core/src/app_state.rs create mode 100644 crates/taskers-core/src/lib.rs rename crates/{taskers-app => taskers-core}/src/pane_runtime.rs (91%) rename crates/{taskers-app => taskers-core}/src/session_store.rs (79%) create mode 100644 crates/taskers-macos-ffi/Cargo.toml create mode 100644 crates/taskers-macos-ffi/include/taskers_macos_ffi.h create mode 100644 crates/taskers-macos-ffi/src/lib.rs create mode 100644 crates/taskers-paths/Cargo.toml create mode 100644 crates/taskers-paths/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index e0301cc..815b822 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1458,8 +1458,10 @@ dependencies = [ "serde_json", "svgtypes", "taskers-control", + "taskers-core", "taskers-domain", "taskers-ghostty", + "taskers-paths", "taskers-runtime", "tempfile", "time", @@ -1488,12 +1490,28 @@ dependencies = [ "serde", "serde_json", "taskers-domain", + "taskers-paths", "tempfile", "thiserror 2.0.18", "tokio", "uuid", ] +[[package]] +name = "taskers-core" +version = "0.2.1" +dependencies = [ + "anyhow", + "serde", + "serde_json", + "taskers-control", + "taskers-domain", + "taskers-ghostty", + "taskers-paths", + "taskers-runtime", + "tempfile", +] + [[package]] name = "taskers-domain" version = "0.2.1" @@ -1514,12 +1532,31 @@ dependencies = [ "libloading", "serde", "tar", + "taskers-paths", "tempfile", "thiserror 2.0.18", "ureq", "xz2", ] +[[package]] +name = "taskers-macos-ffi" +version = "0.2.1" +dependencies = [ + "serde_json", + "taskers-control", + "taskers-core", + "taskers-domain", + "taskers-ghostty", + "taskers-paths", + "taskers-runtime", + "tempfile", +] + +[[package]] +name = "taskers-paths" +version = "0.2.1" + [[package]] name = "taskers-runtime" version = "0.2.1" @@ -1529,6 +1566,7 @@ dependencies = [ "libc", "portable-pty", "taskers-domain", + "taskers-paths", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 3436f36..8da1f99 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,10 +1,13 @@ [workspace] members = [ "crates/taskers-app", + "crates/taskers-core", "crates/taskers-cli", "crates/taskers-control", "crates/taskers-domain", "crates/taskers-ghostty", + "crates/taskers-macos-ffi", + "crates/taskers-paths", "crates/taskers-runtime", ] resolver = "2" @@ -34,3 +37,5 @@ thiserror = "2" time = { version = "0.3", features = ["formatting", "macros", "parsing", "serde", "serde-human-readable"] } tokio = { version = "1.50.0", features = ["io-util", "macros", "net", "rt-multi-thread", "sync"] } uuid = { version = "1.22.0", features = ["serde", "v7"] } +taskers-core = { version = "0.2.1", path = "crates/taskers-core" } +taskers-paths = { version = "0.2.1", path = "crates/taskers-paths" } diff --git a/crates/taskers-app/Cargo.toml b/crates/taskers-app/Cargo.toml index b0e495b..a024acb 100644 --- a/crates/taskers-app/Cargo.toml +++ b/crates/taskers-app/Cargo.toml @@ -20,6 +20,8 @@ gtk.workspace = true serde.workspace = true serde_json.workspace = true svgtypes.workspace = true +taskers-core.workspace = true +taskers-paths.workspace = true taskers-runtime = { version = "0.2.1", path = "../taskers-runtime" } tokio.workspace = true taskers-control = { version = "0.2.1", path = "../taskers-control" } diff --git a/crates/taskers-app/src/app_state.rs b/crates/taskers-app/src/app_state.rs deleted file mode 100644 index 79b8b18..0000000 --- a/crates/taskers-app/src/app_state.rs +++ /dev/null @@ -1,77 +0,0 @@ -use std::path::PathBuf; - -use anyhow::{Context, Result, anyhow}; -use taskers_control::{ControlCommand, ControlResponse, InMemoryController}; -use taskers_domain::AppModel; -use taskers_ghostty::BackendChoice; -use taskers_runtime::ShellLaunchSpec; - -use crate::{pane_runtime::RuntimeManager, session_store}; - -#[derive(Clone)] -pub struct AppState { - controller: InMemoryController, - runtime: RuntimeManager, - session_path: PathBuf, -} - -impl AppState { - pub fn new( - model: AppModel, - session_path: PathBuf, - backend: BackendChoice, - shell_launch: ShellLaunchSpec, - ) -> Result { - let controller = InMemoryController::new(model.clone()); - let runtime = RuntimeManager::new( - controller.clone(), - backend != BackendChoice::Ghostty, - shell_launch, - ); - runtime.sync_model(&model)?; - - let state = Self { - controller, - runtime, - session_path, - }; - state.persist_snapshot()?; - Ok(state) - } - - pub fn controller(&self) -> InMemoryController { - self.controller.clone() - } - - pub fn runtime(&self) -> RuntimeManager { - self.runtime.clone() - } - - pub fn snapshot_model(&self) -> AppModel { - self.controller.snapshot().model - } - - pub fn dispatch(&self, command: ControlCommand) -> Result { - let response = self - .controller - .handle(command) - .map_err(|error| anyhow!(error.to_string()))?; - self.runtime.sync_model(&self.snapshot_model())?; - self.persist_snapshot()?; - Ok(response) - } - - pub fn persist_snapshot(&self) -> Result<()> { - let model = self.snapshot_model(); - self.persist_model(&model) - } - - pub fn persist_model(&self, model: &AppModel) -> Result<()> { - session_store::save_session(&self.session_path, model).with_context(|| { - format!( - "failed to save taskers session to {}", - self.session_path.display() - ) - }) - } -} diff --git a/crates/taskers-app/src/main.rs b/crates/taskers-app/src/main.rs index c4cd14d..be09e46 100644 --- a/crates/taskers-app/src/main.rs +++ b/crates/taskers-app/src/main.rs @@ -1,7 +1,4 @@ -mod app_state; mod crash_reporter; -mod pane_runtime; -mod session_store; mod settings_store; mod terminal_transitions; mod theme; @@ -20,17 +17,16 @@ use std::{ }; use adw::prelude::*; -use app_state::AppState; use clap::Parser; use crash_reporter::CrashReporter; use gtk::{ Align, Box as GtkBox, Button, DrawingArea, Entry, Fixed, Label, Orientation, Overlay, Paned, PolicyType, ScrolledWindow, Separator, TextView, Widget, WrapMode, gdk, glib, }; -use pane_runtime::PaneRuntimeSnapshot; use serde_json::json; use settings_store::{AppConfig, ShortcutAction, ShortcutPreset}; use svgtypes::{SimplePathSegment, SimplifyingPathParser}; +use taskers_core::{AppState, PaneRuntimeSnapshot, default_session_path, load_or_bootstrap}; use taskers_control::{ ControlCommand, InMemoryController, bind_socket, default_socket_path, serve, }; @@ -43,7 +39,7 @@ use taskers_domain::{ WorkspaceViewport, WorkspaceWindowId, }; use taskers_ghostty::{ - BackendChoice, BackendProbe, DefaultBackend, GhosttyHost, SurfaceDescriptor, TerminalBackend, + BackendChoice, BackendProbe, DefaultBackend, GhosttyHost, TerminalBackend, ensure_runtime_installed, }; use taskers_runtime::{ @@ -80,7 +76,6 @@ struct StartupContext { app_config: AppConfig, crash_reporter: CrashReporter, ghostty_host: Option, - shell_launch: ShellLaunchSpec, startup_toast: Option, } @@ -92,7 +87,6 @@ struct UiHandle { overlay: adw::ToastOverlay, crash_reporter: CrashReporter, ghostty_host: Option, - shell_launch: ShellLaunchSpec, ghostty_surfaces: RefCell>, shell: RefCell>, pane_cards: RefCell>, @@ -394,7 +388,6 @@ impl UiHandle { overlay: adw::ToastOverlay, crash_reporter: CrashReporter, ghostty_host: Option, - shell_launch: ShellLaunchSpec, ) -> Rc { Rc::new(Self { app_state, @@ -404,7 +397,6 @@ impl UiHandle { overlay, crash_reporter, ghostty_host, - shell_launch, ghostty_surfaces: RefCell::new(HashMap::new()), shell: RefCell::new(None), pane_cards: RefCell::new(HashMap::new()), @@ -787,22 +779,13 @@ impl UiHandle { } let host = self.ghostty_host.as_ref()?; - let mut env = self.shell_launch.env.clone(); - env.insert("TASKERS_PANE_ID".into(), pane.id.to_string()); - env.insert("TASKERS_WORKSPACE_ID".into(), workspace_id.to_string()); - env.insert("TASKERS_SURFACE_ID".into(), surface.id.to_string()); - let widget = match host.create_surface(&SurfaceDescriptor { - cols: 120, - rows: 40, - cwd: surface.metadata.cwd.clone(), - title: surface.metadata.title.clone(), - command_argv: self - .shell_launch - .program_and_args() - .into_iter() - .collect::>(), - env, - }) { + let widget = match self + .app_state + .surface_descriptor_for_pane(workspace_id, pane.id) + .and_then(|descriptor| { + host.create_surface(&descriptor) + .map_err(|error| anyhow::anyhow!(error.to_string())) + }) { Ok(widget) => widget, Err(error) => { self.toast(&error.to_string()); @@ -1887,9 +1870,7 @@ fn main() -> gtk::glib::ExitCode { || cli.session.is_some() || std::env::var_os("TASKERS_NON_UNIQUE").is_some(); let socket_path = cli.socket.unwrap_or_else(default_socket_path); - let session_path = cli - .session - .unwrap_or_else(session_store::default_session_path); + let session_path = cli.session.unwrap_or_else(default_session_path); let config_path = settings_store::default_config_path(); let crash_reporter = CrashReporter::for_session(&session_path, &config_path); crash_reporter.install_panic_hook(); @@ -1923,7 +1904,7 @@ fn main() -> gtk::glib::ExitCode { std::env::set_var("TASKERS_DISABLE_SHELL_INTEGRATION", "1"); } } - let initial_model = match session_store::load_or_bootstrap(&session_path, cli.demo) { + let initial_model = match load_or_bootstrap(&session_path, cli.demo) { Ok(model) => model, Err(error) => { eprintln!( @@ -2001,7 +1982,6 @@ fn main() -> gtk::glib::ExitCode { app_config, crash_reporter, ghostty_host, - shell_launch, startup_toast, }; @@ -2073,7 +2053,6 @@ fn build_ui( overlay, startup.crash_reporter.clone(), startup.ghostty_host, - startup.shell_launch, ); connect_navigation_shortcuts(&ui); ui.refresh(true); diff --git a/crates/taskers-app/src/settings_store.rs b/crates/taskers-app/src/settings_store.rs index c85990a..755c52e 100644 --- a/crates/taskers-app/src/settings_store.rs +++ b/crates/taskers-app/src/settings_store.rs @@ -333,25 +333,7 @@ impl KeybindingConfig { } pub fn default_config_path() -> PathBuf { - if let Some(path) = std::env::var_os("TASKERS_CONFIG_PATH").map(PathBuf::from) { - return path; - } - - if let Some(path) = std::env::var_os("XDG_CONFIG_HOME") - .map(PathBuf::from) - .map(|path| path.join("taskers").join("config.json")) - { - return path; - } - - if let Some(path) = std::env::var_os("HOME") - .map(PathBuf::from) - .map(|path| path.join(".config").join("taskers").join("config.json")) - { - return path; - } - - PathBuf::from("/tmp/taskers-config.json") + taskers_paths::default_config_path() } pub fn load_or_default(path: &Path) -> Result { diff --git a/crates/taskers-app/src/theme.rs b/crates/taskers-app/src/theme.rs index ce73af0..cf42527 100644 --- a/crates/taskers-app/src/theme.rs +++ b/crates/taskers-app/src/theme.rs @@ -234,13 +234,7 @@ pub struct ThemeDefinition { // ── Theme loading ── fn theme_dir() -> Option { - if let Some(path) = std::env::var_os("XDG_CONFIG_HOME").map(PathBuf::from) { - return Some(path.join("taskers").join("themes")); - } - if let Some(path) = std::env::var_os("HOME").map(PathBuf::from) { - return Some(path.join(".config").join("taskers").join("themes")); - } - None + Some(taskers_paths::default_theme_dir()) } pub fn load_theme( diff --git a/crates/taskers-control/Cargo.toml b/crates/taskers-control/Cargo.toml index d8885fb..f6f441e 100644 --- a/crates/taskers-control/Cargo.toml +++ b/crates/taskers-control/Cargo.toml @@ -15,6 +15,7 @@ serde_json.workspace = true thiserror.workspace = true tokio.workspace = true uuid.workspace = true +taskers-paths.workspace = true taskers-domain = { version = "0.2.1", path = "../taskers-domain" } [dev-dependencies] diff --git a/crates/taskers-control/src/paths.rs b/crates/taskers-control/src/paths.rs index acf4094..48784ad 100644 --- a/crates/taskers-control/src/paths.rs +++ b/crates/taskers-control/src/paths.rs @@ -1,7 +1,5 @@ use std::path::PathBuf; pub fn default_socket_path() -> PathBuf { - std::env::var_os("TASKERS_SOCKET_PATH") - .map(PathBuf::from) - .unwrap_or_else(|| PathBuf::from("/tmp/taskers.sock")) + taskers_paths::default_socket_path() } diff --git a/crates/taskers-core/Cargo.toml b/crates/taskers-core/Cargo.toml new file mode 100644 index 0000000..b624387 --- /dev/null +++ b/crates/taskers-core/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "taskers-core" +description = "Shared application orchestration and runtime state for taskers." +edition.workspace = true +homepage.workspace = true +license.workspace = true +readme = "../../README.md" +repository.workspace = true +version.workspace = true + +[dependencies] +anyhow.workspace = true +serde.workspace = true +serde_json.workspace = true +taskers-control = { version = "0.2.1", path = "../taskers-control" } +taskers-domain = { version = "0.2.1", path = "../taskers-domain" } +taskers-ghostty = { version = "0.2.1", path = "../taskers-ghostty" } +taskers-paths.workspace = true +taskers-runtime = { version = "0.2.1", path = "../taskers-runtime" } + +[dev-dependencies] +tempfile.workspace = true diff --git a/crates/taskers-core/src/app_state.rs b/crates/taskers-core/src/app_state.rs new file mode 100644 index 0000000..a137abb --- /dev/null +++ b/crates/taskers-core/src/app_state.rs @@ -0,0 +1,161 @@ +use std::path::PathBuf; + +use anyhow::{Context, Result, anyhow}; +use taskers_control::{ControlCommand, ControlResponse, InMemoryController}; +use taskers_domain::{AppModel, PaneId, WorkspaceId}; +use taskers_ghostty::{BackendChoice, SurfaceDescriptor}; +use taskers_runtime::ShellLaunchSpec; + +use crate::{pane_runtime::RuntimeManager, session_store}; + +#[derive(Clone)] +pub struct AppState { + controller: InMemoryController, + runtime: RuntimeManager, + session_path: PathBuf, + shell_launch: ShellLaunchSpec, +} + +impl AppState { + pub fn new( + model: AppModel, + session_path: PathBuf, + backend: BackendChoice, + shell_launch: ShellLaunchSpec, + ) -> Result { + let controller = InMemoryController::new(model.clone()); + let runtime = RuntimeManager::new( + controller.clone(), + backend != BackendChoice::Ghostty, + shell_launch.clone(), + ); + runtime.sync_model(&model)?; + + let state = Self { + controller, + runtime, + session_path, + shell_launch, + }; + state.persist_snapshot()?; + Ok(state) + } + + pub fn controller(&self) -> InMemoryController { + self.controller.clone() + } + + pub fn runtime(&self) -> RuntimeManager { + self.runtime.clone() + } + + pub fn shell_launch(&self) -> &ShellLaunchSpec { + &self.shell_launch + } + + pub fn snapshot_model(&self) -> AppModel { + self.controller.snapshot().model + } + + pub fn dispatch(&self, command: ControlCommand) -> Result { + let response = self + .controller + .handle(command) + .map_err(|error| anyhow!(error.to_string()))?; + self.runtime.sync_model(&self.snapshot_model())?; + self.persist_snapshot()?; + Ok(response) + } + + pub fn persist_snapshot(&self) -> Result<()> { + let model = self.snapshot_model(); + self.persist_model(&model) + } + + pub fn persist_model(&self, model: &AppModel) -> Result<()> { + session_store::save_session(&self.session_path, model).with_context(|| { + format!( + "failed to save taskers session to {}", + self.session_path.display() + ) + }) + } + + pub fn surface_descriptor_for_pane( + &self, + workspace_id: WorkspaceId, + pane_id: PaneId, + ) -> Result { + let model = self.snapshot_model(); + let workspace = model + .workspaces + .get(&workspace_id) + .ok_or_else(|| anyhow!("workspace {workspace_id} is not present"))?; + let pane = workspace + .panes + .get(&pane_id) + .ok_or_else(|| anyhow!("pane {pane_id} is not present"))?; + let surface = pane + .active_surface() + .ok_or_else(|| anyhow!("pane {pane_id} has no active surface"))?; + + let mut env = self.shell_launch.env.clone(); + env.insert("TASKERS_PANE_ID".into(), pane.id.to_string()); + env.insert("TASKERS_WORKSPACE_ID".into(), workspace_id.to_string()); + env.insert("TASKERS_SURFACE_ID".into(), surface.id.to_string()); + + Ok(SurfaceDescriptor { + cols: 120, + rows: 40, + cwd: surface.metadata.cwd.clone(), + title: surface.metadata.title.clone(), + command_argv: self.shell_launch.program_and_args(), + env, + }) + } +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use taskers_domain::AppModel; + use taskers_ghostty::BackendChoice; + use taskers_runtime::ShellLaunchSpec; + + use super::AppState; + + #[test] + fn surface_descriptor_includes_shell_launch_and_surface_metadata() { + let model = AppModel::new("Main"); + let workspace = model.active_workspace_id().expect("workspace"); + let pane = model.active_workspace().expect("workspace").active_pane; + + let mut shell_launch = ShellLaunchSpec::fallback(); + shell_launch.program = PathBuf::from("/bin/zsh"); + shell_launch.args = vec!["-i".into()]; + shell_launch + .env + .insert("TASKERS_SOCKET".into(), "/tmp/taskers.sock".into()); + + let app_state = AppState::new( + model, + PathBuf::from("/tmp/taskers-session.json"), + BackendChoice::Mock, + shell_launch, + ) + .expect("app state"); + + let descriptor = app_state + .surface_descriptor_for_pane(workspace, pane) + .expect("descriptor"); + + assert_eq!(descriptor.command_argv, vec!["/bin/zsh", "-i"]); + assert_eq!( + descriptor.env.get("TASKERS_WORKSPACE_ID"), + Some(&workspace.to_string()) + ); + assert_eq!(descriptor.env.get("TASKERS_PANE_ID"), Some(&pane.to_string())); + assert!(descriptor.env.contains_key("TASKERS_SURFACE_ID")); + } +} diff --git a/crates/taskers-core/src/lib.rs b/crates/taskers-core/src/lib.rs new file mode 100644 index 0000000..59dc502 --- /dev/null +++ b/crates/taskers-core/src/lib.rs @@ -0,0 +1,7 @@ +mod app_state; +mod pane_runtime; +mod session_store; + +pub use app_state::AppState; +pub use pane_runtime::{PaneRuntimeSnapshot, RuntimeManager}; +pub use session_store::{default_session_path, load_or_bootstrap, load_session, save_session}; diff --git a/crates/taskers-app/src/pane_runtime.rs b/crates/taskers-core/src/pane_runtime.rs similarity index 91% rename from crates/taskers-app/src/pane_runtime.rs rename to crates/taskers-core/src/pane_runtime.rs index fe717dc..8e8ea2b 100644 --- a/crates/taskers-app/src/pane_runtime.rs +++ b/crates/taskers-core/src/pane_runtime.rs @@ -270,16 +270,14 @@ fn sanitize_terminal_output(input: &str) -> String { index += 1; while index < bytes.len() { let byte = bytes[index]; + index += 1; if byte == 0x07 { - index += 1; break; } - if byte == 0x1b && index + 1 < bytes.len() && bytes[index + 1] == b'\\' - { - index += 2; + if byte == 0x1b && bytes.get(index) == Some(&b'\\') { + index += 1; break; } - index += 1; } } _ => { @@ -287,9 +285,22 @@ fn sanitize_terminal_output(input: &str) -> String { } } } + byte if byte.is_ascii_control() && byte != b'\n' && byte != b'\t' => { + index += 1; + } _ => { - result.push(bytes[index] as char); + let start = index; index += 1; + while index < bytes.len() + && bytes[index] != 0x1b + && bytes[index] != b'\r' + && (!bytes[index].is_ascii_control() + || bytes[index] == b'\n' + || bytes[index] == b'\t') + { + index += 1; + } + result.push_str(&input[start..index]); } } } @@ -302,10 +313,8 @@ mod tests { use super::sanitize_terminal_output; #[test] - fn strips_common_terminal_escape_sequences() { - let cleaned = sanitize_terminal_output( - "\u{1b}[32mgreen\u{1b}[0m text \u{1b}]777;taskers;kind=completed;message=done\u{7}", - ); - assert_eq!(cleaned, "green text "); + fn strips_control_sequences_from_terminal_output() { + let input = "\u{1b}[31mhello\u{1b}[0m\r\nworld\u{1b}]2;title\u{7}"; + assert_eq!(sanitize_terminal_output(input), "hello\nworld"); } } diff --git a/crates/taskers-app/src/session_store.rs b/crates/taskers-core/src/session_store.rs similarity index 79% rename from crates/taskers-app/src/session_store.rs rename to crates/taskers-core/src/session_store.rs index 55bf825..50eef79 100644 --- a/crates/taskers-app/src/session_store.rs +++ b/crates/taskers-core/src/session_store.rs @@ -5,29 +5,10 @@ use std::{ use anyhow::{Result, bail}; use taskers_domain::{AppModel, PersistedSession, SESSION_SCHEMA_VERSION}; +use taskers_paths::default_session_path as shared_default_session_path; pub fn default_session_path() -> PathBuf { - if let Some(path) = std::env::var_os("TASKERS_SESSION_PATH").map(PathBuf::from) { - return path; - } - - if let Some(path) = std::env::var_os("XDG_STATE_HOME") - .map(PathBuf::from) - .map(|path| path.join("taskers").join("session.json")) - { - return path; - } - - if let Some(path) = std::env::var_os("HOME").map(PathBuf::from).map(|path| { - path.join(".local") - .join("state") - .join("taskers") - .join("session.json") - }) { - return path; - } - - PathBuf::from("/tmp/taskers-session.json") + shared_default_session_path() } pub fn load_or_bootstrap(path: &Path, demo: bool) -> Result { diff --git a/crates/taskers-ghostty/Cargo.toml b/crates/taskers-ghostty/Cargo.toml index 2490595..56da84f 100644 --- a/crates/taskers-ghostty/Cargo.toml +++ b/crates/taskers-ghostty/Cargo.toml @@ -14,6 +14,7 @@ gtk.workspace = true libloading = "0.8" serde.workspace = true tar = "0.4" +taskers-paths.workspace = true thiserror.workspace = true ureq = "2.12" xz2 = "0.1" diff --git a/crates/taskers-ghostty/src/runtime.rs b/crates/taskers-ghostty/src/runtime.rs index 94026fd..19c1238 100644 --- a/crates/taskers-ghostty/src/runtime.rs +++ b/crates/taskers-ghostty/src/runtime.rs @@ -274,19 +274,7 @@ fn explicit_runtime_dir() -> Option { } fn default_installed_runtime_dir() -> Option { - if let Some(path) = env::var_os("XDG_DATA_HOME") - .map(PathBuf::from) - .map(|path| path.join("taskers").join("ghostty")) - { - return Some(path); - } - - env::var_os("HOME").map(PathBuf::from).map(|path| { - path.join(".local") - .join("share") - .join("taskers") - .join("ghostty") - }) + Some(taskers_paths::default_ghostty_runtime_dir()) } fn default_runtime_bundle_url() -> String { diff --git a/crates/taskers-macos-ffi/Cargo.toml b/crates/taskers-macos-ffi/Cargo.toml new file mode 100644 index 0000000..f1f9da4 --- /dev/null +++ b/crates/taskers-macos-ffi/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "taskers-macos-ffi" +description = "C ABI bridge for a native macOS taskers shell." +edition.workspace = true +homepage.workspace = true +license.workspace = true +readme = "../../README.md" +repository.workspace = true +version.workspace = true + +[lib] +name = "taskers_macos_ffi" +crate-type = ["staticlib", "rlib"] + +[dependencies] +taskers-core.workspace = true +taskers-control = { version = "0.2.1", path = "../taskers-control" } +taskers-domain = { version = "0.2.1", path = "../taskers-domain" } +taskers-ghostty = { version = "0.2.1", path = "../taskers-ghostty" } +taskers-paths.workspace = true +taskers-runtime = { version = "0.2.1", path = "../taskers-runtime" } +serde_json.workspace = true + +[dev-dependencies] +tempfile.workspace = true diff --git a/crates/taskers-macos-ffi/include/taskers_macos_ffi.h b/crates/taskers-macos-ffi/include/taskers_macos_ffi.h new file mode 100644 index 0000000..7636073 --- /dev/null +++ b/crates/taskers-macos-ffi/include/taskers_macos_ffi.h @@ -0,0 +1,29 @@ +#ifndef TASKERS_MACOS_FFI_H +#define TASKERS_MACOS_FFI_H + +#include +#include + +typedef struct taskers_macos_core taskers_macos_core_t; + +taskers_macos_core_t *taskers_macos_core_new( + const char *session_path, + const char *socket_path, + const char *configured_shell, + bool demo +); +void taskers_macos_core_free(taskers_macos_core_t *core); + +char *taskers_macos_core_snapshot_json(const taskers_macos_core_t *core); +char *taskers_macos_core_dispatch_json(taskers_macos_core_t *core, const char *command_json); +char *taskers_macos_core_surface_descriptor_json( + const taskers_macos_core_t *core, + const char *workspace_id, + const char *pane_id +); + +uint64_t taskers_macos_core_revision(const taskers_macos_core_t *core); +char *taskers_macos_last_error_message(void); +void taskers_macos_string_free(char *value); + +#endif diff --git a/crates/taskers-macos-ffi/src/lib.rs b/crates/taskers-macos-ffi/src/lib.rs new file mode 100644 index 0000000..2f4bf8d --- /dev/null +++ b/crates/taskers-macos-ffi/src/lib.rs @@ -0,0 +1,413 @@ +use std::{ + cell::RefCell, + ffi::{CStr, CString, c_char}, + path::PathBuf, + ptr, + str::FromStr, +}; + +use taskers_control::{ControlCommand, default_socket_path}; +use taskers_core::{AppState, default_session_path, load_or_bootstrap}; +use taskers_domain::{PaneId, WorkspaceId}; +use taskers_ghostty::{BackendChoice, DefaultBackend, TerminalBackend}; +use taskers_runtime::{ShellLaunchSpec, install_shell_integration}; + +pub struct TaskersMacosCore { + app_state: AppState, + revision: u64, + _socket_path: PathBuf, +} + +thread_local! { + static LAST_ERROR: RefCell> = const { RefCell::new(None) }; +} + +fn set_last_error(message: impl Into) { + LAST_ERROR.with(|slot| { + *slot.borrow_mut() = Some(message.into()); + }); +} + +fn clear_last_error() { + LAST_ERROR.with(|slot| { + *slot.borrow_mut() = None; + }); +} + +impl TaskersMacosCore { + fn new( + session_path: Option, + socket_path: Option, + configured_shell: Option<&str>, + demo: bool, + ) -> Result { + let session_path = session_path.unwrap_or_else(default_session_path); + let socket_path = socket_path.unwrap_or_else(default_socket_path); + let model = load_or_bootstrap(&session_path, demo) + .map_err(|error| format!("failed to initialize session state: {error}"))?; + + let (mut shell_launch, shell_integration_error) = + match install_shell_integration(configured_shell) { + Ok(integration) => (integration.launch_spec(), None), + Err(error) => ( + ShellLaunchSpec::fallback(), + Some(format!("shell integration unavailable: {error}")), + ), + }; + shell_launch + .env + .insert("TASKERS_SOCKET".into(), socket_path.display().to_string()); + + let probe = DefaultBackend::probe(BackendChoice::Auto); + let backend_choice = if probe.selected == BackendChoice::Ghostty { + BackendChoice::Ghostty + } else { + BackendChoice::Mock + }; + let app_state = AppState::new(model, session_path, backend_choice, shell_launch) + .map_err(|error| format!("failed to create shared app state: {error}"))?; + + if let Some(error) = shell_integration_error { + set_last_error(error); + } else { + clear_last_error(); + } + + Ok(Self { + app_state, + revision: 0, + _socket_path: socket_path, + }) + } + + fn snapshot_json(&self) -> Result { + serde_json::to_string(&self.app_state.snapshot_model()) + .map_err(|error| format!("failed to serialize snapshot: {error}")) + } + + fn dispatch_json(&mut self, command_json: &str) -> Result { + let command = serde_json::from_str::(command_json) + .map_err(|error| format!("failed to decode command JSON: {error}"))?; + let response = self + .app_state + .dispatch(command) + .map_err(|error| format!("command failed: {error}"))?; + self.revision = self.revision.saturating_add(1); + serde_json::to_string(&response) + .map_err(|error| format!("failed to serialize response: {error}")) + } + + fn surface_descriptor_json( + &self, + workspace_id: &str, + pane_id: &str, + ) -> Result { + let workspace_id = WorkspaceId::from_str(workspace_id) + .map_err(|error| format!("invalid workspace id: {error}"))?; + let pane_id = + PaneId::from_str(pane_id).map_err(|error| format!("invalid pane id: {error}"))?; + let descriptor = self + .app_state + .surface_descriptor_for_pane(workspace_id, pane_id) + .map_err(|error| format!("failed to build surface descriptor: {error}"))?; + serde_json::to_string(&descriptor) + .map_err(|error| format!("failed to serialize surface descriptor: {error}")) + } +} + +fn optional_path_from_ptr(value: *const c_char) -> Result, String> { + if value.is_null() { + return Ok(None); + } + let value = unsafe { CStr::from_ptr(value) } + .to_str() + .map_err(|error| format!("argument contained invalid UTF-8: {error}"))? + .trim() + .to_string(); + if value.is_empty() { + Ok(None) + } else { + Ok(Some(PathBuf::from(value))) + } +} + +fn optional_string_from_ptr(value: *const c_char) -> Result, String> { + if value.is_null() { + return Ok(None); + } + let value = unsafe { CStr::from_ptr(value) } + .to_str() + .map_err(|error| format!("argument contained invalid UTF-8: {error}"))?; + let trimmed = value.trim(); + if trimmed.is_empty() { + Ok(None) + } else { + Ok(Some(trimmed.to_string())) + } +} + +fn string_into_ptr(value: String) -> *mut c_char { + match CString::new(value) { + Ok(value) => value.into_raw(), + Err(_) => ptr::null_mut(), + } +} + +fn with_core_mut( + core: *mut TaskersMacosCore, + f: impl FnOnce(&mut TaskersMacosCore) -> Result, +) -> Option { + if core.is_null() { + set_last_error("taskers macOS core handle was null"); + return None; + } + + let core = unsafe { &mut *core }; + match f(core) { + Ok(value) => { + clear_last_error(); + Some(value) + } + Err(error) => { + set_last_error(error); + None + } + } +} + +fn with_core( + core: *const TaskersMacosCore, + f: impl FnOnce(&TaskersMacosCore) -> Result, +) -> Option { + if core.is_null() { + set_last_error("taskers macOS core handle was null"); + return None; + } + + let core = unsafe { &*core }; + match f(core) { + Ok(value) => { + clear_last_error(); + Some(value) + } + Err(error) => { + set_last_error(error); + None + } + } +} + +#[unsafe(no_mangle)] +pub extern "C" fn taskers_macos_core_new( + session_path: *const c_char, + socket_path: *const c_char, + configured_shell: *const c_char, + demo: bool, +) -> *mut TaskersMacosCore { + let session_path = match optional_path_from_ptr(session_path) { + Ok(value) => value, + Err(error) => { + set_last_error(error); + return ptr::null_mut(); + } + }; + let socket_path = match optional_path_from_ptr(socket_path) { + Ok(value) => value, + Err(error) => { + set_last_error(error); + return ptr::null_mut(); + } + }; + let configured_shell = match optional_string_from_ptr(configured_shell) { + Ok(value) => value, + Err(error) => { + set_last_error(error); + return ptr::null_mut(); + } + }; + + match TaskersMacosCore::new( + session_path, + socket_path, + configured_shell.as_deref(), + demo, + ) { + Ok(core) => Box::into_raw(Box::new(core)), + Err(error) => { + set_last_error(error); + ptr::null_mut() + } + } +} + +#[unsafe(no_mangle)] +pub extern "C" fn taskers_macos_core_free(core: *mut TaskersMacosCore) { + if core.is_null() { + return; + } + + unsafe { + drop(Box::from_raw(core)); + } +} + +#[unsafe(no_mangle)] +pub extern "C" fn taskers_macos_core_snapshot_json( + core: *const TaskersMacosCore, +) -> *mut c_char { + with_core(core, TaskersMacosCore::snapshot_json) + .map(string_into_ptr) + .unwrap_or(ptr::null_mut()) +} + +#[unsafe(no_mangle)] +pub extern "C" fn taskers_macos_core_dispatch_json( + core: *mut TaskersMacosCore, + command_json: *const c_char, +) -> *mut c_char { + let command_json = match optional_string_from_ptr(command_json) { + Ok(Some(value)) => value, + Ok(None) => { + set_last_error("command JSON must not be empty"); + return ptr::null_mut(); + } + Err(error) => { + set_last_error(error); + return ptr::null_mut(); + } + }; + + with_core_mut(core, |core| core.dispatch_json(&command_json)) + .map(string_into_ptr) + .unwrap_or(ptr::null_mut()) +} + +#[unsafe(no_mangle)] +pub extern "C" fn taskers_macos_core_surface_descriptor_json( + core: *const TaskersMacosCore, + workspace_id: *const c_char, + pane_id: *const c_char, +) -> *mut c_char { + let workspace_id = match optional_string_from_ptr(workspace_id) { + Ok(Some(value)) => value, + Ok(None) => { + set_last_error("workspace id must not be empty"); + return ptr::null_mut(); + } + Err(error) => { + set_last_error(error); + return ptr::null_mut(); + } + }; + let pane_id = match optional_string_from_ptr(pane_id) { + Ok(Some(value)) => value, + Ok(None) => { + set_last_error("pane id must not be empty"); + return ptr::null_mut(); + } + Err(error) => { + set_last_error(error); + return ptr::null_mut(); + } + }; + + with_core(core, |core| core.surface_descriptor_json(&workspace_id, &pane_id)) + .map(string_into_ptr) + .unwrap_or(ptr::null_mut()) +} + +#[unsafe(no_mangle)] +pub extern "C" fn taskers_macos_core_revision(core: *const TaskersMacosCore) -> u64 { + with_core(core, |core| Ok(core.revision)).unwrap_or(0) +} + +#[unsafe(no_mangle)] +pub extern "C" fn taskers_macos_last_error_message() -> *mut c_char { + LAST_ERROR + .with(|slot| slot.borrow().clone()) + .map(string_into_ptr) + .unwrap_or(ptr::null_mut()) +} + +#[unsafe(no_mangle)] +pub extern "C" fn taskers_macos_string_free(value: *mut c_char) { + if value.is_null() { + return; + } + + unsafe { + drop(CString::from_raw(value)); + } +} + +#[cfg(test)] +mod tests { + use serde_json::Value; + use tempfile::tempdir; + + use super::TaskersMacosCore; + + #[test] + fn core_roundtrips_snapshot_and_dispatch_json() { + let temp = tempdir().expect("tempdir"); + let session_path = temp.path().join("session.json"); + let socket_path = temp.path().join("taskers.sock"); + let mut core = TaskersMacosCore::new( + Some(session_path), + Some(socket_path), + Some("/bin/sh"), + false, + ) + .expect("core"); + + let snapshot = core.snapshot_json().expect("snapshot"); + let snapshot: Value = serde_json::from_str(&snapshot).expect("snapshot json"); + assert!(snapshot.get("workspaces").is_some()); + + let response = core + .dispatch_json(r#"{"command":"create_workspace","label":"Docs"}"#) + .expect("dispatch"); + let response: Value = serde_json::from_str(&response).expect("response json"); + assert_eq!( + response.get("status").and_then(Value::as_str), + Some("workspace_created") + ); + assert_eq!(core.revision, 1); + } + + #[test] + fn surface_descriptor_json_includes_shell_command() { + let temp = tempdir().expect("tempdir"); + let session_path = temp.path().join("session.json"); + let socket_path = temp.path().join("taskers.sock"); + let core = TaskersMacosCore::new( + Some(session_path), + Some(socket_path), + Some("/bin/sh"), + false, + ) + .expect("core"); + + let model = core.app_state.snapshot_model(); + let workspace_id = model.active_workspace_id().expect("workspace").to_string(); + let pane_id = model.active_workspace().expect("workspace").active_pane.to_string(); + + let descriptor = core + .surface_descriptor_json(&workspace_id, &pane_id) + .expect("descriptor"); + let descriptor: Value = serde_json::from_str(&descriptor).expect("descriptor json"); + let command_argv = descriptor + .get("command_argv") + .and_then(Value::as_array) + .expect("command argv array"); + assert!(!command_argv.is_empty()); + assert_eq!( + descriptor + .get("env") + .and_then(Value::as_object) + .and_then(|env| env.get("TASKERS_SOCKET")) + .and_then(Value::as_str), + Some(temp.path().join("taskers.sock").to_str().expect("utf-8 socket path")) + ); + } +} diff --git a/crates/taskers-paths/Cargo.toml b/crates/taskers-paths/Cargo.toml new file mode 100644 index 0000000..30313c4 --- /dev/null +++ b/crates/taskers-paths/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "taskers-paths" +description = "Platform-aware path defaults for taskers." +edition.workspace = true +homepage.workspace = true +license.workspace = true +readme = "../../README.md" +repository.workspace = true +version.workspace = true + +[dependencies] diff --git a/crates/taskers-paths/src/lib.rs b/crates/taskers-paths/src/lib.rs new file mode 100644 index 0000000..ebd7cbf --- /dev/null +++ b/crates/taskers-paths/src/lib.rs @@ -0,0 +1,418 @@ +use std::{env, path::PathBuf}; + +pub const APP_ID: &str = "dev.taskers.app"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum HostPlatform { + Linux, + Macos, + Other, +} + +impl HostPlatform { + pub const fn detect() -> Self { + if cfg!(target_os = "linux") { + Self::Linux + } else if cfg!(target_os = "macos") { + Self::Macos + } else { + Self::Other + } + } +} + +#[derive(Debug, Clone, Default)] +struct EnvPaths { + home: Option, + xdg_cache_home: Option, + xdg_config_home: Option, + xdg_data_home: Option, + xdg_runtime_dir: Option, + xdg_state_home: Option, + taskers_config_path: Option, + taskers_ghostty_runtime_dir: Option, + taskers_runtime_dir: Option, + taskers_session_path: Option, + taskers_socket_path: Option, +} + +impl EnvPaths { + fn current() -> Self { + Self { + home: env::var_os("HOME").map(PathBuf::from), + xdg_cache_home: env::var_os("XDG_CACHE_HOME").map(PathBuf::from), + xdg_config_home: env::var_os("XDG_CONFIG_HOME").map(PathBuf::from), + xdg_data_home: env::var_os("XDG_DATA_HOME").map(PathBuf::from), + xdg_runtime_dir: env::var_os("XDG_RUNTIME_DIR").map(PathBuf::from), + xdg_state_home: env::var_os("XDG_STATE_HOME").map(PathBuf::from), + taskers_config_path: env::var_os("TASKERS_CONFIG_PATH").map(PathBuf::from), + taskers_ghostty_runtime_dir: env::var_os("TASKERS_GHOSTTY_RUNTIME_DIR") + .map(PathBuf::from), + taskers_runtime_dir: env::var_os("TASKERS_RUNTIME_DIR").map(PathBuf::from), + taskers_session_path: env::var_os("TASKERS_SESSION_PATH").map(PathBuf::from), + taskers_socket_path: env::var_os("TASKERS_SOCKET_PATH").map(PathBuf::from), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TaskersPaths { + config_dir: PathBuf, + state_dir: PathBuf, + cache_dir: PathBuf, + data_dir: PathBuf, + shell_runtime_dir: PathBuf, + ghostty_runtime_dir: PathBuf, + socket_path: PathBuf, + session_path: PathBuf, + config_path: PathBuf, + theme_dir: PathBuf, +} + +impl TaskersPaths { + pub fn detect() -> Self { + Self::from_env(HostPlatform::detect(), &EnvPaths::current()) + } + + fn from_env(platform: HostPlatform, env_paths: &EnvPaths) -> Self { + if let Some(config_path) = env_paths.taskers_config_path.clone() { + let config_dir = config_path + .parent() + .map(PathBuf::from) + .unwrap_or_else(|| temp_root().join("config")); + let state_dir = env_paths + .taskers_session_path + .clone() + .and_then(|path| path.parent().map(PathBuf::from)) + .unwrap_or_else(|| platform_state_dir(platform, env_paths)); + let cache_dir = platform_cache_dir(platform, env_paths); + let data_dir = platform_data_dir(platform, env_paths); + let shell_runtime_dir = shell_runtime_dir(platform, env_paths, &cache_dir); + let ghostty_runtime_dir = env_paths + .taskers_ghostty_runtime_dir + .clone() + .unwrap_or_else(|| data_dir.join("ghostty")); + let socket_path = env_paths + .taskers_socket_path + .clone() + .unwrap_or_else(|| socket_path(platform, &cache_dir)); + let session_path = env_paths + .taskers_session_path + .clone() + .unwrap_or_else(|| state_dir.join("session.json")); + return Self { + theme_dir: config_dir.join("themes"), + config_dir, + state_dir, + cache_dir, + data_dir, + shell_runtime_dir, + ghostty_runtime_dir, + socket_path, + session_path, + config_path, + }; + } + + let config_dir = platform_config_dir(platform, env_paths); + let state_dir = platform_state_dir(platform, env_paths); + let cache_dir = platform_cache_dir(platform, env_paths); + let data_dir = platform_data_dir(platform, env_paths); + let shell_runtime_dir = shell_runtime_dir(platform, env_paths, &cache_dir); + let ghostty_runtime_dir = env_paths + .taskers_ghostty_runtime_dir + .clone() + .unwrap_or_else(|| data_dir.join("ghostty")); + let socket_path = env_paths + .taskers_socket_path + .clone() + .unwrap_or_else(|| socket_path(platform, &cache_dir)); + let session_path = env_paths + .taskers_session_path + .clone() + .unwrap_or_else(|| state_dir.join("session.json")); + + Self { + config_path: config_dir.join("config.json"), + theme_dir: config_dir.join("themes"), + config_dir, + state_dir, + cache_dir, + data_dir, + shell_runtime_dir, + ghostty_runtime_dir, + socket_path, + session_path, + } + } + + pub fn config_dir(&self) -> &PathBuf { + &self.config_dir + } + + pub fn state_dir(&self) -> &PathBuf { + &self.state_dir + } + + pub fn cache_dir(&self) -> &PathBuf { + &self.cache_dir + } + + pub fn data_dir(&self) -> &PathBuf { + &self.data_dir + } + + pub fn shell_runtime_dir(&self) -> &PathBuf { + &self.shell_runtime_dir + } + + pub fn ghostty_runtime_dir(&self) -> &PathBuf { + &self.ghostty_runtime_dir + } + + pub fn socket_path(&self) -> &PathBuf { + &self.socket_path + } + + pub fn session_path(&self) -> &PathBuf { + &self.session_path + } + + pub fn config_path(&self) -> &PathBuf { + &self.config_path + } + + pub fn theme_dir(&self) -> &PathBuf { + &self.theme_dir + } +} + +pub fn default_socket_path() -> PathBuf { + TaskersPaths::detect().socket_path +} + +pub fn default_session_path() -> PathBuf { + TaskersPaths::detect().session_path +} + +pub fn default_config_path() -> PathBuf { + TaskersPaths::detect().config_path +} + +pub fn default_theme_dir() -> PathBuf { + TaskersPaths::detect().theme_dir +} + +pub fn default_shell_runtime_dir() -> PathBuf { + TaskersPaths::detect().shell_runtime_dir +} + +pub fn default_ghostty_runtime_dir() -> PathBuf { + TaskersPaths::detect().ghostty_runtime_dir +} + +fn platform_config_dir(platform: HostPlatform, env_paths: &EnvPaths) -> PathBuf { + match platform { + HostPlatform::Macos => home_library_dir(env_paths, "Application Support"), + HostPlatform::Linux => env_paths + .xdg_config_home + .clone() + .map(|path| path.join("taskers")) + .or_else(|| { + env_paths + .home + .clone() + .map(|path| path.join(".config").join("taskers")) + }) + .unwrap_or_else(|| temp_root().join("config")), + HostPlatform::Other => env_paths + .home + .clone() + .map(|path| path.join(".taskers")) + .unwrap_or_else(|| temp_root().join("config")), + } +} + +fn platform_state_dir(platform: HostPlatform, env_paths: &EnvPaths) -> PathBuf { + match platform { + HostPlatform::Macos => home_library_dir(env_paths, "Application Support"), + HostPlatform::Linux => env_paths + .xdg_state_home + .clone() + .map(|path| path.join("taskers")) + .or_else(|| { + env_paths + .home + .clone() + .map(|path| path.join(".local").join("state").join("taskers")) + }) + .unwrap_or_else(|| temp_root().join("state")), + HostPlatform::Other => env_paths + .home + .clone() + .map(|path| path.join(".taskers")) + .unwrap_or_else(|| temp_root().join("state")), + } +} + +fn platform_cache_dir(platform: HostPlatform, env_paths: &EnvPaths) -> PathBuf { + match platform { + HostPlatform::Macos => home_library_cache_dir(env_paths), + HostPlatform::Linux => env_paths + .xdg_cache_home + .clone() + .map(|path| path.join("taskers")) + .or_else(|| { + env_paths + .home + .clone() + .map(|path| path.join(".cache").join("taskers")) + }) + .unwrap_or_else(|| temp_root().join("cache")), + HostPlatform::Other => env_paths + .home + .clone() + .map(|path| path.join(".taskers").join("cache")) + .unwrap_or_else(|| temp_root().join("cache")), + } +} + +fn platform_data_dir(platform: HostPlatform, env_paths: &EnvPaths) -> PathBuf { + match platform { + HostPlatform::Macos => home_library_dir(env_paths, "Application Support"), + HostPlatform::Linux => env_paths + .xdg_data_home + .clone() + .map(|path| path.join("taskers")) + .or_else(|| { + env_paths + .home + .clone() + .map(|path| path.join(".local").join("share").join("taskers")) + }) + .unwrap_or_else(|| temp_root().join("data")), + HostPlatform::Other => env_paths + .home + .clone() + .map(|path| path.join(".taskers").join("data")) + .unwrap_or_else(|| temp_root().join("data")), + } +} + +fn shell_runtime_dir(platform: HostPlatform, env_paths: &EnvPaths, cache_dir: &PathBuf) -> PathBuf { + if let Some(path) = env_paths.taskers_runtime_dir.clone() { + return path.join("shell"); + } + + match platform { + HostPlatform::Linux => env_paths + .xdg_runtime_dir + .clone() + .map(|path| path.join("taskers").join("shell")) + .unwrap_or_else(|| env::temp_dir().join("taskers-runtime").join("shell")), + HostPlatform::Macos => cache_dir.join("runtime").join("shell"), + HostPlatform::Other => env::temp_dir().join("taskers-runtime").join("shell"), + } +} + +fn socket_path(platform: HostPlatform, cache_dir: &PathBuf) -> PathBuf { + match platform { + HostPlatform::Macos => cache_dir.join("control.sock"), + HostPlatform::Linux | HostPlatform::Other => PathBuf::from("/tmp/taskers.sock"), + } +} + +fn home_library_dir(env_paths: &EnvPaths, leaf: &str) -> PathBuf { + env_paths + .home + .clone() + .map(|path| path.join("Library").join(leaf).join(APP_ID)) + .unwrap_or_else(|| temp_root().join(leaf.replace(' ', "-").to_ascii_lowercase())) +} + +fn home_library_cache_dir(env_paths: &EnvPaths) -> PathBuf { + env_paths + .home + .clone() + .map(|path| path.join("Library").join("Caches").join(APP_ID)) + .unwrap_or_else(|| temp_root().join("cache")) +} + +fn temp_root() -> PathBuf { + env::temp_dir().join("taskers") +} + +#[cfg(test)] +mod tests { + use super::{APP_ID, EnvPaths, HostPlatform, TaskersPaths}; + use std::path::PathBuf; + + #[test] + fn macos_paths_use_library_directories() { + let env = EnvPaths { + home: Some(PathBuf::from("/Users/notes")), + ..EnvPaths::default() + }; + let paths = TaskersPaths::from_env(HostPlatform::Macos, &env); + + assert_eq!( + paths.config_path(), + &PathBuf::from(format!( + "/Users/notes/Library/Application Support/{APP_ID}/config.json" + )) + ); + assert_eq!( + paths.session_path(), + &PathBuf::from(format!( + "/Users/notes/Library/Application Support/{APP_ID}/session.json" + )) + ); + assert_eq!( + paths.socket_path(), + &PathBuf::from(format!("/Users/notes/Library/Caches/{APP_ID}/control.sock")) + ); + assert_eq!( + paths.shell_runtime_dir(), + &PathBuf::from(format!("/Users/notes/Library/Caches/{APP_ID}/runtime/shell")) + ); + } + + #[test] + fn linux_paths_preserve_xdg_defaults() { + let env = EnvPaths { + home: Some(PathBuf::from("/home/notes")), + xdg_config_home: Some(PathBuf::from("/tmp/config")), + xdg_state_home: Some(PathBuf::from("/tmp/state")), + xdg_cache_home: Some(PathBuf::from("/tmp/cache")), + xdg_data_home: Some(PathBuf::from("/tmp/data")), + xdg_runtime_dir: Some(PathBuf::from("/tmp/runtime")), + ..EnvPaths::default() + }; + let paths = TaskersPaths::from_env(HostPlatform::Linux, &env); + + assert_eq!(paths.config_path(), &PathBuf::from("/tmp/config/taskers/config.json")); + assert_eq!(paths.session_path(), &PathBuf::from("/tmp/state/taskers/session.json")); + assert_eq!(paths.ghostty_runtime_dir(), &PathBuf::from("/tmp/data/taskers/ghostty")); + assert_eq!(paths.shell_runtime_dir(), &PathBuf::from("/tmp/runtime/taskers/shell")); + assert_eq!(paths.socket_path(), &PathBuf::from("/tmp/taskers.sock")); + } + + #[test] + fn explicit_overrides_win() { + let env = EnvPaths { + taskers_config_path: Some(PathBuf::from("/work/config.json")), + taskers_session_path: Some(PathBuf::from("/work/session.json")), + taskers_socket_path: Some(PathBuf::from("/work/control.sock")), + taskers_runtime_dir: Some(PathBuf::from("/work/runtime")), + taskers_ghostty_runtime_dir: Some(PathBuf::from("/work/ghostty")), + ..EnvPaths::default() + }; + let paths = TaskersPaths::from_env(HostPlatform::Macos, &env); + + assert_eq!(paths.config_path(), &PathBuf::from("/work/config.json")); + assert_eq!(paths.session_path(), &PathBuf::from("/work/session.json")); + assert_eq!(paths.socket_path(), &PathBuf::from("/work/control.sock")); + assert_eq!(paths.shell_runtime_dir(), &PathBuf::from("/work/runtime/shell")); + assert_eq!(paths.ghostty_runtime_dir(), &PathBuf::from("/work/ghostty")); + } +} diff --git a/crates/taskers-runtime/Cargo.toml b/crates/taskers-runtime/Cargo.toml index 8f20859..42a6619 100644 --- a/crates/taskers-runtime/Cargo.toml +++ b/crates/taskers-runtime/Cargo.toml @@ -13,4 +13,5 @@ anyhow.workspace = true base64.workspace = true libc.workspace = true portable-pty.workspace = true +taskers-paths.workspace = true taskers-domain = { version = "0.2.1", path = "../taskers-domain" } diff --git a/crates/taskers-runtime/src/shell.rs b/crates/taskers-runtime/src/shell.rs index 53a4914..02659eb 100644 --- a/crates/taskers-runtime/src/shell.rs +++ b/crates/taskers-runtime/src/shell.rs @@ -276,21 +276,7 @@ fn prepend_path_entry(entry: &Path) -> String { } fn runtime_root() -> PathBuf { - if let Some(path) = std::env::var_os("TASKERS_RUNTIME_DIR") - .map(PathBuf::from) - .filter(|path| !path.as_os_str().is_empty()) - { - return path.join("shell"); - } - - if let Some(path) = std::env::var_os("XDG_RUNTIME_DIR") - .map(PathBuf::from) - .filter(|path| !path.as_os_str().is_empty()) - { - return path.join("taskers").join("shell"); - } - - std::env::temp_dir().join("taskers-runtime").join("shell") + taskers_paths::default_shell_runtime_dir() } fn write_asset(path: &Path, content: &str, executable: bool) -> Result<()> { From 94425244d33266715d71be49fd8415e5cab17333 Mon Sep 17 00:00:00 2001 From: OneNoted Date: Mon, 16 Mar 2026 23:47:16 +0100 Subject: [PATCH 02/40] feat: bootstrap native macOS preview host --- .github/workflows/macos-preview.yml | 87 +++++ .gitignore | 3 + Cargo.lock | 1 + crates/taskers-app/src/main.rs | 2 +- crates/taskers-app/src/settings_store.rs | 25 +- crates/taskers-app/src/themes.rs | 362 +++++++++--------- crates/taskers-control/src/controller.rs | 297 ++++++++++---- crates/taskers-core/src/app_state.rs | 68 +++- crates/taskers-domain/src/model.rs | 39 +- crates/taskers-ghostty/src/backend.rs | 44 +++ crates/taskers-macos-ffi/Cargo.toml | 3 +- .../include/taskers_macos_ffi.h | 3 + crates/taskers-macos-ffi/src/lib.rs | 153 +++++--- crates/taskers-paths/src/lib.rs | 29 +- macos/TaskersMac/Info.plist | 26 ++ .../Sources/TaskersCoreBridge.swift | 150 ++++++++ .../Sources/TaskersEnvironment.swift | 35 ++ .../Sources/TaskersGhosttyHost.swift | 167 ++++++++ .../Sources/TaskersMac-Bridging-Header.h | 2 + .../TaskersMac/Sources/TaskersSnapshot.swift | 191 +++++++++ .../Sources/TaskersTerminalView.swift | 343 +++++++++++++++++ .../Sources/TaskersWorkspaceController.swift | 241 ++++++++++++ macos/TaskersMac/Sources/main.swift | 51 +++ .../TaskersCoreBridgeTests.swift | 29 ++ macos/TaskersMacTests/TaskersSmokeTests.swift | 102 +++++ .../TaskersSnapshotTests.swift | 82 ++++ macos/project.yml | 62 +++ scripts/generate_macos_project.sh | 7 + scripts/macos-build-preview-deps.sh | 28 ++ scripts/stage_macos_bundle_support.sh | 20 + 30 files changed, 2318 insertions(+), 334 deletions(-) create mode 100644 .github/workflows/macos-preview.yml create mode 100644 macos/TaskersMac/Info.plist create mode 100644 macos/TaskersMac/Sources/TaskersCoreBridge.swift create mode 100644 macos/TaskersMac/Sources/TaskersEnvironment.swift create mode 100644 macos/TaskersMac/Sources/TaskersGhosttyHost.swift create mode 100644 macos/TaskersMac/Sources/TaskersMac-Bridging-Header.h create mode 100644 macos/TaskersMac/Sources/TaskersSnapshot.swift create mode 100644 macos/TaskersMac/Sources/TaskersTerminalView.swift create mode 100644 macos/TaskersMac/Sources/TaskersWorkspaceController.swift create mode 100644 macos/TaskersMac/Sources/main.swift create mode 100644 macos/TaskersMacTests/TaskersCoreBridgeTests.swift create mode 100644 macos/TaskersMacTests/TaskersSmokeTests.swift create mode 100644 macos/TaskersMacTests/TaskersSnapshotTests.swift create mode 100644 macos/project.yml create mode 100755 scripts/generate_macos_project.sh create mode 100755 scripts/macos-build-preview-deps.sh create mode 100755 scripts/stage_macos_bundle_support.sh diff --git a/.github/workflows/macos-preview.yml b/.github/workflows/macos-preview.yml new file mode 100644 index 0000000..dbe7216 --- /dev/null +++ b/.github/workflows/macos-preview.yml @@ -0,0 +1,87 @@ +name: macOS Preview + +on: + pull_request: + push: + branches: + - main + workflow_dispatch: + +jobs: + macos-preview: + strategy: + fail-fast: false + matrix: + runner: + - macos-15 + - macos-15-intel + runs-on: ${{ matrix.runner }} + + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + + - name: Cache Rust artifacts + uses: Swatinem/rust-cache@v2 + + - name: Install macOS build tools + run: | + brew update + brew install xcodegen zig + + - name: Run shared Rust validation + run: | + cargo test -p taskers-control -p taskers-core -p taskers-macos-ffi + cargo check --workspace + + - name: Generate Xcode project + run: ./scripts/generate_macos_project.sh + + - name: Build and test Taskers.app + run: | + set -o pipefail + xcodebuild test \ + -project macos/Taskers.xcodeproj \ + -scheme TaskersMac \ + -configuration Debug \ + -derivedDataPath build/macos/DerivedData \ + -resultBundlePath build/macos/Taskers-${{ matrix.runner }}.xcresult \ + CODE_SIGNING_ALLOWED=NO \ + CODE_SIGNING_REQUIRED=NO \ + | tee build/macos/xcodebuild-test-${{ matrix.runner }}.log + + - name: Smoke launch bundled app + run: | + APP_PATH="build/macos/DerivedData/Build/Products/Debug/Taskers.app" + if [[ ! -d "${APP_PATH}" ]]; then + echo "expected app bundle at ${APP_PATH}" >&2 + exit 1 + fi + + TASKERS_SMOKE_TEST=1 \ + "${APP_PATH}/Contents/MacOS/Taskers" + + - name: Package unsigned app + if: always() + run: | + APP_PATH="build/macos/DerivedData/Build/Products/Debug/Taskers.app" + if [[ -d "${APP_PATH}" ]]; then + ditto -c -k --sequesterRsrc --keepParent \ + "${APP_PATH}" \ + "build/macos/Taskers-${{ matrix.runner }}.zip" + fi + + - name: Upload build artifacts + if: always() + uses: actions/upload-artifact@v4 + with: + name: taskers-macos-${{ matrix.runner }} + if-no-files-found: ignore + path: | + build/macos/Taskers-${{ matrix.runner }}.zip + build/macos/Taskers-${{ matrix.runner }}.xcresult + build/macos/xcodebuild-test-${{ matrix.runner }}.log + build/macos/DerivedData/Logs/Build/*.xcactivitylog diff --git a/.gitignore b/.gitignore index 849ddff..e369eb3 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,4 @@ +build/ dist/ +build/ +macos/Taskers.xcodeproj/ diff --git a/Cargo.lock b/Cargo.lock index 815b822..da1cf55 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1543,6 +1543,7 @@ dependencies = [ name = "taskers-macos-ffi" version = "0.2.1" dependencies = [ + "serde", "serde_json", "taskers-control", "taskers-core", diff --git a/crates/taskers-app/src/main.rs b/crates/taskers-app/src/main.rs index be09e46..996f6d7 100644 --- a/crates/taskers-app/src/main.rs +++ b/crates/taskers-app/src/main.rs @@ -26,10 +26,10 @@ use gtk::{ use serde_json::json; use settings_store::{AppConfig, ShortcutAction, ShortcutPreset}; use svgtypes::{SimplePathSegment, SimplifyingPathParser}; -use taskers_core::{AppState, PaneRuntimeSnapshot, default_session_path, load_or_bootstrap}; use taskers_control::{ ControlCommand, InMemoryController, bind_socket, default_socket_path, serve, }; +use taskers_core::{AppState, PaneRuntimeSnapshot, default_session_path, load_or_bootstrap}; use taskers_domain::{ ActivityItem, AppModel, AttentionState, DEFAULT_WORKSPACE_WINDOW_GAP, DEFAULT_WORKSPACE_WINDOW_HEIGHT, DEFAULT_WORKSPACE_WINDOW_WIDTH, Direction, diff --git a/crates/taskers-app/src/settings_store.rs b/crates/taskers-app/src/settings_store.rs index 755c52e..5c861a2 100644 --- a/crates/taskers-app/src/settings_store.rs +++ b/crates/taskers-app/src/settings_store.rs @@ -109,7 +109,9 @@ impl ShortcutAction { match self { Self::ToggleOverview => "Zoom the current workspace out to fit the full column strip.", Self::CloseTerminal => "Close the active pane or active top-level window.", - Self::FocusLeft => "Move focus to the column on the left, then fall back to pane focus.", + Self::FocusLeft => { + "Move focus to the column on the left, then fall back to pane focus." + } Self::FocusRight => { "Move focus to the column on the right, then fall back to pane focus." } @@ -370,10 +372,9 @@ mod tests { let tempdir = tempdir().expect("tempdir"); let config_path = tempdir.path().join("config.json"); let mut config = AppConfig::default(); - config.keybindings.set_accelerators( - ShortcutAction::FocusRight, - vec!["t".into()], - ); + config + .keybindings + .set_accelerators(ShortcutAction::FocusRight, vec!["t".into()]); save_config(&config_path, &config).expect("config saved"); let loaded = load_or_default(&config_path).expect("config loaded"); @@ -390,7 +391,9 @@ mod tests { let loaded = load_or_default(&config_path).expect("config loaded"); assert_eq!( - loaded.keybindings.accelerators(ShortcutAction::ToggleOverview), + loaded + .keybindings + .accelerators(ShortcutAction::ToggleOverview), ShortcutAction::ToggleOverview .default_accelerators() .iter() @@ -406,7 +409,9 @@ mod tests { .collect::>() ); assert_eq!( - loaded.keybindings.accelerators(ShortcutAction::CloseTerminal), + loaded + .keybindings + .accelerators(ShortcutAction::CloseTerminal), ShortcutAction::CloseTerminal .default_accelerators() .iter() @@ -422,7 +427,11 @@ mod tests { .default_accelerators() .is_empty() ); - assert!(ShortcutAction::NewWindowUp.default_accelerators().is_empty()); + assert!( + ShortcutAction::NewWindowUp + .default_accelerators() + .is_empty() + ); assert!( ShortcutAction::ResizeWindowLeft .default_accelerators() diff --git a/crates/taskers-app/src/themes.rs b/crates/taskers-app/src/themes.rs index cf73495..03f31c6 100644 --- a/crates/taskers-app/src/themes.rs +++ b/crates/taskers-app/src/themes.rs @@ -56,22 +56,22 @@ pub fn builtin_theme(name: &str) -> Option { pub fn catppuccin_mocha() -> ThemePalette { ThemePalette { - base: Color::new(0x1e, 0x1e, 0x2e), // Base - surface: Color::new(0x18, 0x18, 0x25), // Mantle - elevated: Color::new(0x31, 0x32, 0x44), // Surface0 - overlay: Color::new(0x45, 0x47, 0x5a), // Surface1 - text: Color::new(0xcd, 0xd6, 0xf4), // Text + base: Color::new(0x1e, 0x1e, 0x2e), // Base + surface: Color::new(0x18, 0x18, 0x25), // Mantle + elevated: Color::new(0x31, 0x32, 0x44), // Surface0 + overlay: Color::new(0x45, 0x47, 0x5a), // Surface1 + text: Color::new(0xcd, 0xd6, 0xf4), // Text text_bright: Color::new(0xe4, 0xe8, 0xfb), - text_muted: Color::new(0xa6, 0xad, 0xc8), // Subtext0 - text_subtle: Color::new(0x93, 0x99, 0xb2), // Overlay2 - text_dim: Color::new(0x7f, 0x84, 0x9c), // Overlay1 - text_faint: Color::new(0x6c, 0x70, 0x86), // Overlay0 + text_muted: Color::new(0xa6, 0xad, 0xc8), // Subtext0 + text_subtle: Color::new(0x93, 0x99, 0xb2), // Overlay2 + text_dim: Color::new(0x7f, 0x84, 0x9c), // Overlay1 + text_faint: Color::new(0x6c, 0x70, 0x86), // Overlay0 border: Color::new(0xff, 0xff, 0xff), - accent: Color::new(0xb4, 0xbe, 0xfe), // Lavender - busy: Color::new(0x89, 0xb4, 0xfa), // Blue - completed: Color::new(0xa6, 0xe3, 0xa1), // Green - waiting: Color::new(0x94, 0xe2, 0xd5), // Teal - error: Color::new(0xf3, 0x8b, 0xa8), // Red + accent: Color::new(0xb4, 0xbe, 0xfe), // Lavender + busy: Color::new(0x89, 0xb4, 0xfa), // Blue + completed: Color::new(0xa6, 0xe3, 0xa1), // Green + waiting: Color::new(0x94, 0xe2, 0xd5), // Teal + error: Color::new(0xf3, 0x8b, 0xa8), // Red busy_text: Color::new(0xbc, 0xd3, 0xfc), completed_text: Color::new(0xc3, 0xed, 0xbe), waiting_text: Color::new(0xb8, 0xed, 0xe6), @@ -88,28 +88,28 @@ pub fn catppuccin_mocha() -> ThemePalette { pub fn catppuccin_macchiato() -> ThemePalette { ThemePalette { base: Color::new(0x24, 0x27, 0x3a), - surface: Color::new(0x1e, 0x20, 0x30), // Mantle - elevated: Color::new(0x36, 0x3a, 0x4f), // Surface0 - overlay: Color::new(0x49, 0x4d, 0x64), // Surface1 + surface: Color::new(0x1e, 0x20, 0x30), // Mantle + elevated: Color::new(0x36, 0x3a, 0x4f), // Surface0 + overlay: Color::new(0x49, 0x4d, 0x64), // Surface1 text: Color::new(0xca, 0xd3, 0xf5), text_bright: Color::new(0xe1, 0xe5, 0xf9), - text_muted: Color::new(0xa5, 0xad, 0xcb), // Subtext0 - text_subtle: Color::new(0x93, 0x9a, 0xb7), // Overlay2 - text_dim: Color::new(0x80, 0x87, 0xa2), // Overlay1 - text_faint: Color::new(0x6e, 0x73, 0x8d), // Overlay0 + text_muted: Color::new(0xa5, 0xad, 0xcb), // Subtext0 + text_subtle: Color::new(0x93, 0x9a, 0xb7), // Overlay2 + text_dim: Color::new(0x80, 0x87, 0xa2), // Overlay1 + text_faint: Color::new(0x6e, 0x73, 0x8d), // Overlay0 border: Color::new(0xff, 0xff, 0xff), - accent: Color::new(0xb7, 0xbd, 0xf8), // Lavender - busy: Color::new(0x8a, 0xad, 0xf4), // Blue - completed: Color::new(0xa6, 0xda, 0x95), // Green - waiting: Color::new(0x8b, 0xd5, 0xca), // Teal - error: Color::new(0xed, 0x87, 0x96), // Red + accent: Color::new(0xb7, 0xbd, 0xf8), // Lavender + busy: Color::new(0x8a, 0xad, 0xf4), // Blue + completed: Color::new(0xa6, 0xda, 0x95), // Green + waiting: Color::new(0x8b, 0xd5, 0xca), // Teal + error: Color::new(0xed, 0x87, 0x96), // Red busy_text: Color::new(0xb8, 0xd2, 0xf8), completed_text: Color::new(0xc3, 0xea, 0xbc), waiting_text: Color::new(0xb3, 0xe6, 0xdf), error_text: Color::new(0xf4, 0xb3, 0xbc), - action_window: Color::new(0xc6, 0xa0, 0xf6), // Mauve - action_split: Color::new(0x91, 0xd7, 0xe3), // Sky - action_teal: Color::new(0x8b, 0xd5, 0xca), // Teal + action_window: Color::new(0xc6, 0xa0, 0xf6), // Mauve + action_split: Color::new(0x91, 0xd7, 0xe3), // Sky + action_teal: Color::new(0x8b, 0xd5, 0xca), // Teal agent_claude: Color::new(0xd9, 0x77, 0x57), agent_codex: Color::new(0xe1, 0xe5, 0xf9), agent_opencode: Color::new(0xa5, 0xad, 0xcb), @@ -119,28 +119,28 @@ pub fn catppuccin_macchiato() -> ThemePalette { pub fn catppuccin_frappe() -> ThemePalette { ThemePalette { base: Color::new(0x30, 0x34, 0x46), - surface: Color::new(0x29, 0x2c, 0x3c), // Mantle - elevated: Color::new(0x41, 0x45, 0x59), // Surface0 - overlay: Color::new(0x51, 0x57, 0x6d), // Surface1 + surface: Color::new(0x29, 0x2c, 0x3c), // Mantle + elevated: Color::new(0x41, 0x45, 0x59), // Surface0 + overlay: Color::new(0x51, 0x57, 0x6d), // Surface1 text: Color::new(0xc6, 0xd0, 0xf5), text_bright: Color::new(0xde, 0xe2, 0xf7), - text_muted: Color::new(0xa5, 0xad, 0xce), // Subtext0 - text_subtle: Color::new(0x94, 0x9c, 0xbb), // Overlay2 - text_dim: Color::new(0x83, 0x8b, 0xa7), // Overlay1 - text_faint: Color::new(0x73, 0x79, 0x94), // Overlay0 + text_muted: Color::new(0xa5, 0xad, 0xce), // Subtext0 + text_subtle: Color::new(0x94, 0x9c, 0xbb), // Overlay2 + text_dim: Color::new(0x83, 0x8b, 0xa7), // Overlay1 + text_faint: Color::new(0x73, 0x79, 0x94), // Overlay0 border: Color::new(0xff, 0xff, 0xff), - accent: Color::new(0xba, 0xbb, 0xf1), // Lavender - busy: Color::new(0x8c, 0xaa, 0xee), // Blue - completed: Color::new(0xa6, 0xd1, 0x89), // Green - waiting: Color::new(0x81, 0xc8, 0xbe), // Teal - error: Color::new(0xe7, 0x82, 0x84), // Red + accent: Color::new(0xba, 0xbb, 0xf1), // Lavender + busy: Color::new(0x8c, 0xaa, 0xee), // Blue + completed: Color::new(0xa6, 0xd1, 0x89), // Green + waiting: Color::new(0x81, 0xc8, 0xbe), // Teal + error: Color::new(0xe7, 0x82, 0x84), // Red busy_text: Color::new(0xb8, 0xce, 0xf5), completed_text: Color::new(0xc4, 0xe5, 0xb2), waiting_text: Color::new(0xaf, 0xe0, 0xd8), error_text: Color::new(0xf0, 0xb2, 0xb3), - action_window: Color::new(0xca, 0x9e, 0xe6), // Mauve - action_split: Color::new(0x99, 0xd1, 0xdb), // Sky - action_teal: Color::new(0x81, 0xc8, 0xbe), // Teal + action_window: Color::new(0xca, 0x9e, 0xe6), // Mauve + action_split: Color::new(0x99, 0xd1, 0xdb), // Sky + action_teal: Color::new(0x81, 0xc8, 0xbe), // Teal agent_claude: Color::new(0xd9, 0x77, 0x57), agent_codex: Color::new(0xde, 0xe2, 0xf7), agent_opencode: Color::new(0xa5, 0xad, 0xce), @@ -149,29 +149,29 @@ pub fn catppuccin_frappe() -> ThemePalette { pub fn catppuccin_latte() -> ThemePalette { ThemePalette { - base: Color::new(0xef, 0xf1, 0xf5), // Base - surface: Color::new(0xe6, 0xe9, 0xef), // Mantle - elevated: Color::new(0xcc, 0xd0, 0xda), // Surface0 - overlay: Color::new(0xbc, 0xc0, 0xcc), // Surface1 - text: Color::new(0x4c, 0x4f, 0x69), // Text + base: Color::new(0xef, 0xf1, 0xf5), // Base + surface: Color::new(0xe6, 0xe9, 0xef), // Mantle + elevated: Color::new(0xcc, 0xd0, 0xda), // Surface0 + overlay: Color::new(0xbc, 0xc0, 0xcc), // Surface1 + text: Color::new(0x4c, 0x4f, 0x69), // Text text_bright: Color::new(0x2a, 0x2c, 0x3e), - text_muted: Color::new(0x6c, 0x6f, 0x85), // Subtext0 - text_subtle: Color::new(0x7c, 0x7f, 0x93), // Overlay2 - text_dim: Color::new(0x8c, 0x8f, 0xa1), // Overlay1 - text_faint: Color::new(0x9c, 0xa0, 0xb0), // Overlay0 - border: Color::new(0x00, 0x00, 0x00), // Black for light theme - accent: Color::new(0x72, 0x87, 0xfd), // Lavender - busy: Color::new(0x1e, 0x66, 0xf5), // Blue - completed: Color::new(0x40, 0xa0, 0x2b), // Green - waiting: Color::new(0x17, 0x92, 0x99), // Teal - error: Color::new(0xd2, 0x0f, 0x39), // Red + text_muted: Color::new(0x6c, 0x6f, 0x85), // Subtext0 + text_subtle: Color::new(0x7c, 0x7f, 0x93), // Overlay2 + text_dim: Color::new(0x8c, 0x8f, 0xa1), // Overlay1 + text_faint: Color::new(0x9c, 0xa0, 0xb0), // Overlay0 + border: Color::new(0x00, 0x00, 0x00), // Black for light theme + accent: Color::new(0x72, 0x87, 0xfd), // Lavender + busy: Color::new(0x1e, 0x66, 0xf5), // Blue + completed: Color::new(0x40, 0xa0, 0x2b), // Green + waiting: Color::new(0x17, 0x92, 0x99), // Teal + error: Color::new(0xd2, 0x0f, 0x39), // Red busy_text: Color::new(0x1e, 0x66, 0xf5), completed_text: Color::new(0x40, 0xa0, 0x2b), waiting_text: Color::new(0x17, 0x92, 0x99), error_text: Color::new(0xd2, 0x0f, 0x39), - action_window: Color::new(0x88, 0x39, 0xef), // Mauve - action_split: Color::new(0x04, 0xa5, 0xe5), // Sky - action_teal: Color::new(0x17, 0x92, 0x99), // Teal + action_window: Color::new(0x88, 0x39, 0xef), // Mauve + action_split: Color::new(0x04, 0xa5, 0xe5), // Sky + action_teal: Color::new(0x17, 0x92, 0x99), // Teal agent_claude: Color::new(0xd9, 0x77, 0x57), agent_codex: Color::new(0x2a, 0x2c, 0x3e), agent_opencode: Color::new(0x6c, 0x6f, 0x85), @@ -184,28 +184,28 @@ pub fn catppuccin_latte() -> ThemePalette { pub fn tokyo_night() -> ThemePalette { ThemePalette { base: Color::new(0x1a, 0x1b, 0x26), - surface: Color::new(0x16, 0x16, 0x1e), // bg_dark - elevated: Color::new(0x29, 0x2e, 0x42), // bg_highlight - overlay: Color::new(0x41, 0x48, 0x68), // terminal_black - text: Color::new(0xc0, 0xca, 0xf5), // fg + surface: Color::new(0x16, 0x16, 0x1e), // bg_dark + elevated: Color::new(0x29, 0x2e, 0x42), // bg_highlight + overlay: Color::new(0x41, 0x48, 0x68), // terminal_black + text: Color::new(0xc0, 0xca, 0xf5), // fg text_bright: Color::new(0xdc, 0xe0, 0xf8), - text_muted: Color::new(0xa9, 0xb1, 0xd6), // fg_dark - text_subtle: Color::new(0x73, 0x7a, 0xa2), // dark5 - text_dim: Color::new(0x56, 0x5f, 0x89), // comment - text_faint: Color::new(0x3b, 0x42, 0x61), // fg_gutter + text_muted: Color::new(0xa9, 0xb1, 0xd6), // fg_dark + text_subtle: Color::new(0x73, 0x7a, 0xa2), // dark5 + text_dim: Color::new(0x56, 0x5f, 0x89), // comment + text_faint: Color::new(0x3b, 0x42, 0x61), // fg_gutter border: Color::new(0xff, 0xff, 0xff), - accent: Color::new(0x7d, 0xcf, 0xff), // cyan - busy: Color::new(0x7a, 0xa2, 0xf7), // blue - completed: Color::new(0x9e, 0xce, 0x6a), // green - waiting: Color::new(0x7d, 0xcf, 0xff), // cyan - error: Color::new(0xf7, 0x76, 0x8e), // red + accent: Color::new(0x7d, 0xcf, 0xff), // cyan + busy: Color::new(0x7a, 0xa2, 0xf7), // blue + completed: Color::new(0x9e, 0xce, 0x6a), // green + waiting: Color::new(0x7d, 0xcf, 0xff), // cyan + error: Color::new(0xf7, 0x76, 0x8e), // red busy_text: Color::new(0xb0, 0xc8, 0xfa), completed_text: Color::new(0xc4, 0xe4, 0xa6), waiting_text: Color::new(0xb0, 0xe3, 0xff), error_text: Color::new(0xfa, 0xb0, 0xbc), - action_window: Color::new(0xbb, 0x9a, 0xf7), // purple - action_split: Color::new(0x2a, 0xc3, 0xde), // blue1 - action_teal: Color::new(0x1a, 0xbc, 0x9c), // teal + action_window: Color::new(0xbb, 0x9a, 0xf7), // purple + action_split: Color::new(0x2a, 0xc3, 0xde), // blue1 + action_teal: Color::new(0x1a, 0xbc, 0x9c), // teal agent_claude: Color::new(0xd9, 0x77, 0x57), agent_codex: Color::new(0xdc, 0xe0, 0xf8), agent_opencode: Color::new(0xa9, 0xb1, 0xd6), @@ -248,29 +248,29 @@ pub fn tokyo_night_storm() -> ThemePalette { pub fn gruvbox_dark() -> ThemePalette { ThemePalette { - base: Color::new(0x28, 0x28, 0x28), // bg - surface: Color::new(0x1d, 0x20, 0x21), // bg0_h - elevated: Color::new(0x3c, 0x38, 0x36), // bg1 - overlay: Color::new(0x50, 0x49, 0x45), // bg2 - text: Color::new(0xeb, 0xdb, 0xb2), // fg - text_bright: Color::new(0xfb, 0xf1, 0xc7), // fg0 - text_muted: Color::new(0xd5, 0xc4, 0xa1), // fg2 - text_subtle: Color::new(0xbd, 0xae, 0x93), // fg3 - text_dim: Color::new(0xa8, 0x99, 0x84), // fg4 - text_faint: Color::new(0x92, 0x83, 0x74), // gray + base: Color::new(0x28, 0x28, 0x28), // bg + surface: Color::new(0x1d, 0x20, 0x21), // bg0_h + elevated: Color::new(0x3c, 0x38, 0x36), // bg1 + overlay: Color::new(0x50, 0x49, 0x45), // bg2 + text: Color::new(0xeb, 0xdb, 0xb2), // fg + text_bright: Color::new(0xfb, 0xf1, 0xc7), // fg0 + text_muted: Color::new(0xd5, 0xc4, 0xa1), // fg2 + text_subtle: Color::new(0xbd, 0xae, 0x93), // fg3 + text_dim: Color::new(0xa8, 0x99, 0x84), // fg4 + text_faint: Color::new(0x92, 0x83, 0x74), // gray border: Color::new(0xff, 0xff, 0xff), - accent: Color::new(0x83, 0xa5, 0x98), // bright aqua - busy: Color::new(0x83, 0xa5, 0x98), // bright blue - completed: Color::new(0xb8, 0xbb, 0x26), // bright green - waiting: Color::new(0x8e, 0xc0, 0x7c), // bright aqua - error: Color::new(0xfb, 0x49, 0x34), // bright red + accent: Color::new(0x83, 0xa5, 0x98), // bright aqua + busy: Color::new(0x83, 0xa5, 0x98), // bright blue + completed: Color::new(0xb8, 0xbb, 0x26), // bright green + waiting: Color::new(0x8e, 0xc0, 0x7c), // bright aqua + error: Color::new(0xfb, 0x49, 0x34), // bright red busy_text: Color::new(0xb4, 0xcf, 0xc5), completed_text: Color::new(0xd5, 0xd7, 0x8a), waiting_text: Color::new(0xbc, 0xdb, 0xac), error_text: Color::new(0xfc, 0xa0, 0x9a), - action_window: Color::new(0xd3, 0x86, 0x9b), // bright purple - action_split: Color::new(0x8e, 0xc0, 0x7c), // bright aqua - action_teal: Color::new(0x68, 0x9d, 0x6a), // aqua + action_window: Color::new(0xd3, 0x86, 0x9b), // bright purple + action_split: Color::new(0x8e, 0xc0, 0x7c), // bright aqua + action_teal: Color::new(0x68, 0x9d, 0x6a), // aqua agent_claude: Color::new(0xd9, 0x77, 0x57), agent_codex: Color::new(0xfb, 0xf1, 0xc7), agent_opencode: Color::new(0xd5, 0xc4, 0xa1), @@ -282,29 +282,29 @@ pub fn gruvbox_dark() -> ThemePalette { pub fn nord() -> ThemePalette { ThemePalette { - base: Color::new(0x2e, 0x34, 0x40), // nord0 + base: Color::new(0x2e, 0x34, 0x40), // nord0 surface: Color::new(0x27, 0x2c, 0x36), - elevated: Color::new(0x3b, 0x42, 0x52), // nord1 - overlay: Color::new(0x43, 0x4c, 0x5e), // nord2 - text: Color::new(0xd8, 0xde, 0xe9), // nord4 - text_bright: Color::new(0xec, 0xef, 0xf4), // nord6 + elevated: Color::new(0x3b, 0x42, 0x52), // nord1 + overlay: Color::new(0x43, 0x4c, 0x5e), // nord2 + text: Color::new(0xd8, 0xde, 0xe9), // nord4 + text_bright: Color::new(0xec, 0xef, 0xf4), // nord6 text_muted: Color::new(0xb8, 0xc0, 0xcc), text_subtle: Color::new(0x8e, 0x96, 0xa3), text_dim: Color::new(0x6c, 0x76, 0x89), - text_faint: Color::new(0x4c, 0x56, 0x6a), // nord3 + text_faint: Color::new(0x4c, 0x56, 0x6a), // nord3 border: Color::new(0xff, 0xff, 0xff), - accent: Color::new(0x88, 0xc0, 0xd0), // nord8 Frost - busy: Color::new(0x81, 0xa1, 0xc1), // nord9 - completed: Color::new(0xa3, 0xbe, 0x8c), // nord14 green - waiting: Color::new(0x8f, 0xbc, 0xbb), // nord7 frost - error: Color::new(0xbf, 0x61, 0x6a), // nord11 red + accent: Color::new(0x88, 0xc0, 0xd0), // nord8 Frost + busy: Color::new(0x81, 0xa1, 0xc1), // nord9 + completed: Color::new(0xa3, 0xbe, 0x8c), // nord14 green + waiting: Color::new(0x8f, 0xbc, 0xbb), // nord7 frost + error: Color::new(0xbf, 0x61, 0x6a), // nord11 red busy_text: Color::new(0xb3, 0xc8, 0xdb), completed_text: Color::new(0xc8, 0xdb, 0xb4), waiting_text: Color::new(0xb8, 0xd8, 0xd6), error_text: Color::new(0xdb, 0xa1, 0xa7), - action_window: Color::new(0xb4, 0x8e, 0xad), // nord15 purple - action_split: Color::new(0x88, 0xc0, 0xd0), // nord8 - action_teal: Color::new(0x8f, 0xbc, 0xbb), // nord7 + action_window: Color::new(0xb4, 0x8e, 0xad), // nord15 purple + action_split: Color::new(0x88, 0xc0, 0xd0), // nord8 + action_teal: Color::new(0x8f, 0xbc, 0xbb), // nord7 agent_claude: Color::new(0xd9, 0x77, 0x57), agent_codex: Color::new(0xec, 0xef, 0xf4), agent_opencode: Color::new(0xb8, 0xc0, 0xcc), @@ -316,29 +316,29 @@ pub fn nord() -> ThemePalette { pub fn dracula() -> ThemePalette { ThemePalette { - base: Color::new(0x28, 0x2a, 0x36), // Background + base: Color::new(0x28, 0x2a, 0x36), // Background surface: Color::new(0x21, 0x22, 0x2c), elevated: Color::new(0x34, 0x37, 0x46), - overlay: Color::new(0x44, 0x47, 0x5a), // Current Line - text: Color::new(0xf8, 0xf8, 0xf2), // Foreground + overlay: Color::new(0x44, 0x47, 0x5a), // Current Line + text: Color::new(0xf8, 0xf8, 0xf2), // Foreground text_bright: Color::new(0xff, 0xff, 0xff), text_muted: Color::new(0xc5, 0xc8, 0xd6), text_subtle: Color::new(0xa4, 0xa8, 0xb8), - text_dim: Color::new(0x62, 0x72, 0xa4), // Comment + text_dim: Color::new(0x62, 0x72, 0xa4), // Comment text_faint: Color::new(0x4e, 0x52, 0x66), border: Color::new(0xff, 0xff, 0xff), - accent: Color::new(0xbd, 0x93, 0xf9), // Purple - busy: Color::new(0x8b, 0xe9, 0xfd), // Cyan - completed: Color::new(0x50, 0xfa, 0x7b), // Green - waiting: Color::new(0xbd, 0x93, 0xf9), // Purple - error: Color::new(0xff, 0x55, 0x55), // Red + accent: Color::new(0xbd, 0x93, 0xf9), // Purple + busy: Color::new(0x8b, 0xe9, 0xfd), // Cyan + completed: Color::new(0x50, 0xfa, 0x7b), // Green + waiting: Color::new(0xbd, 0x93, 0xf9), // Purple + error: Color::new(0xff, 0x55, 0x55), // Red busy_text: Color::new(0xbd, 0xf0, 0xfe), completed_text: Color::new(0x9e, 0xfc, 0xb4), waiting_text: Color::new(0xdb, 0xc8, 0xfc), error_text: Color::new(0xff, 0x99, 0x99), - action_window: Color::new(0xff, 0x79, 0xc6), // Pink - action_split: Color::new(0x8b, 0xe9, 0xfd), // Cyan - action_teal: Color::new(0x50, 0xfa, 0x7b), // Green + action_window: Color::new(0xff, 0x79, 0xc6), // Pink + action_split: Color::new(0x8b, 0xe9, 0xfd), // Cyan + action_teal: Color::new(0x50, 0xfa, 0x7b), // Green agent_claude: Color::new(0xd9, 0x77, 0x57), agent_codex: Color::new(0xff, 0xff, 0xff), agent_opencode: Color::new(0xc5, 0xc8, 0xd6), @@ -350,29 +350,29 @@ pub fn dracula() -> ThemePalette { pub fn solarized_dark() -> ThemePalette { ThemePalette { - base: Color::new(0x00, 0x2b, 0x36), // base03 + base: Color::new(0x00, 0x2b, 0x36), // base03 surface: Color::new(0x00, 0x1e, 0x27), - elevated: Color::new(0x07, 0x36, 0x42), // base02 + elevated: Color::new(0x07, 0x36, 0x42), // base02 overlay: Color::new(0x0a, 0x40, 0x50), - text: Color::new(0x83, 0x94, 0x96), // base0 - text_bright: Color::new(0x93, 0xa1, 0xa1), // base1 + text: Color::new(0x83, 0x94, 0x96), // base0 + text_bright: Color::new(0x93, 0xa1, 0xa1), // base1 text_muted: Color::new(0x6d, 0x82, 0x86), - text_subtle: Color::new(0x58, 0x6e, 0x75), // base01 + text_subtle: Color::new(0x58, 0x6e, 0x75), // base01 text_dim: Color::new(0x46, 0x56, 0x5c), text_faint: Color::new(0x2e, 0x42, 0x48), border: Color::new(0xff, 0xff, 0xff), - accent: Color::new(0x26, 0x8b, 0xd2), // blue - busy: Color::new(0x26, 0x8b, 0xd2), // blue - completed: Color::new(0x85, 0x99, 0x00), // green - waiting: Color::new(0x2a, 0xa1, 0x98), // cyan - error: Color::new(0xdc, 0x32, 0x2f), // red + accent: Color::new(0x26, 0x8b, 0xd2), // blue + busy: Color::new(0x26, 0x8b, 0xd2), // blue + completed: Color::new(0x85, 0x99, 0x00), // green + waiting: Color::new(0x2a, 0xa1, 0x98), // cyan + error: Color::new(0xdc, 0x32, 0x2f), // red busy_text: Color::new(0x6c, 0xb0, 0xde), completed_text: Color::new(0xb0, 0xc4, 0x4d), waiting_text: Color::new(0x6d, 0xc5, 0xbd), error_text: Color::new(0xe8, 0x75, 0x6f), - action_window: Color::new(0x6c, 0x71, 0xc4), // violet - action_split: Color::new(0x2a, 0xa1, 0x98), // cyan - action_teal: Color::new(0x2a, 0xa1, 0x98), // cyan + action_window: Color::new(0x6c, 0x71, 0xc4), // violet + action_split: Color::new(0x2a, 0xa1, 0x98), // cyan + action_teal: Color::new(0x2a, 0xa1, 0x98), // cyan agent_claude: Color::new(0xd9, 0x77, 0x57), agent_codex: Color::new(0x93, 0xa1, 0xa1), agent_opencode: Color::new(0x6d, 0x82, 0x86), @@ -395,18 +395,18 @@ pub fn one_dark() -> ThemePalette { text_dim: Color::new(0x5c, 0x63, 0x70), text_faint: Color::new(0x49, 0x51, 0x62), border: Color::new(0xff, 0xff, 0xff), - accent: Color::new(0x61, 0xaf, 0xef), // blue - busy: Color::new(0x61, 0xaf, 0xef), // blue - completed: Color::new(0x98, 0xc3, 0x79), // green - waiting: Color::new(0x56, 0xb6, 0xc2), // cyan - error: Color::new(0xe0, 0x6c, 0x75), // red + accent: Color::new(0x61, 0xaf, 0xef), // blue + busy: Color::new(0x61, 0xaf, 0xef), // blue + completed: Color::new(0x98, 0xc3, 0x79), // green + waiting: Color::new(0x56, 0xb6, 0xc2), // cyan + error: Color::new(0xe0, 0x6c, 0x75), // red busy_text: Color::new(0x9d, 0xcd, 0xf5), completed_text: Color::new(0xc1, 0xdc, 0xa8), waiting_text: Color::new(0x96, 0xd4, 0xda), error_text: Color::new(0xeb, 0xa8, 0xad), - action_window: Color::new(0xc6, 0x78, 0xdd), // purple - action_split: Color::new(0x56, 0xb6, 0xc2), // cyan - action_teal: Color::new(0x56, 0xb6, 0xc2), // cyan + action_window: Color::new(0xc6, 0x78, 0xdd), // purple + action_split: Color::new(0x56, 0xb6, 0xc2), // cyan + action_teal: Color::new(0x56, 0xb6, 0xc2), // cyan agent_claude: Color::new(0xd9, 0x77, 0x57), agent_codex: Color::new(0xd7, 0xda, 0xe0), agent_opencode: Color::new(0x9d, 0xa5, 0xb4), @@ -418,29 +418,29 @@ pub fn one_dark() -> ThemePalette { pub fn rose_pine() -> ThemePalette { ThemePalette { - base: Color::new(0x19, 0x17, 0x24), // Base - surface: Color::new(0x1f, 0x1d, 0x2e), // Surface - elevated: Color::new(0x26, 0x23, 0x3a), // Overlay - overlay: Color::new(0x40, 0x3d, 0x52), // Highlight Med - text: Color::new(0xe0, 0xde, 0xf4), // Text + base: Color::new(0x19, 0x17, 0x24), // Base + surface: Color::new(0x1f, 0x1d, 0x2e), // Surface + elevated: Color::new(0x26, 0x23, 0x3a), // Overlay + overlay: Color::new(0x40, 0x3d, 0x52), // Highlight Med + text: Color::new(0xe0, 0xde, 0xf4), // Text text_bright: Color::new(0xee, 0xed, 0xff), - text_muted: Color::new(0x90, 0x8c, 0xaa), // Subtle + text_muted: Color::new(0x90, 0x8c, 0xaa), // Subtle text_subtle: Color::new(0x81, 0x7d, 0x9c), - text_dim: Color::new(0x6e, 0x6a, 0x86), // Muted - text_faint: Color::new(0x52, 0x4f, 0x67), // Highlight High + text_dim: Color::new(0x6e, 0x6a, 0x86), // Muted + text_faint: Color::new(0x52, 0x4f, 0x67), // Highlight High border: Color::new(0xff, 0xff, 0xff), - accent: Color::new(0xc4, 0xa7, 0xe7), // Iris - busy: Color::new(0xc4, 0xa7, 0xe7), // Iris - completed: Color::new(0x9c, 0xcf, 0xd8), // Foam - waiting: Color::new(0xf6, 0xc1, 0x77), // Gold - error: Color::new(0xeb, 0x6f, 0x92), // Love + accent: Color::new(0xc4, 0xa7, 0xe7), // Iris + busy: Color::new(0xc4, 0xa7, 0xe7), // Iris + completed: Color::new(0x9c, 0xcf, 0xd8), // Foam + waiting: Color::new(0xf6, 0xc1, 0x77), // Gold + error: Color::new(0xeb, 0x6f, 0x92), // Love busy_text: Color::new(0xdd, 0xd0, 0xf2), completed_text: Color::new(0xc5, 0xe5, 0xe9), waiting_text: Color::new(0xf9, 0xdd, 0xb4), error_text: Color::new(0xf4, 0xa5, 0xb8), - action_window: Color::new(0xeb, 0xbc, 0xba), // Rose - action_split: Color::new(0x9c, 0xcf, 0xd8), // Foam - action_teal: Color::new(0x31, 0x74, 0x8f), // Pine + action_window: Color::new(0xeb, 0xbc, 0xba), // Rose + action_split: Color::new(0x9c, 0xcf, 0xd8), // Foam + action_teal: Color::new(0x31, 0x74, 0x8f), // Pine agent_claude: Color::new(0xd9, 0x77, 0x57), agent_codex: Color::new(0xee, 0xed, 0xff), agent_opencode: Color::new(0x90, 0x8c, 0xaa), @@ -461,17 +461,17 @@ pub fn rose_pine_moon() -> ThemePalette { text_faint: Color::new(0x56, 0x52, 0x6e), border: Color::new(0xff, 0xff, 0xff), accent: Color::new(0xc4, 0xa7, 0xe7), - busy: Color::new(0xc4, 0xa7, 0xe7), // Iris - completed: Color::new(0x9c, 0xcf, 0xd8), // Foam - waiting: Color::new(0xf6, 0xc1, 0x77), // Gold - error: Color::new(0xeb, 0x6f, 0x92), // Love + busy: Color::new(0xc4, 0xa7, 0xe7), // Iris + completed: Color::new(0x9c, 0xcf, 0xd8), // Foam + waiting: Color::new(0xf6, 0xc1, 0x77), // Gold + error: Color::new(0xeb, 0x6f, 0x92), // Love busy_text: Color::new(0xdd, 0xd0, 0xf2), completed_text: Color::new(0xc5, 0xe5, 0xe9), waiting_text: Color::new(0xf9, 0xdd, 0xb4), error_text: Color::new(0xf4, 0xa5, 0xb8), - action_window: Color::new(0xea, 0x9a, 0x97), // Rose - action_split: Color::new(0x9c, 0xcf, 0xd8), // Foam - action_teal: Color::new(0x3e, 0x8f, 0xb0), // Pine + action_window: Color::new(0xea, 0x9a, 0x97), // Rose + action_split: Color::new(0x9c, 0xcf, 0xd8), // Foam + action_teal: Color::new(0x3e, 0x8f, 0xb0), // Pine agent_claude: Color::new(0xd9, 0x77, 0x57), agent_codex: Color::new(0xee, 0xed, 0xff), agent_opencode: Color::new(0x90, 0x8c, 0xaa), @@ -483,29 +483,29 @@ pub fn rose_pine_moon() -> ThemePalette { pub fn kanagawa() -> ThemePalette { ThemePalette { - base: Color::new(0x1f, 0x1f, 0x28), // sumiInk3 - surface: Color::new(0x16, 0x16, 0x1d), // sumiInk0 - elevated: Color::new(0x2a, 0x2a, 0x37), // sumiInk4 - overlay: Color::new(0x36, 0x36, 0x46), // sumiInk5 - text: Color::new(0xdc, 0xd7, 0xba), // fujiWhite + base: Color::new(0x1f, 0x1f, 0x28), // sumiInk3 + surface: Color::new(0x16, 0x16, 0x1d), // sumiInk0 + elevated: Color::new(0x2a, 0x2a, 0x37), // sumiInk4 + overlay: Color::new(0x36, 0x36, 0x46), // sumiInk5 + text: Color::new(0xdc, 0xd7, 0xba), // fujiWhite text_bright: Color::new(0xec, 0xea, 0xd5), - text_muted: Color::new(0xc8, 0xc0, 0x93), // oldWhite + text_muted: Color::new(0xc8, 0xc0, 0x93), // oldWhite text_subtle: Color::new(0xa0, 0x9e, 0x7d), text_dim: Color::new(0x72, 0x71, 0x69), - text_faint: Color::new(0x54, 0x54, 0x6d), // sumiInk6 + text_faint: Color::new(0x54, 0x54, 0x6d), // sumiInk6 border: Color::new(0xff, 0xff, 0xff), - accent: Color::new(0x7e, 0x9c, 0xd8), // crystalBlue - busy: Color::new(0x7e, 0x9c, 0xd8), // crystalBlue - completed: Color::new(0x98, 0xbb, 0x6c), // springGreen - waiting: Color::new(0x7a, 0xa8, 0x9f), // waveAqua2 - error: Color::new(0xe4, 0x68, 0x76), // waveRed + accent: Color::new(0x7e, 0x9c, 0xd8), // crystalBlue + busy: Color::new(0x7e, 0x9c, 0xd8), // crystalBlue + completed: Color::new(0x98, 0xbb, 0x6c), // springGreen + waiting: Color::new(0x7a, 0xa8, 0x9f), // waveAqua2 + error: Color::new(0xe4, 0x68, 0x76), // waveRed busy_text: Color::new(0xb3, 0xc5, 0xe8), completed_text: Color::new(0xc2, 0xd7, 0xa4), waiting_text: Color::new(0xaf, 0xd0, 0xc6), error_text: Color::new(0xf0, 0xa3, 0xab), - action_window: Color::new(0x95, 0x7f, 0xb8), // oniViolet - action_split: Color::new(0x7a, 0xa8, 0x9f), // waveAqua2 - action_teal: Color::new(0x6a, 0x95, 0x89), // waveAqua1 + action_window: Color::new(0x95, 0x7f, 0xb8), // oniViolet + action_split: Color::new(0x7a, 0xa8, 0x9f), // waveAqua2 + action_teal: Color::new(0x6a, 0x95, 0x89), // waveAqua1 agent_claude: Color::new(0xd9, 0x77, 0x57), agent_codex: Color::new(0xec, 0xea, 0xd5), agent_opencode: Color::new(0xc8, 0xc0, 0x93), diff --git a/crates/taskers-control/src/controller.rs b/crates/taskers-control/src/controller.rs index 1c64ab4..52cfaa7 100644 --- a/crates/taskers-control/src/controller.rs +++ b/crates/taskers-control/src/controller.rs @@ -6,42 +6,63 @@ use crate::protocol::{ControlCommand, ControlQuery, ControlResponse}; #[derive(Debug, Clone)] pub struct InMemoryController { - state: Arc>, + state: Arc>, +} + +#[derive(Debug, Clone)] +struct ControllerState { + model: AppModel, + revision: u64, } #[derive(Debug, Clone)] pub struct ControllerSnapshot { pub model: AppModel, + pub revision: u64, } impl InMemoryController { pub fn new(state: AppModel) -> Self { Self { - state: Arc::new(Mutex::new(state)), + state: Arc::new(Mutex::new(ControllerState { + model: state, + revision: 0, + })), } } pub fn snapshot(&self) -> ControllerSnapshot { let state = self.state.lock().expect("state mutex poisoned").clone(); - ControllerSnapshot { model: state } + ControllerSnapshot { + model: state.model, + revision: state.revision, + } + } + + pub fn revision(&self) -> u64 { + self.state.lock().expect("state mutex poisoned").revision } pub fn handle(&self, command: ControlCommand) -> Result { - let mut model = self.state.lock().expect("state mutex poisoned"); + let mut state = self.state.lock().expect("state mutex poisoned"); + let model = &mut state.model; - match command { + let (response, mutated) = match command { ControlCommand::CreateWorkspace { label } => { let workspace_id = model.create_workspace(label); - Ok(ControlResponse::WorkspaceCreated { workspace_id }) + (ControlResponse::WorkspaceCreated { workspace_id }, true) } ControlCommand::RenameWorkspace { workspace_id, label, } => { model.rename_workspace(workspace_id, label)?; - Ok(ControlResponse::Ack { - message: "workspace renamed".into(), - }) + ( + ControlResponse::Ack { + message: "workspace renamed".into(), + }, + true, + ) } ControlCommand::SwitchWorkspace { window_id, @@ -49,9 +70,12 @@ impl InMemoryController { } => { let target_window = window_id.unwrap_or(model.active_window); model.switch_workspace(target_window, workspace_id)?; - Ok(ControlResponse::Ack { - message: "workspace switched".into(), - }) + ( + ControlResponse::Ack { + message: "workspace switched".into(), + }, + true, + ) } ControlCommand::SplitPane { workspace_id, @@ -59,45 +83,60 @@ impl InMemoryController { axis, } => { let new_pane_id = model.split_pane(workspace_id, pane_id, axis)?; - Ok(ControlResponse::PaneSplit { - pane_id: new_pane_id, - }) + ( + ControlResponse::PaneSplit { + pane_id: new_pane_id, + }, + true, + ) } ControlCommand::CreateWorkspaceWindow { workspace_id, direction, } => { let new_pane_id = model.create_workspace_window(workspace_id, direction)?; - Ok(ControlResponse::WorkspaceWindowCreated { - pane_id: new_pane_id, - }) + ( + ControlResponse::WorkspaceWindowCreated { + pane_id: new_pane_id, + }, + true, + ) } ControlCommand::FocusWorkspaceWindow { workspace_id, workspace_window_id, } => { model.focus_workspace_window(workspace_id, workspace_window_id)?; - Ok(ControlResponse::Ack { - message: "workspace window focused".into(), - }) + ( + ControlResponse::Ack { + message: "workspace window focused".into(), + }, + true, + ) } ControlCommand::FocusPane { workspace_id, pane_id, } => { model.focus_pane(workspace_id, pane_id)?; - Ok(ControlResponse::Ack { - message: "pane focused".into(), - }) + ( + ControlResponse::Ack { + message: "pane focused".into(), + }, + true, + ) } ControlCommand::FocusPaneDirection { workspace_id, direction, } => { model.focus_pane_direction(workspace_id, direction)?; - Ok(ControlResponse::Ack { - message: "pane focus moved".into(), - }) + ( + ControlResponse::Ack { + message: "pane focus moved".into(), + }, + true, + ) } ControlCommand::ResizeActiveWindow { workspace_id, @@ -105,9 +144,12 @@ impl InMemoryController { amount, } => { model.resize_active_window(workspace_id, direction, amount)?; - Ok(ControlResponse::Ack { - message: "workspace window resized".into(), - }) + ( + ControlResponse::Ack { + message: "workspace window resized".into(), + }, + true, + ) } ControlCommand::ResizeActivePaneSplit { workspace_id, @@ -115,9 +157,12 @@ impl InMemoryController { amount, } => { model.resize_active_pane_split(workspace_id, direction, amount)?; - Ok(ControlResponse::Ack { - message: "pane split resized".into(), - }) + ( + ControlResponse::Ack { + message: "pane split resized".into(), + }, + true, + ) } ControlCommand::SetWorkspaceColumnWidth { workspace_id, @@ -125,9 +170,12 @@ impl InMemoryController { width, } => { model.set_workspace_column_width(workspace_id, workspace_column_id, width)?; - Ok(ControlResponse::Ack { - message: "workspace column width updated".into(), - }) + ( + ControlResponse::Ack { + message: "workspace column width updated".into(), + }, + true, + ) } ControlCommand::SetWorkspaceWindowHeight { workspace_id, @@ -135,9 +183,12 @@ impl InMemoryController { height, } => { model.set_workspace_window_height(workspace_id, workspace_window_id, height)?; - Ok(ControlResponse::Ack { - message: "workspace window height updated".into(), - }) + ( + ControlResponse::Ack { + message: "workspace window height updated".into(), + }, + true, + ) } ControlCommand::SetWindowSplitRatio { workspace_id, @@ -146,21 +197,30 @@ impl InMemoryController { ratio, } => { model.set_window_split_ratio(workspace_id, workspace_window_id, &path, ratio)?; - Ok(ControlResponse::Ack { - message: "window split ratio updated".into(), - }) + ( + ControlResponse::Ack { + message: "window split ratio updated".into(), + }, + true, + ) } ControlCommand::UpdatePaneMetadata { pane_id, patch } => { model.update_pane_metadata(pane_id, patch)?; - Ok(ControlResponse::Ack { - message: "pane metadata updated".into(), - }) + ( + ControlResponse::Ack { + message: "pane metadata updated".into(), + }, + true, + ) } ControlCommand::UpdateSurfaceMetadata { surface_id, patch } => { model.update_surface_metadata(surface_id, patch)?; - Ok(ControlResponse::Ack { - message: "surface metadata updated".into(), - }) + ( + ControlResponse::Ack { + message: "surface metadata updated".into(), + }, + true, + ) } ControlCommand::CreateSurface { workspace_id, @@ -168,7 +228,7 @@ impl InMemoryController { kind, } => { let surface_id = model.create_surface(workspace_id, pane_id, kind)?; - Ok(ControlResponse::SurfaceCreated { surface_id }) + (ControlResponse::SurfaceCreated { surface_id }, true) } ControlCommand::FocusSurface { workspace_id, @@ -176,9 +236,12 @@ impl InMemoryController { surface_id, } => { model.focus_surface(workspace_id, pane_id, surface_id)?; - Ok(ControlResponse::Ack { - message: "surface focused".into(), - }) + ( + ControlResponse::Ack { + message: "surface focused".into(), + }, + true, + ) } ControlCommand::MarkSurfaceCompleted { workspace_id, @@ -186,9 +249,12 @@ impl InMemoryController { surface_id, } => { model.mark_surface_completed(workspace_id, pane_id, surface_id)?; - Ok(ControlResponse::Ack { - message: "surface marked completed".into(), - }) + ( + ControlResponse::Ack { + message: "surface marked completed".into(), + }, + true, + ) } ControlCommand::CloseSurface { workspace_id, @@ -196,9 +262,12 @@ impl InMemoryController { surface_id, } => { model.close_surface(workspace_id, pane_id, surface_id)?; - Ok(ControlResponse::Ack { - message: "surface closed".into(), - }) + ( + ControlResponse::Ack { + message: "surface closed".into(), + }, + true, + ) } ControlCommand::MoveSurface { workspace_id, @@ -207,33 +276,45 @@ impl InMemoryController { to_index, } => { model.move_surface(workspace_id, pane_id, surface_id, to_index)?; - Ok(ControlResponse::Ack { - message: "surface moved".into(), - }) + ( + ControlResponse::Ack { + message: "surface moved".into(), + }, + true, + ) } ControlCommand::SetWorkspaceViewport { workspace_id, viewport, } => { model.set_workspace_viewport(workspace_id, viewport)?; - Ok(ControlResponse::Ack { - message: "workspace viewport updated".into(), - }) + ( + ControlResponse::Ack { + message: "workspace viewport updated".into(), + }, + true, + ) } ControlCommand::ClosePane { workspace_id, pane_id, } => { model.close_pane(workspace_id, pane_id)?; - Ok(ControlResponse::Ack { - message: "pane closed".into(), - }) + ( + ControlResponse::Ack { + message: "pane closed".into(), + }, + true, + ) } ControlCommand::CloseWorkspace { workspace_id } => { model.close_workspace(workspace_id)?; - Ok(ControlResponse::Ack { - message: "workspace closed".into(), - }) + ( + ControlResponse::Ack { + message: "workspace closed".into(), + }, + true, + ) } ControlCommand::EmitSignal { workspace_id, @@ -246,20 +327,32 @@ impl InMemoryController { } else { model.apply_signal(workspace_id, pane_id, event)?; } - Ok(ControlResponse::Ack { - message: "signal applied".into(), - }) + ( + ControlResponse::Ack { + message: "signal applied".into(), + }, + true, + ) } ControlCommand::QueryStatus { query } => match query { - ControlQuery::ActiveWindow | ControlQuery::All => Ok(ControlResponse::Status { - session: model.snapshot(), - }), - ControlQuery::Window { window_id } => window_snapshot(&model, window_id), + ControlQuery::ActiveWindow | ControlQuery::All => ( + ControlResponse::Status { + session: model.snapshot(), + }, + false, + ), + ControlQuery::Window { window_id } => (window_snapshot(model, window_id)?, false), ControlQuery::Workspace { workspace_id } => { - workspace_snapshot(&model, workspace_id) + (workspace_snapshot(model, workspace_id)?, false) } }, + }; + + if mutated { + state.revision = state.revision.saturating_add(1); } + + Ok(response) } } @@ -286,3 +379,51 @@ fn workspace_snapshot( session: model.snapshot(), }) } + +#[cfg(test)] +mod tests { + use taskers_domain::{AppModel, SignalEvent, SignalKind}; + + use crate::{ControlCommand, ControlQuery}; + + use super::InMemoryController; + + #[test] + fn revision_increments_for_mutations_but_not_queries() { + let controller = InMemoryController::new(AppModel::new("Main")); + assert_eq!(controller.revision(), 0); + + controller + .handle(ControlCommand::QueryStatus { + query: ControlQuery::All, + }) + .expect("query status"); + assert_eq!(controller.revision(), 0); + + controller + .handle(ControlCommand::CreateWorkspace { + label: "Docs".into(), + }) + .expect("create workspace"); + assert_eq!(controller.revision(), 1); + assert_eq!(controller.snapshot().revision, 1); + } + + #[test] + fn revision_increments_for_signal_mutations() { + let controller = InMemoryController::new(AppModel::new("Main")); + let snapshot = controller.snapshot(); + let workspace = snapshot.model.active_workspace().expect("workspace"); + + controller + .handle(ControlCommand::EmitSignal { + workspace_id: workspace.id, + pane_id: workspace.active_pane, + surface_id: None, + event: SignalEvent::new("pty", SignalKind::Progress, Some("Running".into())), + }) + .expect("emit signal"); + + assert_eq!(controller.revision(), 1); + } +} diff --git a/crates/taskers-core/src/app_state.rs b/crates/taskers-core/src/app_state.rs index a137abb..9461878 100644 --- a/crates/taskers-core/src/app_state.rs +++ b/crates/taskers-core/src/app_state.rs @@ -12,6 +12,7 @@ use crate::{pane_runtime::RuntimeManager, session_store}; pub struct AppState { controller: InMemoryController, runtime: RuntimeManager, + backend: BackendChoice, session_path: PathBuf, shell_launch: ShellLaunchSpec, } @@ -26,7 +27,10 @@ impl AppState { let controller = InMemoryController::new(model.clone()); let runtime = RuntimeManager::new( controller.clone(), - backend != BackendChoice::Ghostty, + !matches!( + backend, + BackendChoice::Ghostty | BackendChoice::GhosttyEmbedded + ), shell_launch.clone(), ); runtime.sync_model(&model)?; @@ -34,6 +38,7 @@ impl AppState { let state = Self { controller, runtime, + backend, session_path, shell_launch, }; @@ -49,6 +54,10 @@ impl AppState { self.runtime.clone() } + pub fn backend(&self) -> BackendChoice { + self.backend + } + pub fn shell_launch(&self) -> &ShellLaunchSpec { &self.shell_launch } @@ -57,6 +66,10 @@ impl AppState { self.controller.snapshot().model } + pub fn revision(&self) -> u64 { + self.controller.revision() + } + pub fn dispatch(&self, command: ControlCommand) -> Result { let response = self .controller @@ -119,6 +132,7 @@ impl AppState { mod tests { use std::path::PathBuf; + use taskers_control::{ControlCommand, ControlQuery}; use taskers_domain::AppModel; use taskers_ghostty::BackendChoice; use taskers_runtime::ShellLaunchSpec; @@ -155,7 +169,57 @@ mod tests { descriptor.env.get("TASKERS_WORKSPACE_ID"), Some(&workspace.to_string()) ); - assert_eq!(descriptor.env.get("TASKERS_PANE_ID"), Some(&pane.to_string())); + assert_eq!( + descriptor.env.get("TASKERS_PANE_ID"), + Some(&pane.to_string()) + ); assert!(descriptor.env.contains_key("TASKERS_SURFACE_ID")); } + + #[test] + fn revision_tracks_controller_mutations() { + let app_state = AppState::new( + AppModel::new("Main"), + PathBuf::from("/tmp/taskers-session.json"), + BackendChoice::Mock, + ShellLaunchSpec::fallback(), + ) + .expect("app state"); + + assert_eq!(app_state.revision(), 0); + + app_state + .dispatch(ControlCommand::QueryStatus { + query: ControlQuery::All, + }) + .expect("query"); + assert_eq!(app_state.revision(), 0); + + app_state + .dispatch(ControlCommand::CreateWorkspace { + label: "Docs".into(), + }) + .expect("create workspace"); + assert_eq!(app_state.revision(), 1); + } + + #[test] + fn embedded_backend_disables_mock_runtime() { + let model = AppModel::new("Main"); + let workspace = model.active_workspace().expect("workspace"); + let pane = workspace.panes.get(&workspace.active_pane).expect("pane"); + let surface_id = pane.active_surface; + + let app_state = AppState::new( + model, + PathBuf::from("/tmp/taskers-session.json"), + BackendChoice::GhosttyEmbedded, + ShellLaunchSpec::fallback(), + ) + .expect("app state"); + + assert_eq!(app_state.backend(), BackendChoice::GhosttyEmbedded); + assert_eq!(app_state.revision(), 0); + assert!(app_state.runtime().snapshot(surface_id).is_none()); + } } diff --git a/crates/taskers-domain/src/model.rs b/crates/taskers-domain/src/model.rs index 255dfef..2d42896 100644 --- a/crates/taskers-domain/src/model.rs +++ b/crates/taskers-domain/src/model.rs @@ -403,7 +403,8 @@ impl WorkspaceColumnRecord { fn normalize(&mut self, windows: &IndexMap) { self.width = self.width.max(MIN_WORKSPACE_WINDOW_WIDTH); - self.window_order.retain(|window_id| windows.contains_key(window_id)); + self.window_order + .retain(|window_id| windows.contains_key(window_id)); if !self.window_order.contains(&self.active_window) && let Some(window_id) = self.window_order.first() { @@ -581,7 +582,11 @@ impl Workspace { Direction::Up => self .columns .get_index(column_index) - .and_then(|(_, column)| window_index.checked_sub(1).and_then(|index| column.window_order.get(index))) + .and_then(|(_, column)| { + window_index + .checked_sub(1) + .and_then(|index| column.window_order.get(index)) + }) .copied(), Direction::Down => self .columns @@ -633,9 +638,8 @@ impl Workspace { let insert_index = index.min(self.columns.len()); let mut next = IndexMap::with_capacity(self.columns.len() + 1); let mut pending = Some(column); - for (current_index, (column_id, current_column)) in std::mem::take(&mut self.columns) - .into_iter() - .enumerate() + for (current_index, (column_id, current_column)) in + std::mem::take(&mut self.columns).into_iter().enumerate() { if current_index == insert_index && let Some(column) = pending.take() @@ -709,14 +713,17 @@ impl Workspace { let mut assigned = BTreeSet::new(); for column in self.columns.values_mut() { - column.window_order.retain(|window_id| assigned.insert(*window_id)); + column + .window_order + .retain(|window_id| assigned.insert(*window_id)); if !column.window_order.contains(&column.active_window) && let Some(window_id) = column.window_order.first() { column.active_window = *window_id; } } - self.columns.retain(|_, column| !column.window_order.is_empty()); + self.columns + .retain(|_, column| !column.window_order.is_empty()); self.append_missing_windows_to_columns(); if self.columns.is_empty() { @@ -1705,9 +1712,11 @@ impl AppModel { workspace .notifications .retain(|item| item.pane_id != pane_id); - if let Some(next_window_id) = - workspace.fallback_window_after_close(column_index, window_index, same_column_survived) - { + if let Some(next_window_id) = workspace.fallback_window_after_close( + column_index, + window_index, + same_column_survived, + ) { workspace.sync_active_from_window(next_window_id); } return Ok(()); @@ -2003,10 +2012,12 @@ mod tests { assert_eq!(workspace.active_pane, stacked_pane); assert_eq!(right_column.window_order.len(), 2); assert_ne!(workspace.active_window, first_window_id); - assert!(workspace - .columns - .values() - .any(|column| column.window_order == vec![first_window_id])); + assert!( + workspace + .columns + .values() + .any(|column| column.window_order == vec![first_window_id]) + ); let upper_window_id = right_column.window_order[0]; assert_eq!( workspace diff --git a/crates/taskers-ghostty/src/backend.rs b/crates/taskers-ghostty/src/backend.rs index 92742a9..3f56cbb 100644 --- a/crates/taskers-ghostty/src/backend.rs +++ b/crates/taskers-ghostty/src/backend.rs @@ -8,6 +8,7 @@ use thiserror::Error; pub enum BackendChoice { Auto, Ghostty, + GhosttyEmbedded, Mock, } @@ -58,6 +59,7 @@ impl TerminalBackend for DefaultBackend { let env_override = std::env::var("TASKERS_TERMINAL_BACKEND").ok(); let requested = match env_override.as_deref() { Some("ghostty") => BackendChoice::Ghostty, + Some("ghostty_embedded") | Some("ghostty-embedded") => BackendChoice::GhosttyEmbedded, Some("mock") => BackendChoice::Mock, _ => requested, }; @@ -70,6 +72,12 @@ impl TerminalBackend for DefaultBackend { availability: BackendAvailability::Fallback, notes: "Using placeholder terminal surfaces.".into(), }, + BackendChoice::GhosttyEmbedded => BackendProbe { + requested, + selected: BackendChoice::GhosttyEmbedded, + availability: embedded_ghostty_availability(), + notes: embedded_ghostty_notes(), + }, BackendChoice::Ghostty => BackendProbe { requested, selected: BackendChoice::Ghostty, @@ -115,6 +123,18 @@ fn ghostty_availability() -> BackendAvailability { } } +fn embedded_ghostty_availability() -> BackendAvailability { + #[cfg(target_os = "macos")] + { + BackendAvailability::Ready + } + + #[cfg(not(target_os = "macos"))] + { + BackendAvailability::Unavailable + } +} + fn ghostty_notes() -> String { let mut notes = String::from("Ghostty GTK bridge compiled in."); if let Some(path) = runtime_bridge_path() { @@ -130,6 +150,10 @@ fn ghostty_notes() -> String { notes } +fn embedded_ghostty_notes() -> String { + String::from("Embedded Ghostty surfaces require the native macOS host.") +} + #[cfg(test)] mod tests { use super::{BackendAvailability, BackendChoice, DefaultBackend, TerminalBackend}; @@ -144,4 +168,24 @@ mod tests { } } } + + #[test] + fn embedded_probe_stays_explicit() { + let probe = DefaultBackend::probe(BackendChoice::GhosttyEmbedded); + assert_eq!(probe.selected, BackendChoice::GhosttyEmbedded); + + #[cfg(target_os = "macos")] + assert_eq!(probe.availability, BackendAvailability::Ready); + + #[cfg(not(target_os = "macos"))] + assert_eq!(probe.availability, BackendAvailability::Unavailable); + } + + #[test] + fn env_override_accepts_hyphenated_embedded_backend() { + unsafe { std::env::set_var("TASKERS_TERMINAL_BACKEND", "ghostty-embedded") }; + let probe = DefaultBackend::probe(BackendChoice::Mock); + unsafe { std::env::remove_var("TASKERS_TERMINAL_BACKEND") }; + assert_eq!(probe.selected, BackendChoice::GhosttyEmbedded); + } } diff --git a/crates/taskers-macos-ffi/Cargo.toml b/crates/taskers-macos-ffi/Cargo.toml index f1f9da4..2afceba 100644 --- a/crates/taskers-macos-ffi/Cargo.toml +++ b/crates/taskers-macos-ffi/Cargo.toml @@ -13,13 +13,14 @@ name = "taskers_macos_ffi" crate-type = ["staticlib", "rlib"] [dependencies] +serde.workspace = true +serde_json.workspace = true taskers-core.workspace = true taskers-control = { version = "0.2.1", path = "../taskers-control" } taskers-domain = { version = "0.2.1", path = "../taskers-domain" } taskers-ghostty = { version = "0.2.1", path = "../taskers-ghostty" } taskers-paths.workspace = true taskers-runtime = { version = "0.2.1", path = "../taskers-runtime" } -serde_json.workspace = true [dev-dependencies] tempfile.workspace = true diff --git a/crates/taskers-macos-ffi/include/taskers_macos_ffi.h b/crates/taskers-macos-ffi/include/taskers_macos_ffi.h index 7636073..de15f50 100644 --- a/crates/taskers-macos-ffi/include/taskers_macos_ffi.h +++ b/crates/taskers-macos-ffi/include/taskers_macos_ffi.h @@ -6,6 +6,9 @@ typedef struct taskers_macos_core taskers_macos_core_t; +taskers_macos_core_t *taskers_macos_core_new_with_options_json( + const char *options_json +); taskers_macos_core_t *taskers_macos_core_new( const char *session_path, const char *socket_path, diff --git a/crates/taskers-macos-ffi/src/lib.rs b/crates/taskers-macos-ffi/src/lib.rs index 2f4bf8d..02051c9 100644 --- a/crates/taskers-macos-ffi/src/lib.rs +++ b/crates/taskers-macos-ffi/src/lib.rs @@ -6,18 +6,32 @@ use std::{ str::FromStr, }; +use serde::Deserialize; use taskers_control::{ControlCommand, default_socket_path}; use taskers_core::{AppState, default_session_path, load_or_bootstrap}; use taskers_domain::{PaneId, WorkspaceId}; -use taskers_ghostty::{BackendChoice, DefaultBackend, TerminalBackend}; +use taskers_ghostty::BackendChoice; use taskers_runtime::{ShellLaunchSpec, install_shell_integration}; pub struct TaskersMacosCore { app_state: AppState, - revision: u64, _socket_path: PathBuf, } +#[derive(Debug, Default, Deserialize)] +struct CoreOptions { + #[serde(default)] + session_path: Option, + #[serde(default)] + socket_path: Option, + #[serde(default)] + configured_shell: Option, + #[serde(default)] + demo: bool, + #[serde(default)] + backend: Option, +} + thread_local! { static LAST_ERROR: RefCell> = const { RefCell::new(None) }; } @@ -35,19 +49,14 @@ fn clear_last_error() { } impl TaskersMacosCore { - fn new( - session_path: Option, - socket_path: Option, - configured_shell: Option<&str>, - demo: bool, - ) -> Result { - let session_path = session_path.unwrap_or_else(default_session_path); - let socket_path = socket_path.unwrap_or_else(default_socket_path); - let model = load_or_bootstrap(&session_path, demo) + fn new_with_options(options: CoreOptions) -> Result { + let session_path = options.session_path.unwrap_or_else(default_session_path); + let socket_path = options.socket_path.unwrap_or_else(default_socket_path); + let model = load_or_bootstrap(&session_path, options.demo) .map_err(|error| format!("failed to initialize session state: {error}"))?; let (mut shell_launch, shell_integration_error) = - match install_shell_integration(configured_shell) { + match install_shell_integration(options.configured_shell.as_deref()) { Ok(integration) => (integration.launch_spec(), None), Err(error) => ( ShellLaunchSpec::fallback(), @@ -58,12 +67,7 @@ impl TaskersMacosCore { .env .insert("TASKERS_SOCKET".into(), socket_path.display().to_string()); - let probe = DefaultBackend::probe(BackendChoice::Auto); - let backend_choice = if probe.selected == BackendChoice::Ghostty { - BackendChoice::Ghostty - } else { - BackendChoice::Mock - }; + let backend_choice = options.backend.unwrap_or(BackendChoice::GhosttyEmbedded); let app_state = AppState::new(model, session_path, backend_choice, shell_launch) .map_err(|error| format!("failed to create shared app state: {error}"))?; @@ -75,11 +79,25 @@ impl TaskersMacosCore { Ok(Self { app_state, - revision: 0, _socket_path: socket_path, }) } + fn new( + session_path: Option, + socket_path: Option, + configured_shell: Option<&str>, + demo: bool, + ) -> Result { + Self::new_with_options(CoreOptions { + session_path, + socket_path, + configured_shell: configured_shell.map(str::to_string), + demo, + backend: Some(BackendChoice::GhosttyEmbedded), + }) + } + fn snapshot_json(&self) -> Result { serde_json::to_string(&self.app_state.snapshot_model()) .map_err(|error| format!("failed to serialize snapshot: {error}")) @@ -92,16 +110,11 @@ impl TaskersMacosCore { .app_state .dispatch(command) .map_err(|error| format!("command failed: {error}"))?; - self.revision = self.revision.saturating_add(1); serde_json::to_string(&response) .map_err(|error| format!("failed to serialize response: {error}")) } - fn surface_descriptor_json( - &self, - workspace_id: &str, - pane_id: &str, - ) -> Result { + fn surface_descriptor_json(&self, workspace_id: &str, pane_id: &str) -> Result { let workspace_id = WorkspaceId::from_str(workspace_id) .map_err(|error| format!("invalid workspace id: {error}"))?; let pane_id = @@ -146,6 +159,14 @@ fn optional_string_from_ptr(value: *const c_char) -> Result, Stri } } +fn options_from_json_ptr(value: *const c_char) -> Result { + match optional_string_from_ptr(value)? { + Some(value) => serde_json::from_str(&value) + .map_err(|error| format!("failed to decode options JSON: {error}")), + None => Ok(CoreOptions::default()), + } +} + fn string_into_ptr(value: String) -> *mut c_char { match CString::new(value) { Ok(value) => value.into_raw(), @@ -153,6 +174,27 @@ fn string_into_ptr(value: String) -> *mut c_char { } } +#[unsafe(no_mangle)] +pub extern "C" fn taskers_macos_core_new_with_options_json( + options_json: *const c_char, +) -> *mut TaskersMacosCore { + let options = match options_from_json_ptr(options_json) { + Ok(value) => value, + Err(error) => { + set_last_error(error); + return ptr::null_mut(); + } + }; + + match TaskersMacosCore::new_with_options(options) { + Ok(core) => Box::into_raw(Box::new(core)), + Err(error) => { + set_last_error(error); + ptr::null_mut() + } + } +} + fn with_core_mut( core: *mut TaskersMacosCore, f: impl FnOnce(&mut TaskersMacosCore) -> Result, @@ -226,12 +268,7 @@ pub extern "C" fn taskers_macos_core_new( } }; - match TaskersMacosCore::new( - session_path, - socket_path, - configured_shell.as_deref(), - demo, - ) { + match TaskersMacosCore::new(session_path, socket_path, configured_shell.as_deref(), demo) { Ok(core) => Box::into_raw(Box::new(core)), Err(error) => { set_last_error(error); @@ -252,9 +289,7 @@ pub extern "C" fn taskers_macos_core_free(core: *mut TaskersMacosCore) { } #[unsafe(no_mangle)] -pub extern "C" fn taskers_macos_core_snapshot_json( - core: *const TaskersMacosCore, -) -> *mut c_char { +pub extern "C" fn taskers_macos_core_snapshot_json(core: *const TaskersMacosCore) -> *mut c_char { with_core(core, TaskersMacosCore::snapshot_json) .map(string_into_ptr) .unwrap_or(ptr::null_mut()) @@ -311,14 +346,16 @@ pub extern "C" fn taskers_macos_core_surface_descriptor_json( } }; - with_core(core, |core| core.surface_descriptor_json(&workspace_id, &pane_id)) - .map(string_into_ptr) - .unwrap_or(ptr::null_mut()) + with_core(core, |core| { + core.surface_descriptor_json(&workspace_id, &pane_id) + }) + .map(string_into_ptr) + .unwrap_or(ptr::null_mut()) } #[unsafe(no_mangle)] pub extern "C" fn taskers_macos_core_revision(core: *const TaskersMacosCore) -> u64 { - with_core(core, |core| Ok(core.revision)).unwrap_or(0) + with_core(core, |core| Ok(core.app_state.revision())).unwrap_or(0) } #[unsafe(no_mangle)] @@ -342,10 +379,10 @@ pub extern "C" fn taskers_macos_string_free(value: *mut c_char) { #[cfg(test)] mod tests { - use serde_json::Value; + use serde_json::{Value, json}; use tempfile::tempdir; - use super::TaskersMacosCore; + use super::{CoreOptions, TaskersMacosCore}; #[test] fn core_roundtrips_snapshot_and_dispatch_json() { @@ -372,7 +409,7 @@ mod tests { response.get("status").and_then(Value::as_str), Some("workspace_created") ); - assert_eq!(core.revision, 1); + assert_eq!(core.app_state.revision(), 1); } #[test] @@ -390,7 +427,11 @@ mod tests { let model = core.app_state.snapshot_model(); let workspace_id = model.active_workspace_id().expect("workspace").to_string(); - let pane_id = model.active_workspace().expect("workspace").active_pane.to_string(); + let pane_id = model + .active_workspace() + .expect("workspace") + .active_pane + .to_string(); let descriptor = core .surface_descriptor_json(&workspace_id, &pane_id) @@ -407,7 +448,33 @@ mod tests { .and_then(Value::as_object) .and_then(|env| env.get("TASKERS_SOCKET")) .and_then(Value::as_str), - Some(temp.path().join("taskers.sock").to_str().expect("utf-8 socket path")) + Some( + temp.path() + .join("taskers.sock") + .to_str() + .expect("utf-8 socket path") + ) + ); + } + + #[test] + fn options_json_supports_explicit_mock_backend() { + let temp = tempdir().expect("tempdir"); + let options = serde_json::from_value::(json!({ + "session_path": temp.path().join("session.json"), + "socket_path": temp.path().join("taskers.sock"), + "configured_shell": "/bin/sh", + "backend": "mock" + })) + .expect("options"); + let core = TaskersMacosCore::new_with_options(options).expect("core"); + + assert_eq!(core.app_state.revision(), 0); + assert!( + core.app_state + .snapshot_model() + .active_workspace_id() + .is_some() ); } } diff --git a/crates/taskers-paths/src/lib.rs b/crates/taskers-paths/src/lib.rs index ebd7cbf..7970ff0 100644 --- a/crates/taskers-paths/src/lib.rs +++ b/crates/taskers-paths/src/lib.rs @@ -373,7 +373,9 @@ mod tests { ); assert_eq!( paths.shell_runtime_dir(), - &PathBuf::from(format!("/Users/notes/Library/Caches/{APP_ID}/runtime/shell")) + &PathBuf::from(format!( + "/Users/notes/Library/Caches/{APP_ID}/runtime/shell" + )) ); } @@ -390,10 +392,22 @@ mod tests { }; let paths = TaskersPaths::from_env(HostPlatform::Linux, &env); - assert_eq!(paths.config_path(), &PathBuf::from("/tmp/config/taskers/config.json")); - assert_eq!(paths.session_path(), &PathBuf::from("/tmp/state/taskers/session.json")); - assert_eq!(paths.ghostty_runtime_dir(), &PathBuf::from("/tmp/data/taskers/ghostty")); - assert_eq!(paths.shell_runtime_dir(), &PathBuf::from("/tmp/runtime/taskers/shell")); + assert_eq!( + paths.config_path(), + &PathBuf::from("/tmp/config/taskers/config.json") + ); + assert_eq!( + paths.session_path(), + &PathBuf::from("/tmp/state/taskers/session.json") + ); + assert_eq!( + paths.ghostty_runtime_dir(), + &PathBuf::from("/tmp/data/taskers/ghostty") + ); + assert_eq!( + paths.shell_runtime_dir(), + &PathBuf::from("/tmp/runtime/taskers/shell") + ); assert_eq!(paths.socket_path(), &PathBuf::from("/tmp/taskers.sock")); } @@ -412,7 +426,10 @@ mod tests { assert_eq!(paths.config_path(), &PathBuf::from("/work/config.json")); assert_eq!(paths.session_path(), &PathBuf::from("/work/session.json")); assert_eq!(paths.socket_path(), &PathBuf::from("/work/control.sock")); - assert_eq!(paths.shell_runtime_dir(), &PathBuf::from("/work/runtime/shell")); + assert_eq!( + paths.shell_runtime_dir(), + &PathBuf::from("/work/runtime/shell") + ); assert_eq!(paths.ghostty_runtime_dir(), &PathBuf::from("/work/ghostty")); } } diff --git a/macos/TaskersMac/Info.plist b/macos/TaskersMac/Info.plist new file mode 100644 index 0000000..c5a72fb --- /dev/null +++ b/macos/TaskersMac/Info.plist @@ -0,0 +1,26 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + Taskers + CFBundlePackageType + APPL + CFBundleShortVersionString + 0.2.1 + CFBundleVersion + 1 + LSMinimumSystemVersion + 14.0 + NSHighResolutionCapable + + + diff --git a/macos/TaskersMac/Sources/TaskersCoreBridge.swift b/macos/TaskersMac/Sources/TaskersCoreBridge.swift new file mode 100644 index 0000000..e1c397e --- /dev/null +++ b/macos/TaskersMac/Sources/TaskersCoreBridge.swift @@ -0,0 +1,150 @@ +import Foundation + +struct TaskersCoreOptions: Encodable { + let sessionPath: String? + let socketPath: String? + let configuredShell: String? + let demo: Bool + let backend: String + + enum CodingKeys: String, CodingKey { + case sessionPath = "session_path" + case socketPath = "socket_path" + case configuredShell = "configured_shell" + case demo + case backend + } +} + +struct TaskersSurfaceDescriptor: Decodable { + let cols: UInt16 + let rows: UInt16 + let cwd: String? + let title: String? + let commandArgv: [String] + let env: [String: String] + + enum CodingKeys: String, CodingKey { + case cols + case rows + case cwd + case title + case commandArgv = "command_argv" + case env + } +} + +enum TaskersCoreBridgeError: LocalizedError { + case createFailed(String) + case callFailed(String) + case invalidResponse(String) + + var errorDescription: String? { + switch self { + case .createFailed(let message): + return message + case .callFailed(let message): + return message + case .invalidResponse(let message): + return message + } + } +} + +final class TaskersCoreBridge { + private let handle: OpaquePointer + private let decoder = JSONDecoder() + private let encoder = JSONEncoder() + + init(options: TaskersCoreOptions) throws { + let encoded = try encoder.encode(options) + guard + let json = String(data: encoded, encoding: .utf8), + let created = json.withCString({ taskers_macos_core_new_with_options_json($0) }) + else { + throw TaskersCoreBridgeError.createFailed(Self.lastError()) + } + + handle = created + } + + deinit { + taskers_macos_core_free(handle) + } + + var revision: UInt64 { + taskers_macos_core_revision(handle) + } + + func snapshot() throws -> TaskersSnapshot { + let json = try callString { + taskers_macos_core_snapshot_json(handle) + } + return try decode(TaskersSnapshot.self, from: json) + } + + @discardableResult + func dispatch(command: [String: Any]) throws -> [String: Any] { + let data = try JSONSerialization.data(withJSONObject: command, options: [.sortedKeys]) + guard let json = String(data: data, encoding: .utf8) else { + throw TaskersCoreBridgeError.invalidResponse("failed to encode command JSON") + } + + let response = try callString { + json.withCString { taskers_macos_core_dispatch_json(handle, $0) } + } + + guard + let value = try JSONSerialization.jsonObject(with: Data(response.utf8)) as? [String: Any] + else { + throw TaskersCoreBridgeError.invalidResponse("failed to decode dispatch response") + } + + return value + } + + func surfaceDescriptor(workspaceId: String, paneId: String) throws -> TaskersSurfaceDescriptor { + let json = try callString { + workspaceId.withCString { workspacePtr in + paneId.withCString { panePtr in + taskers_macos_core_surface_descriptor_json(handle, workspacePtr, panePtr) + } + } + } + + return try decode(TaskersSurfaceDescriptor.self, from: json) + } + + private func decode(_ type: T.Type, from string: String) throws -> T { + do { + return try decoder.decode(T.self, from: Data(string.utf8)) + } catch { + throw TaskersCoreBridgeError.invalidResponse(error.localizedDescription) + } + } + + private func callString(_ body: () -> UnsafeMutablePointer?) throws -> String { + guard let pointer = body() else { + throw TaskersCoreBridgeError.callFailed(Self.lastError()) + } + + defer { + taskers_macos_string_free(pointer) + } + + return String(cString: pointer) + } + + private static func lastError() -> String { + guard let pointer = taskers_macos_last_error_message() else { + return "unknown error" + } + + defer { + taskers_macos_string_free(pointer) + } + + let message = String(cString: pointer) + return message.isEmpty ? "unknown error" : message + } +} diff --git a/macos/TaskersMac/Sources/TaskersEnvironment.swift b/macos/TaskersMac/Sources/TaskersEnvironment.swift new file mode 100644 index 0000000..407c015 --- /dev/null +++ b/macos/TaskersMac/Sources/TaskersEnvironment.swift @@ -0,0 +1,35 @@ +import Foundation + +enum TaskersEnvironment { + static var isSmokeTestEnabled: Bool { + ProcessInfo.processInfo.environment["TASKERS_SMOKE_TEST"] == "1" + } + + static func configureBundledPaths() { + guard let resourceURL = Bundle.main.resourceURL else { + return + } + + setPath("GHOSTTY_RESOURCES_DIR", url: resourceURL.appendingPathComponent("ghostty")) + setPath("TERMINFO", url: resourceURL.appendingPathComponent("terminfo")) + setPath("TASKERS_CTL_PATH", url: resourceURL.appendingPathComponent("bin/taskersctl")) + } + + static func defaultCoreOptions() -> TaskersCoreOptions { + TaskersCoreOptions( + sessionPath: nil, + socketPath: nil, + configuredShell: nil, + demo: isSmokeTestEnabled, + backend: "ghostty_embedded" + ) + } + + private static func setPath(_ key: String, url: URL) { + guard FileManager.default.fileExists(atPath: url.path) else { + return + } + + setenv(key, url.path, 1) + } +} diff --git a/macos/TaskersMac/Sources/TaskersGhosttyHost.swift b/macos/TaskersMac/Sources/TaskersGhosttyHost.swift new file mode 100644 index 0000000..17e0016 --- /dev/null +++ b/macos/TaskersMac/Sources/TaskersGhosttyHost.swift @@ -0,0 +1,167 @@ +import AppKit +import Foundation + +enum TaskersGhosttyHostError: LocalizedError { + case configurationFailed + case appCreationFailed + + var errorDescription: String? { + switch self { + case .configurationFailed: + return "failed to initialize Ghostty configuration" + case .appCreationFailed: + return "failed to initialize the embedded Ghostty runtime" + } + } +} + +final class TaskersGhosttyHost: NSObject { + private var config: ghostty_config_t? + private var app: ghostty_app_t? + private let surfaces = NSHashTable.weakObjects() + + var onSurfaceClosed: ((String, String, String) -> Void)? + + override init() { + super.init() + } + + deinit { + if let app { + ghostty_app_free(app) + } + if let config { + ghostty_config_free(config) + } + } + + func bootstrap() throws { + guard config == nil, app == nil else { + return + } + + guard let config = ghostty_config_new() else { + throw TaskersGhosttyHostError.configurationFailed + } + + ghostty_config_load_default_files(config) + ghostty_config_load_recursive_files(config) + ghostty_config_finalize(config) + + var runtime = ghostty_runtime_config_s( + userdata: Unmanaged.passUnretained(self).toOpaque(), + supports_selection_clipboard: false, + wakeup_cb: { userdata in + TaskersGhosttyHost.from(userdata)?.scheduleTick() + }, + action_cb: { _, target, action in + TaskersGhosttyHost.handleAction(target: target, action: action) + }, + read_clipboard_cb: { _, _, _ in }, + confirm_read_clipboard_cb: { _, _, _, _ in }, + write_clipboard_cb: { _, _, _, _, _ in }, + close_surface_cb: { userdata, _ in + TaskersTerminalView.from(userdata: userdata)?.handleSurfaceClosed() + } + ) + + guard let app = ghostty_app_new(&runtime, config) else { + ghostty_config_free(config) + throw TaskersGhosttyHostError.appCreationFailed + } + + self.config = config + self.app = app + ghostty_app_set_focus(app, NSApp.isActive) + } + + func makeSurface( + workspaceID: String, + paneID: String, + surfaceID: String, + descriptor: TaskersSurfaceDescriptor + ) throws -> TaskersTerminalView { + try bootstrap() + guard let app else { + throw TaskersGhosttyHostError.appCreationFailed + } + + let view = try TaskersTerminalView( + host: self, + app: app, + workspaceID: workspaceID, + paneID: paneID, + surfaceID: surfaceID, + descriptor: descriptor + ) + registerSurface(view) + return view + } + + func registerSurface(_ surface: TaskersTerminalView) { + surfaces.add(surface) + } + + func unregisterSurface(_ surface: TaskersTerminalView) { + surfaces.remove(surface) + } + + func setFocused(_ focused: Bool) { + guard let app else { + return + } + + ghostty_app_set_focus(app, focused) + } + + private func scheduleTick() { + DispatchQueue.main.async { [weak self] in + self?.tick() + } + } + + private func tick() { + guard let app else { + return + } + + ghostty_app_tick(app) + for view in surfaces.allObjects { + view.refresh() + } + } + + fileprivate func surfaceDidClose(workspaceID: String, paneID: String, surfaceID: String) { + onSurfaceClosed?(workspaceID, paneID, surfaceID) + } + + private static func handleAction(target: ghostty_target_s, action: ghostty_action_s) -> Bool { + guard target.tag == GHOSTTY_TARGET_SURFACE else { + return false + } + + guard let surface = target.target.surface else { + return false + } + + guard let view = TaskersTerminalView.from(surface: surface) else { + return false + } + + switch action.tag { + case GHOSTTY_ACTION_SHOW_CHILD_EXITED: + view.handleChildExited(exitCode: action.action.child_exited.exit_code) + return true + default: + return false + } + } + + private static func from(_ userdata: UnsafeMutableRawPointer?) -> TaskersGhosttyHost? { + guard let userdata else { + return nil + } + + return Unmanaged.fromOpaque(userdata).takeUnretainedValue() + } +} diff --git a/macos/TaskersMac/Sources/TaskersMac-Bridging-Header.h b/macos/TaskersMac/Sources/TaskersMac-Bridging-Header.h new file mode 100644 index 0000000..eaa63f6 --- /dev/null +++ b/macos/TaskersMac/Sources/TaskersMac-Bridging-Header.h @@ -0,0 +1,2 @@ +#import "../../../crates/taskers-macos-ffi/include/taskers_macos_ffi.h" +#import "../../../vendor/ghostty/include/ghostty.h" diff --git a/macos/TaskersMac/Sources/TaskersSnapshot.swift b/macos/TaskersMac/Sources/TaskersSnapshot.swift new file mode 100644 index 0000000..bd76292 --- /dev/null +++ b/macos/TaskersMac/Sources/TaskersSnapshot.swift @@ -0,0 +1,191 @@ +import Foundation + +struct OrderedMap: Decodable { + let elements: [(String, Value)] + private let storage: [String: Value] + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: DynamicCodingKey.self) + var elements: [(String, Value)] = [] + var storage: [String: Value] = [:] + storage.reserveCapacity(container.allKeys.count) + + for key in container.allKeys { + let value = try container.decode(Value.self, forKey: key) + elements.append((key.stringValue, value)) + storage[key.stringValue] = value + } + + self.elements = elements + self.storage = storage + } + + subscript(key: String) -> Value? { + storage[key] + } + + struct DynamicCodingKey: CodingKey { + var stringValue: String + var intValue: Int? + + init?(stringValue: String) { + self.stringValue = stringValue + } + + init?(intValue: Int) { + self.stringValue = String(intValue) + self.intValue = intValue + } + } +} + +struct TaskersSnapshot: Decodable { + let activeWindow: String + let windows: OrderedMap + let workspaces: OrderedMap + + enum CodingKeys: String, CodingKey { + case activeWindow = "active_window" + case windows + case workspaces + } + + var activeWorkspace: TaskersWorkspace? { + guard let window = windows[activeWindow] else { + return nil + } + + return workspaces[window.activeWorkspace] + } + + var liveSurfaceIDs: Set { + var surfaceIDs: Set = [] + for (_, workspace) in workspaces.elements { + for (_, pane) in workspace.panes.elements { + surfaceIDs.insert(pane.activeSurface) + } + } + return surfaceIDs + } +} + +struct TaskersWindowRecord: Decodable { + let id: String + let workspaceOrder: [String] + let activeWorkspace: String + + enum CodingKeys: String, CodingKey { + case id + case workspaceOrder = "workspace_order" + case activeWorkspace = "active_workspace" + } +} + +struct TaskersWorkspace: Decodable { + let id: String + let label: String + let columns: OrderedMap + let windows: OrderedMap + let activeWindow: String + let panes: OrderedMap + let activePane: String + + enum CodingKeys: String, CodingKey { + case id + case label + case columns + case windows + case activeWindow = "active_window" + case panes + case activePane = "active_pane" + } +} + +struct TaskersWorkspaceColumn: Decodable { + let id: String + let width: Int + let windowOrder: [String] + let activeWindow: String + + enum CodingKeys: String, CodingKey { + case id + case width + case windowOrder = "window_order" + case activeWindow = "active_window" + } +} + +struct TaskersWorkspaceWindow: Decodable { + let id: String + let height: Int + let layout: TaskersLayoutNode + let activePane: String + + enum CodingKeys: String, CodingKey { + case id + case height + case layout + case activePane = "active_pane" + } +} + +struct TaskersPane: Decodable { + let id: String + let surfaces: OrderedMap + let activeSurface: String + + enum CodingKeys: String, CodingKey { + case id + case surfaces + case activeSurface = "active_surface" + } +} + +struct TaskersSurface: Decodable { + let id: String + let metadata: TaskersSurfaceMetadata +} + +struct TaskersSurfaceMetadata: Decodable { + let title: String? + let cwd: String? +} + +enum TaskersSplitAxis: String, Decodable { + case horizontal + case vertical +} + +enum TaskersLayoutNode: Decodable { + case leaf(paneID: String) + case split(axis: TaskersSplitAxis, ratio: UInt16, first: TaskersLayoutNode, second: TaskersLayoutNode) + + enum CodingKeys: String, CodingKey { + case kind + case paneID = "pane_id" + case axis + case ratio + case first + case second + } + + enum NodeKind: String, Decodable { + case leaf + case split + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + switch try container.decode(NodeKind.self, forKey: .kind) { + case .leaf: + self = .leaf(paneID: try container.decode(String.self, forKey: .paneID)) + case .split: + self = .split( + axis: try container.decode(TaskersSplitAxis.self, forKey: .axis), + ratio: try container.decode(UInt16.self, forKey: .ratio), + first: try container.decode(TaskersLayoutNode.self, forKey: .first), + second: try container.decode(TaskersLayoutNode.self, forKey: .second) + ) + } + } +} diff --git a/macos/TaskersMac/Sources/TaskersTerminalView.swift b/macos/TaskersMac/Sources/TaskersTerminalView.swift new file mode 100644 index 0000000..13521e0 --- /dev/null +++ b/macos/TaskersMac/Sources/TaskersTerminalView.swift @@ -0,0 +1,343 @@ +import AppKit +import Foundation + +final class TaskersTerminalView: NSView { + let workspaceID: String + let paneID: String + let surfaceID: String + + private weak var host: TaskersGhosttyHost? + private var surface: ghostty_surface_t? + private var commandString: String + + override var acceptsFirstResponder: Bool { + true + } + + init( + host: TaskersGhosttyHost, + app: ghostty_app_t, + workspaceID: String, + paneID: String, + surfaceID: String, + descriptor: TaskersSurfaceDescriptor + ) throws { + self.host = host + self.workspaceID = workspaceID + self.paneID = paneID + self.surfaceID = surfaceID + self.commandString = Self.commandString(for: descriptor.commandArgv) + + super.init(frame: NSRect(x: 0, y: 0, width: 640, height: 420)) + wantsLayer = true + layer?.backgroundColor = NSColor.black.cgColor + + self.surface = try Self.createSurface( + view: self, + app: app, + descriptor: descriptor, + commandString: commandString + ) + updateSurfaceMetrics() + } + + required init?(coder: NSCoder) { + return nil + } + + deinit { + if let surface { + ghostty_surface_free(surface) + } + host?.unregisterSurface(self) + } + + override func becomeFirstResponder() -> Bool { + setFocused(true) + return true + } + + override func resignFirstResponder() -> Bool { + setFocused(false) + return true + } + + override func draw(_ dirtyRect: NSRect) { + super.draw(dirtyRect) + + guard let surface else { + return + } + + ghostty_surface_draw(surface) + } + + override func layout() { + super.layout() + updateSurfaceMetrics() + } + + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + updateSurfaceMetrics() + } + + override func viewDidChangeBackingProperties() { + super.viewDidChangeBackingProperties() + updateSurfaceMetrics() + } + + override func keyDown(with event: NSEvent) { + if let characters = event.characters, !characters.isEmpty { + sendText(characters) + } else { + super.keyDown(with: event) + } + } + + override func mouseDown(with event: NSEvent) { + window?.makeFirstResponder(self) + sendMouseButton(event, action: GHOSTTY_MOUSE_PRESS) + } + + override func mouseUp(with event: NSEvent) { + sendMouseButton(event, action: GHOSTTY_MOUSE_RELEASE) + } + + override func mouseDragged(with event: NSEvent) { + sendMousePosition(event) + } + + override func mouseMoved(with event: NSEvent) { + sendMousePosition(event) + } + + override func scrollWheel(with event: NSEvent) { + guard let surface else { + return + } + + let mods = Int32(Self.modifiers(from: event.modifierFlags).rawValue) + ghostty_surface_mouse_scroll( + surface, + event.scrollingDeltaX, + event.scrollingDeltaY, + mods + ) + refresh() + } + + func refresh() { + needsDisplay = true + } + + func handleChildExited(exitCode: UInt32) { + _ = exitCode + host?.surfaceDidClose(workspaceID: workspaceID, paneID: paneID, surfaceID: surfaceID) + } + + func handleSurfaceClosed() { + host?.surfaceDidClose(workspaceID: workspaceID, paneID: paneID, surfaceID: surfaceID) + } + + private func setFocused(_ focused: Bool) { + guard let surface else { + return + } + + ghostty_surface_set_focus(surface, focused) + refresh() + } + + private func updateSurfaceMetrics() { + guard let surface else { + return + } + + let backingRect = convertToBacking(bounds) + let width = UInt32(max(1, Int(backingRect.width))) + let height = UInt32(max(1, Int(backingRect.height))) + ghostty_surface_set_size(surface, width, height) + + let scale = window?.backingScaleFactor ?? window?.screen?.backingScaleFactor ?? NSScreen.main?.backingScaleFactor ?? 2.0 + ghostty_surface_set_content_scale(surface, scale, scale) + } + + private func sendText(_ text: String) { + guard let surface else { + return + } + + let length = text.utf8.count + text.withCString { pointer in + ghostty_surface_text(surface, pointer, UInt(length)) + } + refresh() + } + + private func sendMouseButton(_ event: NSEvent, action: ghostty_input_mouse_state_e) { + guard let surface else { + return + } + + ghostty_surface_mouse_button( + surface, + action, + Self.mouseButton(from: event.buttonNumber), + Self.modifiers(from: event.modifierFlags) + ) + sendMousePosition(event) + } + + private func sendMousePosition(_ event: NSEvent) { + guard let surface else { + return + } + + let point = convert(event.locationInWindow, from: nil) + ghostty_surface_mouse_pos( + surface, + Double(point.x), + Double(point.y), + Self.modifiers(from: event.modifierFlags) + ) + refresh() + } + + private static func createSurface( + view: TaskersTerminalView, + app: ghostty_app_t, + descriptor: TaskersSurfaceDescriptor, + commandString: String + ) throws -> ghostty_surface_t { + let scale = NSScreen.main?.backingScaleFactor ?? 2.0 + var config = ghostty_surface_config_new() + config.platform_tag = GHOSTTY_PLATFORM_MACOS + config.platform = ghostty_platform_u( + macos: ghostty_platform_macos_s(nsview: Unmanaged.passUnretained(view).toOpaque()) + ) + config.userdata = Unmanaged.passUnretained(view).toOpaque() + config.scale_factor = scale + config.context = GHOSTTY_SURFACE_CONTEXT_SPLIT + + return try withCString(descriptor.cwd) { workingDirectory in + config.working_directory = workingDirectory + return try commandString.withCString { command in + config.command = command + return try withEnvironment(descriptor.env) { envVars, count in + config.env_vars = envVars + config.env_var_count = count + guard let surface = ghostty_surface_new(app, &config) else { + throw TaskersGhosttyHostError.appCreationFailed + } + return surface + } + } + } + } + + private static func commandString(for argv: [String]) -> String { + argv.map(shellQuote).joined(separator: " ") + } + + private static func shellQuote(_ value: String) -> String { + if value.isEmpty { + return "''" + } + + let allowed = CharacterSet(charactersIn: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._/:") + if value.unicodeScalars.allSatisfy({ allowed.contains($0) }) { + return value + } + + return "'" + value.replacingOccurrences(of: "'", with: "'\"'\"'") + "'" + } + + private static func withCString(_ value: String?, body: (UnsafePointer?) throws -> T) rethrows -> T { + guard let value else { + return try body(nil) + } + + return try value.withCString(body) + } + + private static func withEnvironment( + _ environment: [String: String], + body: (UnsafeMutablePointer?, Int) throws -> T + ) rethrows -> T { + let entries = Array(environment) + return try withCStringPairs(entries) { envVars in + try envVars.withUnsafeMutableBufferPointer { buffer in + try body(buffer.baseAddress, buffer.count) + } + } + } + + private static func withCStringPairs( + _ entries: [(key: String, value: String)], + body: ([ghostty_env_var_s]) throws -> T + ) rethrows -> T { + func recurse( + _ index: Int, + _ envVars: inout [ghostty_env_var_s], + _ body: ([ghostty_env_var_s]) throws -> T + ) rethrows -> T { + if index == entries.count { + return try body(envVars) + } + + let entry = entries[index] + return try entry.key.withCString { keyPointer in + try entry.value.withCString { valuePointer in + envVars.append(ghostty_env_var_s(key: keyPointer, value: valuePointer)) + defer { envVars.removeLast() } + return try recurse(index + 1, &envVars, body) + } + } + } + + var envVars: [ghostty_env_var_s] = [] + envVars.reserveCapacity(entries.count) + return try recurse(0, &envVars, body) + } + + private static func mouseButton(from buttonNumber: Int) -> ghostty_input_mouse_button_e { + switch buttonNumber { + case 1: + return GHOSTTY_MOUSE_RIGHT + case 2: + return GHOSTTY_MOUSE_MIDDLE + default: + return GHOSTTY_MOUSE_LEFT + } + } + + private static func modifiers(from flags: NSEvent.ModifierFlags) -> ghostty_input_mods_e { + var mods = Int32(GHOSTTY_MODS_NONE.rawValue) + if flags.contains(.shift) { + mods |= Int32(GHOSTTY_MODS_SHIFT.rawValue) + } + if flags.contains(.control) { + mods |= Int32(GHOSTTY_MODS_CTRL.rawValue) + } + if flags.contains(.option) { + mods |= Int32(GHOSTTY_MODS_ALT.rawValue) + } + if flags.contains(.command) { + mods |= Int32(GHOSTTY_MODS_SUPER.rawValue) + } + return ghostty_input_mods_e(mods) + } + + static func from(surface: ghostty_surface_t) -> TaskersTerminalView? { + from(userdata: ghostty_surface_userdata(surface)) + } + + static func from(userdata: UnsafeMutableRawPointer?) -> TaskersTerminalView? { + guard let userdata else { + return nil + } + + return Unmanaged.fromOpaque(userdata).takeUnretainedValue() + } +} diff --git a/macos/TaskersMac/Sources/TaskersWorkspaceController.swift b/macos/TaskersMac/Sources/TaskersWorkspaceController.swift new file mode 100644 index 0000000..8cace14 --- /dev/null +++ b/macos/TaskersMac/Sources/TaskersWorkspaceController.swift @@ -0,0 +1,241 @@ +import AppKit +import Foundation + +final class WeightedSplitView: NSSplitView { + private let weights: [CGFloat] + + init(isVertical: Bool, weights: [CGFloat]) { + self.weights = weights + super.init(frame: .zero) + self.isVertical = isVertical + dividerStyle = .thin + translatesAutoresizingMaskIntoConstraints = false + } + + required init?(coder: NSCoder) { + return nil + } + + override func layout() { + super.layout() + guard arrangedSubviews.count > 1 else { + return + } + + let totalWeight = max(weights.reduce(0, +), 1) + let dividerCount = CGFloat(arrangedSubviews.count - 1) + let available = (isVertical ? bounds.width : bounds.height) - (dividerThickness * dividerCount) + + var consumed: CGFloat = 0 + for index in 0..<(arrangedSubviews.count - 1) { + consumed += available * (weights[index] / totalWeight) + setPosition(consumed + dividerThickness * CGFloat(index), ofDividerAt: index) + } + } +} + +final class TaskersWorkspaceController: NSWindowController { + private let core: TaskersCoreBridge + private let ghosttyHost: TaskersGhosttyHost + private var surfaceRegistry: [String: TaskersTerminalView] = [:] + private var pollTimer: Timer? + private var lastRevision: UInt64? + + var surfaceCount: Int { + surfaceRegistry.count + } + + var lastRenderedSurfaceIDs: Set { + Set(surfaceRegistry.keys) + } + + init(core: TaskersCoreBridge, ghosttyHost: TaskersGhosttyHost) { + self.core = core + self.ghosttyHost = ghosttyHost + let window = NSWindow( + contentRect: NSRect(x: 80, y: 80, width: 1380, height: 900), + styleMask: [.titled, .closable, .miniaturizable, .resizable], + backing: .buffered, + defer: false + ) + window.title = "Taskers" + window.isReleasedWhenClosed = false + super.init(window: window) + ghosttyHost.onSurfaceClosed = { [weak self] workspaceID, paneID, surfaceID in + self?.closeSurface(workspaceID: workspaceID, paneID: paneID, surfaceID: surfaceID) + } + } + + required init?(coder: NSCoder) { + return nil + } + + deinit { + pollTimer?.invalidate() + } + + func start() throws { + try refresh(force: true) + showWindow(nil) + + pollTimer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { [weak self] _ in + try? self?.refresh(force: false) + } + } + + func refresh(force: Bool) throws { + let currentRevision = core.revision + if !force, lastRevision == currentRevision { + return + } + + let snapshot = try core.snapshot() + lastRevision = currentRevision + pruneSurfaceRegistry(keeping: snapshot.liveSurfaceIDs) + try render(snapshot: snapshot) + } + + private func render(snapshot: TaskersSnapshot) throws { + guard let workspace = snapshot.activeWorkspace else { + window?.contentView = makeMessageView("No active workspace.") + return + } + + window?.title = workspace.label + + let rootView = try buildWorkspaceView(workspace) + let container = NSView(frame: rootView.frame) + container.translatesAutoresizingMaskIntoConstraints = false + container.addSubview(rootView) + + NSLayoutConstraint.activate([ + rootView.leadingAnchor.constraint(equalTo: container.leadingAnchor), + rootView.trailingAnchor.constraint(equalTo: container.trailingAnchor), + rootView.topAnchor.constraint(equalTo: container.topAnchor), + rootView.bottomAnchor.constraint(equalTo: container.bottomAnchor) + ]) + + window?.contentView = container + } + + private func buildWorkspaceView(_ workspace: TaskersWorkspace) throws -> NSView { + let columns = workspace.columns.elements.compactMap { _, column -> (TaskersWorkspaceColumn, [TaskersWorkspaceWindow])? in + let windows = column.windowOrder.compactMap { workspace.windows[$0] } + guard !windows.isEmpty else { + return nil + } + return (column, windows) + } + + guard !columns.isEmpty else { + return makeMessageView("Workspace has no columns.") + } + + if columns.count == 1 { + return try buildColumnView(workspace: workspace, column: columns[0].0, windows: columns[0].1) + } + + let split = WeightedSplitView( + isVertical: true, + weights: columns.map { CGFloat(max($0.0.width, 1)) } + ) + for column in columns { + split.addArrangedSubview(try buildColumnView(workspace: workspace, column: column.0, windows: column.1)) + } + return split + } + + private func buildColumnView( + workspace: TaskersWorkspace, + column: TaskersWorkspaceColumn, + windows: [TaskersWorkspaceWindow] + ) throws -> NSView { + _ = column + if windows.count == 1 { + return try buildWindowView(workspace: workspace, window: windows[0]) + } + + let split = WeightedSplitView( + isVertical: false, + weights: windows.map { CGFloat(max($0.height, 1)) } + ) + for window in windows { + split.addArrangedSubview(try buildWindowView(workspace: workspace, window: window)) + } + return split + } + + private func buildWindowView(workspace: TaskersWorkspace, window: TaskersWorkspaceWindow) throws -> NSView { + try buildLayoutView(workspace: workspace, node: window.layout) + } + + private func buildLayoutView(workspace: TaskersWorkspace, node: TaskersLayoutNode) throws -> NSView { + switch node { + case .leaf(let paneID): + return try surfaceView(workspace: workspace, paneID: paneID) + case .split(let axis, let ratio, let first, let second): + let split = WeightedSplitView( + isVertical: axis == .horizontal, + weights: [CGFloat(ratio), CGFloat(max(1000 - Int(ratio), 1))] + ) + split.addArrangedSubview(try buildLayoutView(workspace: workspace, node: first)) + split.addArrangedSubview(try buildLayoutView(workspace: workspace, node: second)) + return split + } + } + + private func surfaceView(workspace: TaskersWorkspace, paneID: String) throws -> NSView { + guard let pane = workspace.panes[paneID] else { + return makeMessageView("Missing pane \(paneID)") + } + + let activeSurfaceID = pane.activeSurface + if let existing = surfaceRegistry[activeSurfaceID] { + existing.removeFromSuperview() + return existing + } + + let descriptor = try core.surfaceDescriptor(workspaceId: workspace.id, paneId: paneID) + let terminalView = try ghosttyHost.makeSurface( + workspaceID: workspace.id, + paneID: paneID, + surfaceID: activeSurfaceID, + descriptor: descriptor + ) + surfaceRegistry[activeSurfaceID] = terminalView + return terminalView + } + + private func pruneSurfaceRegistry(keeping liveSurfaceIDs: Set) { + for surfaceID in Array(surfaceRegistry.keys) where !liveSurfaceIDs.contains(surfaceID) { + surfaceRegistry[surfaceID]?.removeFromSuperview() + surfaceRegistry.removeValue(forKey: surfaceID) + } + } + + private func closeSurface(workspaceID: String, paneID: String, surfaceID: String) { + _ = try? core.dispatch(command: [ + "command": "close_surface", + "workspace_id": workspaceID, + "pane_id": paneID, + "surface_id": surfaceID + ]) + } + + private func makeMessageView(_ message: String) -> NSView { + let label = NSTextField(labelWithString: message) + label.font = .systemFont(ofSize: 16, weight: .medium) + label.textColor = .secondaryLabelColor + label.alignment = .center + label.translatesAutoresizingMaskIntoConstraints = false + + let view = NSView(frame: .zero) + view.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(label) + NSLayoutConstraint.activate([ + label.centerXAnchor.constraint(equalTo: view.centerXAnchor), + label.centerYAnchor.constraint(equalTo: view.centerYAnchor) + ]) + return view + } +} diff --git a/macos/TaskersMac/Sources/main.swift b/macos/TaskersMac/Sources/main.swift new file mode 100644 index 0000000..0000d26 --- /dev/null +++ b/macos/TaskersMac/Sources/main.swift @@ -0,0 +1,51 @@ +import AppKit +import Foundation + +@main +final class TaskersMacApplication: NSObject, NSApplicationDelegate { + private var core: TaskersCoreBridge? + private var ghosttyHost: TaskersGhosttyHost? + private var workspaceController: TaskersWorkspaceController? + + func applicationDidFinishLaunching(_ notification: Notification) { + _ = notification + TaskersEnvironment.configureBundledPaths() + + do { + let core = try TaskersCoreBridge(options: TaskersEnvironment.defaultCoreOptions()) + let ghosttyHost = TaskersGhosttyHost() + let controller = TaskersWorkspaceController(core: core, ghosttyHost: ghosttyHost) + + self.core = core + self.ghosttyHost = ghosttyHost + self.workspaceController = controller + + try controller.start() + + if TaskersEnvironment.isSmokeTestEnabled { + DispatchQueue.main.asyncAfter(deadline: .now() + 2) { + NSApp.terminate(nil) + } + } + } catch { + fputs("Taskers macOS bootstrap failed: \(error.localizedDescription)\n", stderr) + NSApp.terminate(nil) + exit(1) + } + } + + func applicationDidBecomeActive(_ notification: Notification) { + _ = notification + ghosttyHost?.setFocused(true) + } + + func applicationDidResignActive(_ notification: Notification) { + _ = notification + ghosttyHost?.setFocused(false) + } + + func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { + _ = sender + return true + } +} diff --git a/macos/TaskersMacTests/TaskersCoreBridgeTests.swift b/macos/TaskersMacTests/TaskersCoreBridgeTests.swift new file mode 100644 index 0000000..774c311 --- /dev/null +++ b/macos/TaskersMacTests/TaskersCoreBridgeTests.swift @@ -0,0 +1,29 @@ +import XCTest +@testable import TaskersMac + +final class TaskersCoreBridgeTests: XCTestCase { + func testMockBackendBootstrapsAndRevisionsIncrement() throws { + let tempDir = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + + let options = TaskersCoreOptions( + sessionPath: tempDir.appendingPathComponent("session.json").path, + socketPath: tempDir.appendingPathComponent("taskers.sock").path, + configuredShell: "/bin/sh", + demo: false, + backend: "mock" + ) + let bridge = try TaskersCoreBridge(options: options) + + XCTAssertEqual(bridge.revision, 0) + let snapshot = try bridge.snapshot() + XCTAssertNotNil(snapshot.activeWorkspace) + + _ = try bridge.dispatch(command: [ + "command": "create_workspace", + "label": "Docs" + ]) + XCTAssertEqual(bridge.revision, 1) + } +} diff --git a/macos/TaskersMacTests/TaskersSmokeTests.swift b/macos/TaskersMacTests/TaskersSmokeTests.swift new file mode 100644 index 0000000..4366e15 --- /dev/null +++ b/macos/TaskersMacTests/TaskersSmokeTests.swift @@ -0,0 +1,102 @@ +import AppKit +import XCTest +@testable import TaskersMac + +@MainActor +final class TaskersSmokeTests: XCTestCase { + private var tempDirectory: URL! + + override func setUpWithError() throws { + _ = NSApplication.shared + try bootstrapTestEnvironment() + tempDirectory = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory( + at: tempDirectory, + withIntermediateDirectories: true + ) + } + + override func tearDownWithError() throws { + if let tempDirectory { + try? FileManager.default.removeItem(at: tempDirectory) + } + } + + func testWorkspaceControllerRendersAndTracksSurfaceLifecycle() throws { + let core = try TaskersCoreBridge(options: TaskersCoreOptions( + sessionPath: tempDirectory.appendingPathComponent("session.json").path, + socketPath: tempDirectory.appendingPathComponent("taskers.sock").path, + configuredShell: "/bin/sh", + demo: false, + backend: "ghostty_embedded" + )) + let host = TaskersGhosttyHost() + let controller = TaskersWorkspaceController(core: core, ghosttyHost: host) + + controller.showWindow(nil) + controller.window?.makeKeyAndOrderFront(nil) + drainMainRunLoop() + + try controller.refresh(force: true) + XCTAssertEqual(controller.surfaceCount, 1) + let initialSurfaceIDs = controller.lastRenderedSurfaceIDs + + let initialWorkspace = try XCTUnwrap(try core.snapshot().activeWorkspace) + _ = try core.dispatch(command: [ + "command": "split_pane", + "workspace_id": initialWorkspace.id, + "pane_id": initialWorkspace.activePane, + "axis": "vertical" + ]) + drainMainRunLoop() + try controller.refresh(force: true) + XCTAssertEqual(controller.surfaceCount, 2) + XCTAssertTrue(initialSurfaceIDs.isSubset(of: controller.lastRenderedSurfaceIDs)) + + let updatedWorkspace = try XCTUnwrap(try core.snapshot().activeWorkspace) + _ = try core.dispatch(command: [ + "command": "close_pane", + "workspace_id": updatedWorkspace.id, + "pane_id": updatedWorkspace.activePane + ]) + drainMainRunLoop() + try controller.refresh(force: true) + XCTAssertEqual(controller.surfaceCount, 1) + XCTAssertEqual(controller.lastRenderedSurfaceIDs.count, 1) + + controller.close() + drainMainRunLoop() + } + + private func bootstrapTestEnvironment() throws { + let repoRoot = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + let resourcesRoot = repoRoot.appendingPathComponent("build/macos/resources", isDirectory: true) + let ghosttyResources = resourcesRoot.appendingPathComponent("ghostty", isDirectory: true) + let terminfo = resourcesRoot.appendingPathComponent("terminfo", isDirectory: true) + let helper = repoRoot.appendingPathComponent("build/macos/bin/taskersctl") + + for requiredPath in [ghosttyResources, terminfo, helper] { + guard FileManager.default.fileExists(atPath: requiredPath.path) else { + throw XCTSkip("missing preview dependency at \(requiredPath.path)") + } + } + + setenv("GHOSTTY_RESOURCES_DIR", ghosttyResources.path, 1) + setenv("TERMINFO", terminfo.path, 1) + setenv("TASKERS_CTL_PATH", helper.path, 1) + setenv("TASKERS_DISABLE_SHELL_INTEGRATION", "1", 1) + } + + private func drainMainRunLoop() { + controllerWindow?.contentView?.layoutSubtreeIfNeeded() + RunLoop.current.run(until: Date().addingTimeInterval(0.1)) + } + + private var controllerWindow: NSWindow? { + NSApplication.shared.windows.first + } +} diff --git a/macos/TaskersMacTests/TaskersSnapshotTests.swift b/macos/TaskersMacTests/TaskersSnapshotTests.swift new file mode 100644 index 0000000..6033dcb --- /dev/null +++ b/macos/TaskersMacTests/TaskersSnapshotTests.swift @@ -0,0 +1,82 @@ +import XCTest +@testable import TaskersMac + +final class TaskersSnapshotTests: XCTestCase { + func testOrderedMapsPreserveJSONKeyOrder() throws { + let json = """ + { + "active_window": "window-a", + "windows": { + "window-a": { + "id": "window-a", + "workspace_order": ["workspace-a"], + "active_workspace": "workspace-a" + } + }, + "workspaces": { + "workspace-a": { + "id": "workspace-a", + "label": "Main", + "columns": { + "column-a": { + "id": "column-a", + "width": 520, + "window_order": ["window-1"], + "active_window": "window-1" + }, + "column-b": { + "id": "column-b", + "width": 360, + "window_order": ["window-2"], + "active_window": "window-2" + } + }, + "windows": { + "window-1": { + "id": "window-1", + "height": 400, + "layout": { "kind": "leaf", "pane_id": "pane-1" }, + "active_pane": "pane-1" + }, + "window-2": { + "id": "window-2", + "height": 400, + "layout": { "kind": "leaf", "pane_id": "pane-2" }, + "active_pane": "pane-2" + } + }, + "active_window": "window-1", + "panes": { + "pane-1": { + "id": "pane-1", + "surfaces": { + "surface-1": { + "id": "surface-1", + "metadata": { "title": "One", "cwd": null } + } + }, + "active_surface": "surface-1" + }, + "pane-2": { + "id": "pane-2", + "surfaces": { + "surface-2": { + "id": "surface-2", + "metadata": { "title": "Two", "cwd": null } + } + }, + "active_surface": "surface-2" + } + }, + "active_pane": "pane-1" + } + } + } + """ + + let snapshot = try JSONDecoder().decode(TaskersSnapshot.self, from: Data(json.utf8)) + let columns = snapshot.activeWorkspace?.columns.elements.map(\.0) + XCTAssertEqual(columns, ["column-a", "column-b"]) + XCTAssertEqual(snapshot.liveSurfaceIDs, Set(["surface-1", "surface-2"])) + } +} diff --git a/macos/project.yml b/macos/project.yml new file mode 100644 index 0000000..7d5e46f --- /dev/null +++ b/macos/project.yml @@ -0,0 +1,62 @@ +name: Taskers +options: + bundleIdPrefix: dev.taskers + deploymentTarget: + macOS: "14.0" +settings: + base: + PRODUCT_NAME: Taskers + SWIFT_VERSION: 5.10 + MACOSX_DEPLOYMENT_TARGET: "14.0" + LD_RUNPATH_SEARCH_PATHS: + - $(inherited) + - "@executable_path/../Frameworks" +targets: + TaskersMac: + type: application + platform: macOS + sources: + - path: TaskersMac/Sources + info: + path: TaskersMac/Info.plist + dependencies: + - framework: ../build/macos/GhosttyKit.xcframework + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: dev.taskers.app + PRODUCT_MODULE_NAME: TaskersMac + SWIFT_OBJC_BRIDGING_HEADER: TaskersMac/Sources/TaskersMac-Bridging-Header.h + LIBRARY_SEARCH_PATHS: + - $(SRCROOT)/../build/macos + OTHER_LDFLAGS: + - $(inherited) + - -ltaskers_macos_ffi + preBuildScripts: + - name: Build macOS Preview Dependencies + script: | + "${SRCROOT}/../scripts/macos-build-preview-deps.sh" + basedOnDependencyAnalysis: false + postBuildScripts: + - name: Stage App Bundle Support + script: | + "${SRCROOT}/../scripts/stage_macos_bundle_support.sh" + basedOnDependencyAnalysis: false + TaskersMacTests: + type: bundle.unit-test + platform: macOS + sources: + - path: TaskersMacTests + dependencies: + - target: TaskersMac + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: dev.taskers.tests +schemes: + TaskersMac: + build: + targets: + TaskersMac: all + TaskersMacTests: [test] + test: + targets: + - TaskersMacTests diff --git a/scripts/generate_macos_project.sh b/scripts/generate_macos_project.sh new file mode 100755 index 0000000..e09286f --- /dev/null +++ b/scripts/generate_macos_project.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "${ROOT_DIR}" + +xcodegen generate --spec macos/project.yml --project macos/Taskers.xcodeproj diff --git a/scripts/macos-build-preview-deps.sh b/scripts/macos-build-preview-deps.sh new file mode 100755 index 0000000..9548853 --- /dev/null +++ b/scripts/macos-build-preview-deps.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +BUILD_DIR="${ROOT_DIR}/build/macos" +GHOSTTY_DIR="${ROOT_DIR}/vendor/ghostty" + +mkdir -p "${BUILD_DIR}/bin" "${BUILD_DIR}/resources" + +pushd "${ROOT_DIR}" >/dev/null +cargo build --release -p taskers-macos-ffi +cargo build --release -p taskers-cli --bin taskersctl +popd >/dev/null + +cp "${ROOT_DIR}/target/release/libtaskers_macos_ffi.a" "${BUILD_DIR}/libtaskers_macos_ffi.a" +cp "${ROOT_DIR}/target/release/taskersctl" "${BUILD_DIR}/bin/taskersctl" +chmod +x "${BUILD_DIR}/bin/taskersctl" + +pushd "${GHOSTTY_DIR}" >/dev/null +zig build -Dapp-runtime=none -Demit-xcframework=true -Dxcframework-target=native +popd >/dev/null + +rm -rf "${BUILD_DIR}/GhosttyKit.xcframework" +cp -R "${GHOSTTY_DIR}/zig-out/macos/GhosttyKit.xcframework" "${BUILD_DIR}/GhosttyKit.xcframework" + +rm -rf "${BUILD_DIR}/resources/ghostty" "${BUILD_DIR}/resources/terminfo" +cp -R "${GHOSTTY_DIR}/zig-out/share/ghostty" "${BUILD_DIR}/resources/ghostty" +cp -R "${GHOSTTY_DIR}/zig-out/share/terminfo" "${BUILD_DIR}/resources/terminfo" diff --git a/scripts/stage_macos_bundle_support.sh b/scripts/stage_macos_bundle_support.sh new file mode 100755 index 0000000..99baf23 --- /dev/null +++ b/scripts/stage_macos_bundle_support.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ -z "${TARGET_BUILD_DIR:-}" || -z "${UNLOCALIZED_RESOURCES_FOLDER_PATH:-}" ]]; then + echo "xcode build paths are not available" >&2 + exit 1 +fi + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +BUILD_DIR="${ROOT_DIR}/build/macos" +RESOURCES_DIR="${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}" +HELPER_DIR="${RESOURCES_DIR}/bin" + +mkdir -p "${HELPER_DIR}" "${RESOURCES_DIR}" + +install -m 755 "${BUILD_DIR}/bin/taskersctl" "${HELPER_DIR}/taskersctl" + +rm -rf "${RESOURCES_DIR}/ghostty" "${RESOURCES_DIR}/terminfo" +cp -R "${BUILD_DIR}/resources/ghostty" "${RESOURCES_DIR}/ghostty" +cp -R "${BUILD_DIR}/resources/terminfo" "${RESOURCES_DIR}/terminfo" From e15b1234d53bda6b8e7e6022b6e8135fbc576ea1 Mon Sep 17 00:00:00 2001 From: OneNoted Date: Tue, 17 Mar 2026 00:11:38 +0100 Subject: [PATCH 03/40] fix: improve macOS preview input and smoke validation --- .../Sources/TaskersEnvironment.swift | 2 +- .../Sources/TaskersTerminalView.swift | 142 ++++++++++++++++-- 2 files changed, 127 insertions(+), 17 deletions(-) diff --git a/macos/TaskersMac/Sources/TaskersEnvironment.swift b/macos/TaskersMac/Sources/TaskersEnvironment.swift index 407c015..328307b 100644 --- a/macos/TaskersMac/Sources/TaskersEnvironment.swift +++ b/macos/TaskersMac/Sources/TaskersEnvironment.swift @@ -20,7 +20,7 @@ enum TaskersEnvironment { sessionPath: nil, socketPath: nil, configuredShell: nil, - demo: isSmokeTestEnabled, + demo: false, backend: "ghostty_embedded" ) } diff --git a/macos/TaskersMac/Sources/TaskersTerminalView.swift b/macos/TaskersMac/Sources/TaskersTerminalView.swift index 13521e0..19757c4 100644 --- a/macos/TaskersMac/Sources/TaskersTerminalView.swift +++ b/macos/TaskersMac/Sources/TaskersTerminalView.swift @@ -88,13 +88,17 @@ final class TaskersTerminalView: NSView { } override func keyDown(with event: NSEvent) { - if let characters = event.characters, !characters.isEmpty { - sendText(characters) - } else { + if !sendKey(event, action: event.isARepeat ? GHOSTTY_ACTION_REPEAT : GHOSTTY_ACTION_PRESS) { super.keyDown(with: event) } } + override func keyUp(with event: NSEvent) { + if !sendKey(event, action: GHOSTTY_ACTION_RELEASE) { + super.keyUp(with: event) + } + } + override func mouseDown(with event: NSEvent) { window?.makeFirstResponder(self) sendMouseButton(event, action: GHOSTTY_MOUSE_PRESS) @@ -163,18 +167,6 @@ final class TaskersTerminalView: NSView { ghostty_surface_set_content_scale(surface, scale, scale) } - private func sendText(_ text: String) { - guard let surface else { - return - } - - let length = text.utf8.count - text.withCString { pointer in - ghostty_surface_text(surface, pointer, UInt(length)) - } - refresh() - } - private func sendMouseButton(_ event: NSEvent, action: ghostty_input_mouse_state_e) { guard let surface else { return @@ -204,6 +196,60 @@ final class TaskersTerminalView: NSView { refresh() } + private func sendKey(_ event: NSEvent, action: ghostty_input_action_e) -> Bool { + guard let surface else { + return false + } + + let translatedModifiers = Self.modifierFlags( + from: ghostty_surface_key_translation_mods( + surface, + Self.modifiers(from: event.modifierFlags) + ) + ) + + let translationEvent: NSEvent + if translatedModifiers == event.modifierFlags { + translationEvent = event + } else { + translationEvent = NSEvent.keyEvent( + with: event.type, + location: event.locationInWindow, + modifierFlags: translatedModifiers, + timestamp: event.timestamp, + windowNumber: event.windowNumber, + context: nil, + characters: event.characters(byApplyingModifiers: translatedModifiers) ?? "", + charactersIgnoringModifiers: event.charactersIgnoringModifiers ?? "", + isARepeat: event.isARepeat, + keyCode: event.keyCode + ) ?? event + } + + var keyEvent = event.taskersGhosttyKeyEvent( + action, + translationMods: translationEvent.modifierFlags + ) + + let handled: Bool + if let text = translationEvent.taskersGhosttyCharacters, + !text.isEmpty, + let codepoint = text.utf8.first, + codepoint >= 0x20 { + handled = text.withCString { pointer in + keyEvent.text = pointer + return ghostty_surface_key(surface, keyEvent) + } + } else { + handled = ghostty_surface_key(surface, keyEvent) + } + + if handled { + refresh() + } + return handled + } + private static func createSurface( view: TaskersTerminalView, app: ghostty_app_t, @@ -312,7 +358,7 @@ final class TaskersTerminalView: NSView { } } - private static func modifiers(from flags: NSEvent.ModifierFlags) -> ghostty_input_mods_e { + fileprivate static func modifiers(from flags: NSEvent.ModifierFlags) -> ghostty_input_mods_e { var mods = Int32(GHOSTTY_MODS_NONE.rawValue) if flags.contains(.shift) { mods |= Int32(GHOSTTY_MODS_SHIFT.rawValue) @@ -329,6 +375,24 @@ final class TaskersTerminalView: NSView { return ghostty_input_mods_e(mods) } + private static func modifierFlags(from mods: ghostty_input_mods_e) -> NSEvent.ModifierFlags { + let raw = Int32(mods.rawValue) + var flags: NSEvent.ModifierFlags = [] + if raw & Int32(GHOSTTY_MODS_SHIFT.rawValue) != 0 { + flags.insert(.shift) + } + if raw & Int32(GHOSTTY_MODS_CTRL.rawValue) != 0 { + flags.insert(.control) + } + if raw & Int32(GHOSTTY_MODS_ALT.rawValue) != 0 { + flags.insert(.option) + } + if raw & Int32(GHOSTTY_MODS_SUPER.rawValue) != 0 { + flags.insert(.command) + } + return flags + } + static func from(surface: ghostty_surface_t) -> TaskersTerminalView? { from(userdata: ghostty_surface_userdata(surface)) } @@ -341,3 +405,49 @@ final class TaskersTerminalView: NSView { return Unmanaged.fromOpaque(userdata).takeUnretainedValue() } } + +private extension NSEvent { + func taskersGhosttyKeyEvent( + _ action: ghostty_input_action_e, + translationMods: NSEvent.ModifierFlags? = nil + ) -> ghostty_input_key_s { + var keyEvent: ghostty_input_key_s = .init() + keyEvent.action = action + keyEvent.keycode = UInt32(keyCode) + keyEvent.text = nil + keyEvent.composing = false + keyEvent.mods = TaskersTerminalView.modifiers(from: modifierFlags) + keyEvent.consumed_mods = TaskersTerminalView.modifiers( + from: (translationMods ?? modifierFlags).subtracting([.control, .command]) + ) + + if type == .keyDown || type == .keyUp, + let chars = characters(byApplyingModifiers: []), + let codepoint = chars.unicodeScalars.first { + keyEvent.unshifted_codepoint = codepoint.value + } else { + keyEvent.unshifted_codepoint = 0 + } + + return keyEvent + } + + var taskersGhosttyCharacters: String? { + guard let characters else { + return nil + } + + if characters.count == 1, + let scalar = characters.unicodeScalars.first { + if scalar.value < 0x20 { + return self.characters(byApplyingModifiers: modifierFlags.subtracting(.control)) + } + + if scalar.value >= 0xF700 && scalar.value <= 0xF8FF { + return nil + } + } + + return characters + } +} From eec1ba490afe8f65572c49a81a1116196fe597ff Mon Sep 17 00:00:00 2001 From: OneNoted Date: Tue, 17 Mar 2026 00:22:57 +0100 Subject: [PATCH 04/40] feat: add cross-platform taskers launcher --- .github/workflows/macos-preview.yml | 6 +- .github/workflows/release-assets.yml | 180 ++++ Cargo.lock | 135 ++- Cargo.toml | 6 + README.md | 6 +- crates/taskers-app/Cargo.toml | 5 +- crates/taskers-cli/src/main.rs | 364 +------- crates/taskers-launcher/Cargo.toml | 27 + .../assets/taskers-codex-notify.sh | 28 + .../assets/taskers.desktop.in | 13 + crates/taskers-launcher/assets/taskers.svg | 8 + crates/taskers-launcher/src/lib.rs | 824 ++++++++++++++++++ crates/taskers-launcher/src/main.rs | 11 + crates/taskers-paths/src/lib.rs | 58 ++ docs/release.md | 49 +- .../build_release_manifest.cpython-314.pyc | Bin 0 -> 4722 bytes scripts/build_linux_bundle.sh | 47 + scripts/build_macos_dmg.sh | 24 + scripts/build_release_manifest.py | 79 ++ scripts/capture_demo_screenshots.sh | 4 +- scripts/package_macos_app_zip.sh | 33 + scripts/sign_macos_app.sh | 29 + scripts/smoke_taskers_focus_churn.sh | 4 +- scripts/smoke_taskers_ui.sh | 4 +- 24 files changed, 1549 insertions(+), 395 deletions(-) create mode 100644 .github/workflows/release-assets.yml create mode 100644 crates/taskers-launcher/Cargo.toml create mode 100644 crates/taskers-launcher/assets/taskers-codex-notify.sh create mode 100644 crates/taskers-launcher/assets/taskers.desktop.in create mode 100644 crates/taskers-launcher/assets/taskers.svg create mode 100644 crates/taskers-launcher/src/lib.rs create mode 100644 crates/taskers-launcher/src/main.rs create mode 100644 scripts/__pycache__/build_release_manifest.cpython-314.pyc create mode 100644 scripts/build_linux_bundle.sh create mode 100644 scripts/build_macos_dmg.sh create mode 100644 scripts/build_release_manifest.py create mode 100644 scripts/package_macos_app_zip.sh create mode 100644 scripts/sign_macos_app.sh diff --git a/.github/workflows/macos-preview.yml b/.github/workflows/macos-preview.yml index dbe7216..6056963 100644 --- a/.github/workflows/macos-preview.yml +++ b/.github/workflows/macos-preview.yml @@ -13,6 +13,7 @@ jobs: fail-fast: false matrix: runner: + - macos-14 - macos-15 - macos-15-intel runs-on: ${{ matrix.runner }} @@ -38,7 +39,7 @@ jobs: cargo check --workspace - name: Generate Xcode project - run: ./scripts/generate_macos_project.sh + run: bash scripts/generate_macos_project.sh - name: Build and test Taskers.app run: | @@ -53,6 +54,9 @@ jobs: CODE_SIGNING_REQUIRED=NO \ | tee build/macos/xcodebuild-test-${{ matrix.runner }}.log + - name: Sign debug app bundle + run: bash scripts/sign_macos_app.sh build/macos/DerivedData build/macos/DerivedData/Build/Products/Debug/Taskers.app + - name: Smoke launch bundled app run: | APP_PATH="build/macos/DerivedData/Build/Products/Debug/Taskers.app" diff --git a/.github/workflows/release-assets.yml b/.github/workflows/release-assets.yml new file mode 100644 index 0000000..d2f9f62 --- /dev/null +++ b/.github/workflows/release-assets.yml @@ -0,0 +1,180 @@ +name: Release Assets + +on: + push: + tags: + - "v*" + workflow_dispatch: + +jobs: + linux-bundle: + runs-on: ubuntu-latest + + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + + - name: Cache Rust artifacts + uses: Swatinem/rust-cache@v2 + + - name: Install Linux bundle tools + run: | + sudo apt-get update + sudo apt-get install -y zig xvfb libgtk-4-dev libadwaita-1-dev + + - name: Build Linux bundle + run: bash scripts/build_linux_bundle.sh + + - name: Run Linux smoke checks + run: | + bash scripts/smoke_taskers_ui.sh + bash scripts/smoke_taskers_focus_churn.sh + + - name: Upload Linux bundle + uses: actions/upload-artifact@v4 + with: + name: linux-bundle + path: dist/taskers-linux-bundle-v*.tar.xz + + macos-preview: + strategy: + fail-fast: false + matrix: + runner: + - macos-15 + - macos-15-intel + runs-on: ${{ matrix.runner }} + + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + + - name: Cache Rust artifacts + uses: Swatinem/rust-cache@v2 + + - name: Install macOS build tools + run: | + brew update + brew install xcodegen zig + + - name: Generate Xcode project + run: bash scripts/generate_macos_project.sh + + - name: Run AppKit tests + run: | + set -o pipefail + xcodebuild test \ + -project macos/Taskers.xcodeproj \ + -scheme TaskersMac \ + -configuration Debug \ + -derivedDataPath build/macos/DerivedData \ + -resultBundlePath build/macos/Taskers-${{ matrix.runner }}.xcresult \ + CODE_SIGNING_ALLOWED=NO \ + CODE_SIGNING_REQUIRED=NO \ + | tee build/macos/xcodebuild-test-${{ matrix.runner }}.log + + - name: Build Taskers.app + run: | + xcodebuild build \ + -project macos/Taskers.xcodeproj \ + -scheme TaskersMac \ + -configuration Release \ + -derivedDataPath build/macos/DerivedData \ + CODE_SIGNING_ALLOWED=NO \ + CODE_SIGNING_REQUIRED=NO + + - name: Sign Taskers.app + run: bash scripts/sign_macos_app.sh + + - name: Package macOS app zip + run: bash scripts/package_macos_app_zip.sh + + - name: Upload macOS app zip + uses: actions/upload-artifact@v4 + with: + name: macos-app-${{ matrix.runner }} + path: | + dist/taskers-macos-app-v*.zip + build/macos/Taskers-${{ matrix.runner }}.xcresult + build/macos/xcodebuild-test-${{ matrix.runner }}.log + + macos-universal-dmg: + runs-on: macos-15 + + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + + - name: Cache Rust artifacts + uses: Swatinem/rust-cache@v2 + + - name: Install macOS build tools + run: | + brew update + brew install xcodegen zig + + - name: Generate Xcode project + run: bash scripts/generate_macos_project.sh + + - name: Build universal Taskers.app + run: | + xcodebuild build \ + -project macos/Taskers.xcodeproj \ + -scheme TaskersMac \ + -configuration Release \ + -derivedDataPath build/macos/DerivedData \ + ARCHS="arm64 x86_64" \ + ONLY_ACTIVE_ARCH=NO \ + CODE_SIGNING_ALLOWED=NO \ + CODE_SIGNING_REQUIRED=NO + + - name: Sign universal Taskers.app + run: bash scripts/sign_macos_app.sh + + - name: Build universal DMG + run: bash scripts/build_macos_dmg.sh + + - name: Upload universal DMG + uses: actions/upload-artifact@v4 + with: + name: macos-universal-dmg + path: dist/Taskers-v*-universal2.dmg + + release-manifest: + needs: + - linux-bundle + - macos-preview + - macos-universal-dmg + runs-on: ubuntu-latest + + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Download built assets + uses: actions/download-artifact@v4 + with: + path: dist + + - name: Flatten downloaded artifacts + run: | + mkdir -p dist/release + find dist -type f -exec cp {} dist/release/ \; + + - name: Build release manifest + run: python3 scripts/build_release_manifest.py --dist-dir dist/release + + - name: Upload release manifest + uses: actions/upload-artifact@v4 + with: + name: release-manifest + path: dist/release/taskers-manifest-v*.json diff --git a/Cargo.lock b/Cargo.lock index da1cf55..c5033d6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -94,12 +94,27 @@ version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + [[package]] name = "bumpalo" version = "3.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + [[package]] name = "bytes" version = "1.11.1" @@ -207,6 +222,15 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + [[package]] name = "crc32fast" version = "1.5.0" @@ -216,6 +240,22 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + [[package]] name = "deranged" version = "0.5.8" @@ -226,6 +266,16 @@ dependencies = [ "serde_core", ] +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + [[package]] name = "displaydoc" version = "0.2.5" @@ -456,6 +506,16 @@ dependencies = [ "system-deps", ] +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -1310,6 +1370,17 @@ dependencies = [ "winapi", ] +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "shared_library" version = "0.1.9" @@ -1451,22 +1522,15 @@ name = "taskers" version = "0.2.1" dependencies = [ "anyhow", - "clap", - "gtk4", - "libadwaita", "serde", "serde_json", - "svgtypes", - "taskers-control", - "taskers-core", - "taskers-domain", - "taskers-ghostty", + "sha2", + "tar", "taskers-paths", - "taskers-runtime", "tempfile", - "time", - "tokio", - "toml 0.8.23", + "ureq", + "xz2", + "zip", ] [[package]] @@ -1539,6 +1603,29 @@ dependencies = [ "xz2", ] +[[package]] +name = "taskers-gtk" +version = "0.2.1" +dependencies = [ + "anyhow", + "clap", + "gtk4", + "libadwaita", + "serde", + "serde_json", + "svgtypes", + "taskers-control", + "taskers-core", + "taskers-domain", + "taskers-ghostty", + "taskers-paths", + "taskers-runtime", + "tempfile", + "time", + "tokio", + "toml 0.8.23", +] + [[package]] name = "taskers-macos-ffi" version = "0.2.1" @@ -1791,6 +1878,12 @@ version = "1.0.6+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab16f14aed21ee8bfd8ec22513f7287cd4a91aa92e44edfe2c17ddd004e92607" +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + [[package]] name = "unicode-ident" version = "1.0.24" @@ -1867,6 +1960,12 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e" +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -2312,6 +2411,18 @@ dependencies = [ "syn", ] +[[package]] +name = "zip" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "760394e246e4c28189f19d488c058bf16f564016aefac5d32bb1f3b51d5e9261" +dependencies = [ + "byteorder", + "crc32fast", + "crossbeam-utils", + "flate2", +] + [[package]] name = "zmij" version = "1.0.21" diff --git a/Cargo.toml b/Cargo.toml index 8da1f99..0d77585 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,7 @@ [workspace] members = [ "crates/taskers-app", + "crates/taskers-launcher", "crates/taskers-core", "crates/taskers-cli", "crates/taskers-control", @@ -30,12 +31,17 @@ libc = "0.2" portable-pty = "0.9.0" serde = { version = "1", features = ["derive"] } serde_json = "1" +sha2 = "0.10" svgtypes = "0.15" +tar = "0.4" tempfile = "3" toml = "0.8" thiserror = "2" time = { version = "0.3", features = ["formatting", "macros", "parsing", "serde", "serde-human-readable"] } tokio = { version = "1.50.0", features = ["io-util", "macros", "net", "rt-multi-thread", "sync"] } +ureq = "2.12" uuid = { version = "1.22.0", features = ["serde", "v7"] } +xz2 = "0.1" +zip = { version = "0.6", default-features = false, features = ["deflate"] } taskers-core = { version = "0.2.1", path = "crates/taskers-core" } taskers-paths = { version = "0.2.1", path = "crates/taskers-paths" } diff --git a/README.md b/README.md index 1304ec6..3603a18 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # taskers -Taskers is a Linux-first terminal workspace for agent-heavy work. It gives you Niri-style top-level windows, local pane splits, and an attention sidebar so active, waiting, and completed terminal work stays visible. +Taskers is a cross-platform terminal workspace for agent-heavy work. It gives you Niri-style top-level windows, local pane splits, and an attention sidebar so active, waiting, and completed terminal work stays visible. ![Taskers workspace list and attention sidebar](docs/screenshots/demo-attention.png) @@ -13,12 +13,12 @@ cargo install taskers --locked taskers --demo ``` -The first launch bootstraps the matching Ghostty runtime bundle when needed. +The first launch downloads the exact version-matched Taskers bundle for your platform when needed. ## Develop ```bash -cargo run -p taskers -- --demo +cargo run -p taskers-gtk --bin taskers-gtk -- --demo ``` Release checklist: [docs/release.md](docs/release.md) diff --git a/crates/taskers-app/Cargo.toml b/crates/taskers-app/Cargo.toml index a024acb..7a5cdeb 100644 --- a/crates/taskers-app/Cargo.toml +++ b/crates/taskers-app/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "taskers" +name = "taskers-gtk" description = "Agent-first terminal workspace app with a Niri-like tiling model." edition.workspace = true homepage.workspace = true @@ -7,9 +7,10 @@ license.workspace = true readme = "../../README.md" repository.workspace = true version.workspace = true +publish = false [[bin]] -name = "taskers" +name = "taskers-gtk" path = "src/main.rs" [dependencies] diff --git a/crates/taskers-cli/src/main.rs b/crates/taskers-cli/src/main.rs index e58e49a..2b46b0f 100644 --- a/crates/taskers-cli/src/main.rs +++ b/crates/taskers-cli/src/main.rs @@ -1,8 +1,7 @@ use std::{ env, future::pending, - path::{Path, PathBuf}, - process::Command as ProcessCommand, + path::PathBuf, }; use anyhow::Context; @@ -18,7 +17,7 @@ use taskers_domain::{ use time::OffsetDateTime; #[derive(Debug, Parser)] -#[command(name = "taskers")] +#[command(name = "taskersctl")] #[command(about = "Local control CLI for the taskers workspace app")] struct Cli { #[command(subcommand)] @@ -27,10 +26,6 @@ struct Cli { #[derive(Debug, Subcommand)] enum Command { - Install { - #[arg(long, default_value_t = false)] - skip_build: bool, - }, Serve { #[arg(long)] socket: Option, @@ -325,9 +320,6 @@ async fn main() -> anyhow::Result<()> { let cli = Cli::parse(); match cli.command { - Command::Install { skip_build } => { - install_app(skip_build)?; - } Command::Serve { socket, demo } => { let socket = resolve_socket_path(socket); let listener = bind_socket(&socket) @@ -738,358 +730,6 @@ fn infer_agent_kind(value: &str) -> Option { } } -fn install_app(skip_build: bool) -> anyhow::Result<()> { - let workspace_root = PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .join("../..") - .canonicalize() - .context("failed to resolve workspace root")?; - let cargo_bin_dir = cargo_bin_dir()?; - let app_binary = cargo_bin_dir.join("taskers"); - let cli_binary = cargo_bin_dir.join("taskersctl"); - - if !skip_build { - for (path, bin_name) in [ - ("crates/taskers-app", "taskers"), - ("crates/taskers-cli", "taskersctl"), - ] { - let status = ProcessCommand::new("cargo") - .arg("install") - .arg("--path") - .arg(path) - .arg("--bin") - .arg(bin_name) - .arg("--force") - .arg("--locked") - .current_dir(&workspace_root) - .status() - .with_context(|| format!("failed to invoke cargo install for {bin_name}"))?; - if !status.success() { - anyhow::bail!("cargo install for {bin_name} exited with status {status}"); - } - } - } - - if !app_binary.exists() { - anyhow::bail!( - "expected installed binary at {}, but it was not found", - app_binary.display() - ); - } - if !cli_binary.exists() { - anyhow::bail!( - "expected installed binary at {}, but it was not found", - cli_binary.display() - ); - } - - let launcher_binary = install_launcher_binary(&app_binary, "taskers")?; - let control_binary = install_launcher_binary(&cli_binary, "taskersctl")?; - let codex_notify_script = install_executable_asset( - "taskers-codex-notify", - include_str!(concat!( - env!("CARGO_MANIFEST_DIR"), - "/assets/taskers-codex-notify.sh" - )), - )?; - - let xdg_data_home = xdg_data_home()?; - let applications_dir = xdg_data_home.join("applications"); - let icons_dir = xdg_data_home - .join("icons") - .join("hicolor") - .join("scalable") - .join("apps"); - let taskers_data_dir = xdg_data_home.join("taskers"); - let ghostty_bundle_dir = taskers_data_dir.join("ghostty"); - let terminfo_dir = xdg_data_home.join("terminfo"); - std::fs::create_dir_all(&applications_dir)?; - std::fs::create_dir_all(&icons_dir)?; - - install_ghostty_runtime( - &workspace_root, - &ghostty_bundle_dir, - &taskers_data_dir.join("locale"), - &terminfo_dir, - )?; - - let desktop_template = include_str!(concat!( - env!("CARGO_MANIFEST_DIR"), - "/assets/taskers.desktop.in" - )); - let desktop_entry = desktop_template.replace("{{EXEC}}", &desktop_exec(&launcher_binary)); - let desktop_path = applications_dir.join("dev.taskers.app.desktop"); - std::fs::write(&desktop_path, desktop_entry) - .with_context(|| format!("failed to write {}", desktop_path.display()))?; - - let icon_path = icons_dir.join("taskers.svg"); - std::fs::write( - &icon_path, - include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/assets/taskers.svg")), - ) - .with_context(|| format!("failed to write {}", icon_path.display()))?; - - refresh_desktop_indexes(&applications_dir); - - println!("Installed taskers"); - println!("Binary: {}", app_binary.display()); - println!("Launcher binary: {}", launcher_binary.display()); - println!("Control binary: {}", control_binary.display()); - println!("Codex notify helper: {}", codex_notify_script.display()); - println!("Desktop entry: {}", desktop_path.display()); - println!("Icon: {}", icon_path.display()); - println!("Ghostty resources: {}", ghostty_bundle_dir.display()); - println!( - "Ghostty bridge: {}", - ghostty_bundle_dir - .join("lib") - .join("libtaskers_ghostty_bridge.so") - .display() - ); - println!( - "Ghostty locale: {}", - taskers_data_dir.join("locale").display() - ); - - Ok(()) -} - -fn cargo_bin_dir() -> anyhow::Result { - if let Some(path) = env::var_os("CARGO_HOME").map(PathBuf::from) { - return Ok(path.join("bin")); - } - - let home = env::var_os("HOME") - .map(PathBuf::from) - .context("HOME is not set and CARGO_HOME is unavailable")?; - Ok(home.join(".cargo").join("bin")) -} - -fn xdg_bin_dir() -> anyhow::Result { - if let Some(path) = env::var_os("XDG_BIN_HOME").map(PathBuf::from) { - return Ok(path); - } - - let home = env::var_os("HOME") - .map(PathBuf::from) - .context("HOME is not set and XDG_BIN_HOME is unavailable")?; - Ok(home.join(".local").join("bin")) -} - -fn xdg_data_home() -> anyhow::Result { - if let Some(path) = env::var_os("XDG_DATA_HOME").map(PathBuf::from) { - return Ok(path); - } - - let home = env::var_os("HOME") - .map(PathBuf::from) - .context("HOME is not set and XDG_DATA_HOME is unavailable")?; - Ok(home.join(".local").join("share")) -} - -fn desktop_exec(path: &Path) -> String { - let raw = path.display().to_string(); - raw.replace('\\', "\\\\").replace(' ', "\\ ") -} - -fn install_launcher_binary(app_binary: &Path, target_name: &str) -> anyhow::Result { - let bin_dir = xdg_bin_dir()?; - std::fs::create_dir_all(&bin_dir) - .with_context(|| format!("failed to create {}", bin_dir.display()))?; - let launcher_binary = bin_dir.join(target_name); - - if launcher_binary == app_binary { - return Ok(launcher_binary); - } - - if launcher_binary.symlink_metadata().is_ok() { - std::fs::remove_file(&launcher_binary) - .with_context(|| format!("failed to remove {}", launcher_binary.display()))?; - } - - #[cfg(unix)] - { - std::os::unix::fs::symlink(app_binary, &launcher_binary).with_context(|| { - format!( - "failed to symlink {} -> {}", - launcher_binary.display(), - app_binary.display() - ) - })?; - } - - #[cfg(not(unix))] - { - std::fs::copy(app_binary, &launcher_binary).with_context(|| { - format!( - "failed to copy {} -> {}", - app_binary.display(), - launcher_binary.display() - ) - })?; - } - - Ok(launcher_binary) -} - -fn install_executable_asset(target_name: &str, content: &str) -> anyhow::Result { - let bin_dir = xdg_bin_dir()?; - std::fs::create_dir_all(&bin_dir) - .with_context(|| format!("failed to create {}", bin_dir.display()))?; - let target_path = bin_dir.join(target_name); - std::fs::write(&target_path, content) - .with_context(|| format!("failed to write {}", target_path.display()))?; - - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - - let mut permissions = std::fs::metadata(&target_path) - .with_context(|| format!("failed to stat {}", target_path.display()))? - .permissions(); - permissions.set_mode(0o755); - std::fs::set_permissions(&target_path, permissions) - .with_context(|| format!("failed to chmod {}", target_path.display()))?; - } - - Ok(target_path) -} - -fn refresh_desktop_indexes(applications_dir: &Path) { - run_if_available("update-desktop-database", [applications_dir.as_os_str()]); -} - -fn install_ghostty_runtime( - workspace_root: &Path, - resources_dir: &Path, - locale_dir: &Path, - terminfo_dir: &Path, -) -> anyhow::Result<()> { - let staging_dir = workspace_root - .join("target") - .join("taskers-ghostty-install"); - let status = ProcessCommand::new("zig") - .current_dir(workspace_root.join("vendor").join("ghostty")) - .args([ - "build", - "taskers-bridge", - "-Dapp-runtime=gtk", - "-Demit-exe=false", - "-Dgtk-wayland=false", - "-Dstrip=true", - "-Di18n=false", - "--summary", - "none", - "--prefix", - ]) - .arg(&staging_dir) - .status() - .context("failed to invoke zig build for Ghostty runtime assets")?; - if !status.success() { - anyhow::bail!("zig build for Ghostty runtime assets exited with status {status}"); - } - - copy_directory(&staging_dir.join("share").join("ghostty"), resources_dir).with_context( - || { - format!( - "failed to copy Ghostty resources to {}", - resources_dir.display() - ) - }, - )?; - copy_directory(&staging_dir.join("lib"), &resources_dir.join("lib")).with_context(|| { - format!( - "failed to copy Ghostty bridge libraries to {}", - resources_dir.join("lib").display() - ) - })?; - copy_directory(&staging_dir.join("share").join("locale"), locale_dir) - .with_context(|| format!("failed to copy locale files to {}", locale_dir.display()))?; - copy_directory(&staging_dir.join("share").join("terminfo"), terminfo_dir) - .with_context(|| format!("failed to copy terminfo to {}", terminfo_dir.display()))?; - let embedded_terminfo_dir = resources_dir - .parent() - .map(|path| path.join("terminfo")) - .ok_or_else(|| anyhow::anyhow!("ghostty resources dir has no parent"))?; - copy_directory( - &staging_dir.join("share").join("terminfo"), - &embedded_terminfo_dir, - ) - .with_context(|| { - format!( - "failed to copy embedded terminfo to {}", - embedded_terminfo_dir.display() - ) - })?; - std::fs::write( - resources_dir.join(".taskers-runtime-version"), - env!("CARGO_PKG_VERSION"), - ) - .with_context(|| { - format!( - "failed to write Ghostty runtime version marker to {}", - resources_dir.join(".taskers-runtime-version").display() - ) - })?; - - Ok(()) -} - -fn copy_directory(source: &Path, destination: &Path) -> anyhow::Result<()> { - std::fs::create_dir_all(destination)?; - - for entry in std::fs::read_dir(source)? { - let entry = entry?; - let source_path = entry.path(); - let destination_path = destination.join(entry.file_name()); - let file_type = entry.file_type()?; - - if file_type.is_dir() { - copy_directory(&source_path, &destination_path)?; - } else if file_type.is_symlink() { - #[cfg(unix)] - { - if let Some(parent) = destination_path.parent() { - std::fs::create_dir_all(parent)?; - } - if destination_path.exists() { - std::fs::remove_file(&destination_path)?; - } - let target = std::fs::read_link(&source_path)?; - std::os::unix::fs::symlink(target, &destination_path)?; - } - } else if file_type.is_file() { - if let Some(parent) = destination_path.parent() { - std::fs::create_dir_all(parent)?; - } - std::fs::copy(&source_path, &destination_path)?; - } - } - - Ok(()) -} - -fn run_if_available(program: &str, args: I) -where - I: IntoIterator, - S: AsRef, -{ - let Some(path) = env::var_os("PATH") else { - return; - }; - let Some(resolved) = env::split_paths(&path) - .map(|entry| entry.join(program)) - .find(|candidate| candidate.exists()) - else { - return; - }; - - let mut command = ProcessCommand::new(resolved); - for arg in args { - command.arg(arg); - } - let _ = command.status(); -} - #[cfg(test)] mod tests { use std::sync::Mutex; diff --git a/crates/taskers-launcher/Cargo.toml b/crates/taskers-launcher/Cargo.toml new file mode 100644 index 0000000..d45658f --- /dev/null +++ b/crates/taskers-launcher/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "taskers" +description = "Cross-platform launcher for the taskers workspace app." +edition.workspace = true +homepage.workspace = true +license.workspace = true +readme = "../../README.md" +repository.workspace = true +version.workspace = true + +[[bin]] +name = "taskers" +path = "src/main.rs" + +[dependencies] +anyhow.workspace = true +serde.workspace = true +serde_json.workspace = true +sha2.workspace = true +tar.workspace = true +taskers-paths.workspace = true +ureq.workspace = true +xz2.workspace = true +zip.workspace = true + +[dev-dependencies] +tempfile.workspace = true diff --git a/crates/taskers-launcher/assets/taskers-codex-notify.sh b/crates/taskers-launcher/assets/taskers-codex-notify.sh new file mode 100644 index 0000000..4527b79 --- /dev/null +++ b/crates/taskers-launcher/assets/taskers-codex-notify.sh @@ -0,0 +1,28 @@ +#!/bin/sh +set -eu + +payload=${1-} +message= +taskers_ctl=${TASKERS_CTL_PATH:-} + +if [ -n "$payload" ]; then + if command -v jq >/dev/null 2>&1; then + message=$( + printf '%s' "$payload" \ + | jq -r '."last-assistant-message" // .message // .title // empty' 2>/dev/null \ + | head -c 160 + ) + fi +fi + +if [ -z "$message" ]; then + message="Turn complete" +fi + +if [ -z "$taskers_ctl" ] && command -v taskersctl >/dev/null 2>&1; then + taskers_ctl=$(command -v taskersctl) +fi + +if [ -n "$taskers_ctl" ] && [ -x "$taskers_ctl" ]; then + "$taskers_ctl" notify --title Codex --body "$message" --agent codex >/dev/null 2>&1 || true +fi diff --git a/crates/taskers-launcher/assets/taskers.desktop.in b/crates/taskers-launcher/assets/taskers.desktop.in new file mode 100644 index 0000000..07ac656 --- /dev/null +++ b/crates/taskers-launcher/assets/taskers.desktop.in @@ -0,0 +1,13 @@ +[Desktop Entry] +Version=1.0 +Type=Application +Name=Taskers +Comment=Agent-first terminal workspace +Exec={{EXEC}} +TryExec={{EXEC}} +Icon=taskers +Terminal=false +Categories=Development; +StartupNotify=true +StartupWMClass=taskers +X-GNOME-UsesNotifications=true diff --git a/crates/taskers-launcher/assets/taskers.svg b/crates/taskers-launcher/assets/taskers.svg new file mode 100644 index 0000000..de9d944 --- /dev/null +++ b/crates/taskers-launcher/assets/taskers.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/crates/taskers-launcher/src/lib.rs b/crates/taskers-launcher/src/lib.rs new file mode 100644 index 0000000..95a21ab --- /dev/null +++ b/crates/taskers-launcher/src/lib.rs @@ -0,0 +1,824 @@ +use std::{ + collections::BTreeMap, + env, + ffi::{OsStr, OsString}, + fs, + io::{self, Read}, + path::{Path, PathBuf}, + process::{Command, ExitStatus}, + time::{SystemTime, UNIX_EPOCH}, +}; + +use anyhow::{Context, Result, anyhow, bail}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use tar::Archive; +use taskers_paths::{ + HostPlatform, default_macos_applications_link_path, default_release_install_root, +}; +use xz2::read::XzDecoder; +use zip::ZipArchive; + +const INSTALL_ROOT_ENV: &str = "TASKERS_INSTALL_ROOT"; +const MANIFEST_URL_ENV: &str = "TASKERS_RELEASE_MANIFEST_URL"; +const SKIP_CODESIGN_VERIFY_ENV: &str = "TASKERS_SKIP_CODESIGN_VERIFY"; + +pub fn run() -> Result { + let args = env::args_os().skip(1).collect::>(); + let installation = ManagedInstallation::ensure_installed(env!("CARGO_PKG_VERSION"))?; + + if installation.platform == HostPlatform::Linux { + installation.install_linux_user_assets()?; + } + + installation.launch(&args) +} + +#[derive(Debug, Deserialize, Serialize)] +struct ReleaseManifest { + version: String, + artifacts: BTreeMap, + #[serde(default)] + manual_downloads: BTreeMap, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +struct ReleaseArtifact { + kind: ArtifactKind, + url: String, + sha256: String, + #[serde(default)] + size_bytes: Option, + #[serde(default)] + minimum_os_version: Option, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +struct ManualDownload { + url: String, + sha256: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +enum ArtifactKind { + #[serde(rename = "linux_bundle_v1")] + LinuxBundleV1, + #[serde(rename = "macos_app_zip_v1")] + MacosAppZipV1, +} + +#[derive(Debug)] +struct ManagedInstallation { + platform: HostPlatform, + target_triple: String, + version: String, + bundle_root: PathBuf, +} + +impl ManagedInstallation { + fn ensure_installed(version: &str) -> Result { + let platform = HostPlatform::detect(); + let target_triple = current_target_triple(platform)?; + let install_root = env::var_os(INSTALL_ROOT_ENV) + .map(PathBuf::from) + .unwrap_or_else(default_release_install_root); + let bundle_root = bundle_root(&install_root, version, &target_triple, platform); + let installation = Self { + platform, + target_triple: target_triple.to_string(), + version: version.to_string(), + bundle_root, + }; + + if installation.is_complete() { + installation.install_platform_integrations()?; + return Ok(installation); + } + + let manifest = installation.load_manifest()?; + let artifact = manifest + .artifacts + .get(&installation.target_triple) + .with_context(|| { + format!( + "release manifest does not contain an artifact for {}", + installation.target_triple + ) + })?; + installation.install_artifact(&manifest, artifact)?; + installation.install_platform_integrations()?; + Ok(installation) + } + + fn launch(&self, args: &[OsString]) -> Result { + let executable = self.executable_path(); + let mut command = Command::new(&executable); + command.args(args); + + if self.platform == HostPlatform::Linux { + command.env("TASKERS_CTL_PATH", self.taskersctl_path()); + command.env("TASKERS_GHOSTTY_RUNTIME_DIR", self.ghostty_resources_path()); + command.env("GHOSTTY_RESOURCES_DIR", self.ghostty_resources_path()); + command.env("TERMINFO", self.terminfo_path()); + command.env("TASKERS_DISABLE_GHOSTTY_RUNTIME_BOOTSTRAP", "1"); + } + + command + .status() + .with_context(|| format!("failed to launch {}", executable.display())) + } + + fn load_manifest(&self) -> Result { + let manifest_url = env::var(MANIFEST_URL_ENV) + .unwrap_or_else(|_| default_manifest_url(&self.version)); + let manifest_bytes = read_source_bytes(&manifest_url) + .with_context(|| format!("failed to load release manifest from {manifest_url}"))?; + let manifest: ReleaseManifest = serde_json::from_slice(&manifest_bytes) + .with_context(|| format!("failed to decode release manifest from {manifest_url}"))?; + if manifest.version != self.version { + bail!( + "release manifest version {} does not match launcher version {}", + manifest.version, + self.version + ); + } + let _ = &manifest.manual_downloads; + Ok(manifest) + } + + fn install_artifact( + &self, + _manifest: &ReleaseManifest, + artifact: &ReleaseArtifact, + ) -> Result<()> { + validate_artifact_kind(self.platform, artifact.kind)?; + + let version_root = self + .bundle_root + .parent() + .ok_or_else(|| anyhow!("bundle root {} has no parent", self.bundle_root.display()))?; + fs::create_dir_all(version_root) + .with_context(|| format!("failed to create {}", version_root.display()))?; + + let timestamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis(); + let staging_root = version_root.join(format!(".install-{}-{timestamp}", std::process::id())); + if staging_root.exists() { + remove_path(&staging_root)?; + } + fs::create_dir_all(&staging_root) + .with_context(|| format!("failed to create {}", staging_root.display()))?; + + let download_path = staging_root.join("artifact.download"); + fetch_source_to_path(&artifact.url, &download_path).with_context(|| { + format!( + "failed to download release artifact for {} from {}", + self.target_triple, artifact.url + ) + })?; + + if let Some(expected_size) = artifact.size_bytes { + let actual_size = fs::metadata(&download_path) + .with_context(|| format!("failed to stat {}", download_path.display()))? + .len(); + if actual_size != expected_size { + bail!( + "artifact size mismatch for {}: expected {}, got {}", + artifact.url, + expected_size, + actual_size + ); + } + } + + let digest = sha256_path(&download_path)?; + if digest != artifact.sha256.to_ascii_lowercase() { + bail!( + "artifact checksum mismatch for {}: expected {}, got {}", + artifact.url, + artifact.sha256, + digest + ); + } + + let unpack_root = staging_root.join("unpacked"); + fs::create_dir_all(&unpack_root) + .with_context(|| format!("failed to create {}", unpack_root.display()))?; + + match artifact.kind { + ArtifactKind::LinuxBundleV1 => unpack_linux_bundle(&download_path, &unpack_root)?, + ArtifactKind::MacosAppZipV1 => unpack_macos_zip(&download_path, &unpack_root)?, + } + + let staged_bundle_root = unpack_root; + if !validate_bundle_layout(self.platform, &staged_bundle_root) { + bail!( + "artifact {} did not unpack the expected layout into {}", + artifact.url, + staged_bundle_root.display() + ); + } + + if self.platform == HostPlatform::Macos { + verify_codesign(&staged_bundle_root.join("Taskers.app"))?; + } + + if self.bundle_root.exists() { + remove_path(&self.bundle_root)?; + } + if let Some(parent) = self.bundle_root.parent() { + fs::create_dir_all(parent) + .with_context(|| format!("failed to create {}", parent.display()))?; + } + fs::rename(&staged_bundle_root, &self.bundle_root).with_context(|| { + format!( + "failed to move {} to {}", + staged_bundle_root.display(), + self.bundle_root.display() + ) + })?; + remove_path(&staging_root)?; + + Ok(()) + } + + fn install_platform_integrations(&self) -> Result<()> { + if self.platform == HostPlatform::Macos { + self.refresh_macos_app_link()?; + } + Ok(()) + } + + fn install_linux_user_assets(&self) -> Result<()> { + let launcher = env::current_exe().context("failed to resolve current launcher path")?; + let xdg_data_home = xdg_data_home()?; + let applications_dir = xdg_data_home.join("applications"); + let icons_dir = xdg_data_home + .join("icons") + .join("hicolor") + .join("scalable") + .join("apps"); + let xdg_bin_home = xdg_bin_home()?; + fs::create_dir_all(&applications_dir) + .with_context(|| format!("failed to create {}", applications_dir.display()))?; + fs::create_dir_all(&icons_dir) + .with_context(|| format!("failed to create {}", icons_dir.display()))?; + fs::create_dir_all(&xdg_bin_home) + .with_context(|| format!("failed to create {}", xdg_bin_home.display()))?; + + let desktop_entry = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/assets/taskers.desktop.in" + )) + .replace("{{EXEC}}", &desktop_exec(&launcher)); + fs::write( + applications_dir.join("dev.taskers.app.desktop"), + desktop_entry, + ) + .with_context(|| format!("failed to write {}", applications_dir.display()))?; + fs::write( + icons_dir.join("taskers.svg"), + include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/assets/taskers.svg")), + ) + .with_context(|| format!("failed to write {}", icons_dir.display()))?; + + let notify_path = xdg_bin_home.join("taskers-codex-notify"); + write_executable( + ¬ify_path, + include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/assets/taskers-codex-notify.sh" + )), + )?; + + refresh_desktop_indexes(&applications_dir); + Ok(()) + } + + fn refresh_macos_app_link(&self) -> Result<()> { + let Some(link_path) = default_macos_applications_link_path() else { + return Ok(()); + }; + + let target = self.bundle_root.join("Taskers.app"); + if let Some(parent) = link_path.parent() { + fs::create_dir_all(parent) + .with_context(|| format!("failed to create {}", parent.display()))?; + } + + if let Ok(metadata) = fs::symlink_metadata(&link_path) { + if metadata.file_type().is_symlink() { + fs::remove_file(&link_path) + .with_context(|| format!("failed to remove {}", link_path.display()))?; + } else { + return Ok(()); + } + } + + #[cfg(unix)] + std::os::unix::fs::symlink(&target, &link_path).with_context(|| { + format!( + "failed to symlink {} -> {}", + link_path.display(), + target.display() + ) + })?; + + Ok(()) + } + + fn is_complete(&self) -> bool { + validate_bundle_layout(self.platform, &self.bundle_root) + } + + fn executable_path(&self) -> PathBuf { + match self.platform { + HostPlatform::Linux => self.bundle_root.join("bin").join("taskers"), + HostPlatform::Macos => self + .bundle_root + .join("Taskers.app") + .join("Contents") + .join("MacOS") + .join("Taskers"), + HostPlatform::Other => self.bundle_root.join("taskers"), + } + } + + fn taskersctl_path(&self) -> PathBuf { + match self.platform { + HostPlatform::Linux => self.bundle_root.join("bin").join("taskersctl"), + HostPlatform::Macos => self + .bundle_root + .join("Taskers.app") + .join("Contents") + .join("Resources") + .join("bin") + .join("taskersctl"), + HostPlatform::Other => self.bundle_root.join("taskersctl"), + } + } + + fn ghostty_resources_path(&self) -> PathBuf { + match self.platform { + HostPlatform::Linux => self.bundle_root.join("ghostty"), + HostPlatform::Macos => self + .bundle_root + .join("Taskers.app") + .join("Contents") + .join("Resources") + .join("ghostty"), + HostPlatform::Other => self.bundle_root.join("ghostty"), + } + } + + fn terminfo_path(&self) -> PathBuf { + match self.platform { + HostPlatform::Linux => self.bundle_root.join("terminfo"), + HostPlatform::Macos => self + .bundle_root + .join("Taskers.app") + .join("Contents") + .join("Resources") + .join("terminfo"), + HostPlatform::Other => self.bundle_root.join("terminfo"), + } + } +} + +fn validate_artifact_kind(platform: HostPlatform, kind: ArtifactKind) -> Result<()> { + match (platform, kind) { + (HostPlatform::Linux, ArtifactKind::LinuxBundleV1) + | (HostPlatform::Macos, ArtifactKind::MacosAppZipV1) => Ok(()), + _ => bail!("artifact kind {kind:?} is incompatible with platform {platform:?}"), + } +} + +fn validate_bundle_layout(platform: HostPlatform, bundle_root: &Path) -> bool { + match platform { + HostPlatform::Linux => { + bundle_root.join("bin").join("taskers").is_file() + && bundle_root.join("bin").join("taskersctl").is_file() + && bundle_root.join("ghostty").is_dir() + && bundle_root + .join("ghostty") + .join("lib") + .join("libtaskers_ghostty_bridge.so") + .is_file() + && bundle_root.join("terminfo").is_dir() + } + HostPlatform::Macos => { + bundle_root + .join("Taskers.app") + .join("Contents") + .join("MacOS") + .join("Taskers") + .is_file() + && bundle_root + .join("Taskers.app") + .join("Contents") + .join("Resources") + .join("bin") + .join("taskersctl") + .is_file() + && bundle_root + .join("Taskers.app") + .join("Contents") + .join("Resources") + .join("ghostty") + .is_dir() + && bundle_root + .join("Taskers.app") + .join("Contents") + .join("Resources") + .join("terminfo") + .is_dir() + } + HostPlatform::Other => false, + } +} + +fn current_target_triple(platform: HostPlatform) -> Result<&'static str> { + match (platform, env::consts::ARCH) { + (HostPlatform::Linux, "x86_64") => Ok("x86_64-unknown-linux-gnu"), + (HostPlatform::Macos, "aarch64") => Ok("aarch64-apple-darwin"), + (HostPlatform::Macos, "x86_64") => Ok("x86_64-apple-darwin"), + _ => bail!( + "unsupported platform/architecture combination: {:?}/{}", + platform, + env::consts::ARCH + ), + } +} + +fn bundle_root( + install_root: &Path, + version: &str, + target_triple: &str, + platform: HostPlatform, +) -> PathBuf { + match platform { + HostPlatform::Linux => install_root.join(version).join(target_triple), + HostPlatform::Macos => install_root.join(version), + HostPlatform::Other => install_root.join(version).join(target_triple), + } +} + +fn default_manifest_url(version: &str) -> String { + let repository = env!("CARGO_PKG_REPOSITORY").trim_end_matches('/'); + format!( + "{repository}/releases/download/v{version}/taskers-manifest-v{version}.json" + ) +} + +fn read_source_bytes(source: &str) -> Result> { + if let Some(path) = file_url_to_path(source) { + return fs::read(&path).with_context(|| format!("failed to read {}", path.display())); + } + if let Some(path) = local_path_source(source) { + return fs::read(&path).with_context(|| format!("failed to read {}", path.display())); + } + + let response = ureq::get(source) + .call() + .with_context(|| format!("failed to fetch {source}"))?; + let mut reader = response.into_reader(); + let mut bytes = Vec::new(); + reader + .read_to_end(&mut bytes) + .with_context(|| format!("failed to read response body from {source}"))?; + Ok(bytes) +} + +fn fetch_source_to_path(source: &str, destination: &Path) -> Result<()> { + if let Some(parent) = destination.parent() { + fs::create_dir_all(parent) + .with_context(|| format!("failed to create {}", parent.display()))?; + } + + if let Some(path) = file_url_to_path(source).or_else(|| local_path_source(source)) { + fs::copy(&path, destination).with_context(|| { + format!("failed to copy {} to {}", path.display(), destination.display()) + })?; + return Ok(()); + } + + let response = ureq::get(source) + .call() + .with_context(|| format!("failed to fetch {source}"))?; + let mut reader = response.into_reader(); + let mut file = fs::File::create(destination) + .with_context(|| format!("failed to create {}", destination.display()))?; + io::copy(&mut reader, &mut file) + .with_context(|| format!("failed to write {}", destination.display()))?; + Ok(()) +} + +fn file_url_to_path(value: &str) -> Option { + value + .strip_prefix("file://") + .map(|path| PathBuf::from(path)) +} + +fn local_path_source(value: &str) -> Option { + if value.contains("://") { + None + } else { + Some(PathBuf::from(value)) + } +} + +fn sha256_path(path: &Path) -> Result { + let mut file = + fs::File::open(path).with_context(|| format!("failed to open {}", path.display()))?; + let mut hasher = Sha256::new(); + let mut buffer = [0u8; 8192]; + + loop { + let read = file + .read(&mut buffer) + .with_context(|| format!("failed to read {}", path.display()))?; + if read == 0 { + break; + } + hasher.update(&buffer[..read]); + } + + let digest = hasher.finalize(); + Ok(digest.iter().map(|byte| format!("{byte:02x}")).collect()) +} + +fn unpack_linux_bundle(archive_path: &Path, destination: &Path) -> Result<()> { + let file = + fs::File::open(archive_path).with_context(|| format!("failed to open {}", archive_path.display()))?; + let decoder = XzDecoder::new(file); + let mut archive = Archive::new(decoder); + archive + .unpack(destination) + .with_context(|| format!("failed to unpack {}", archive_path.display())) +} + +fn unpack_macos_zip(zip_path: &Path, destination: &Path) -> Result<()> { + let file = + fs::File::open(zip_path).with_context(|| format!("failed to open {}", zip_path.display()))?; + let mut archive = ZipArchive::new(file) + .with_context(|| format!("failed to decode {}", zip_path.display()))?; + + for index in 0..archive.len() { + let mut entry = archive + .by_index(index) + .with_context(|| format!("failed to open zip entry {index}"))?; + let relative = entry + .enclosed_name() + .ok_or_else(|| anyhow!("zip entry {} escaped the destination", entry.name()))? + .to_path_buf(); + let output_path = destination.join(relative); + + if entry.name().ends_with('/') { + fs::create_dir_all(&output_path) + .with_context(|| format!("failed to create {}", output_path.display()))?; + continue; + } + + if let Some(parent) = output_path.parent() { + fs::create_dir_all(parent) + .with_context(|| format!("failed to create {}", parent.display()))?; + } + + let mut output = fs::File::create(&output_path) + .with_context(|| format!("failed to create {}", output_path.display()))?; + io::copy(&mut entry, &mut output) + .with_context(|| format!("failed to write {}", output_path.display()))?; + + #[cfg(unix)] + if let Some(mode) = entry.unix_mode() { + use std::os::unix::fs::PermissionsExt; + + fs::set_permissions(&output_path, fs::Permissions::from_mode(mode)).with_context( + || format!("failed to chmod {}", output_path.display()), + )?; + } + } + + Ok(()) +} + +fn verify_codesign(app_path: &Path) -> Result<()> { + if env::var_os(SKIP_CODESIGN_VERIFY_ENV).is_some() { + return Ok(()); + } + + let status = Command::new("codesign") + .args(["--verify", "--deep", "--strict"]) + .arg(app_path) + .status() + .with_context(|| format!("failed to invoke codesign for {}", app_path.display()))?; + if !status.success() { + bail!("codesign verification failed for {}", app_path.display()); + } + Ok(()) +} + +fn remove_path(path: &Path) -> Result<()> { + if !path.exists() { + return Ok(()); + } + + let metadata = + fs::symlink_metadata(path).with_context(|| format!("failed to stat {}", path.display()))?; + if metadata.file_type().is_symlink() || metadata.is_file() { + fs::remove_file(path).with_context(|| format!("failed to remove {}", path.display()))?; + } else { + fs::remove_dir_all(path) + .with_context(|| format!("failed to remove {}", path.display()))?; + } + Ok(()) +} + +fn xdg_bin_home() -> Result { + if let Some(path) = env::var_os("XDG_BIN_HOME").map(PathBuf::from) { + return Ok(path); + } + let home = env::var_os("HOME") + .map(PathBuf::from) + .context("HOME is not set and XDG_BIN_HOME is unavailable")?; + Ok(home.join(".local").join("bin")) +} + +fn xdg_data_home() -> Result { + if let Some(path) = env::var_os("XDG_DATA_HOME").map(PathBuf::from) { + return Ok(path); + } + let home = env::var_os("HOME") + .map(PathBuf::from) + .context("HOME is not set and XDG_DATA_HOME is unavailable")?; + Ok(home.join(".local").join("share")) +} + +fn desktop_exec(path: &Path) -> String { + let raw = path.display().to_string(); + raw.replace('\\', "\\\\").replace(' ', "\\ ") +} + +fn write_executable(path: &Path, contents: &str) -> Result<()> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .with_context(|| format!("failed to create {}", parent.display()))?; + } + fs::write(path, contents).with_context(|| format!("failed to write {}", path.display()))?; + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + + let mut permissions = fs::metadata(path) + .with_context(|| format!("failed to stat {}", path.display()))? + .permissions(); + permissions.set_mode(0o755); + fs::set_permissions(path, permissions) + .with_context(|| format!("failed to chmod {}", path.display()))?; + } + + Ok(()) +} + +fn refresh_desktop_indexes(applications_dir: &Path) { + run_if_available("update-desktop-database", [applications_dir.as_os_str()]); +} + +fn run_if_available(program: &str, args: I) +where + I: IntoIterator, + S: AsRef, +{ + let Some(path) = env::var_os("PATH") else { + return; + }; + let Some(resolved) = env::split_paths(&path) + .map(|entry| entry.join(program)) + .find(|candidate| candidate.exists()) + else { + return; + }; + + let mut command = Command::new(resolved); + for arg in args { + command.arg(arg); + } + let _ = command.status(); +} + +#[cfg(test)] +mod tests { + use super::{ + ArtifactKind, ManagedInstallation, ReleaseArtifact, ReleaseManifest, bundle_root, + current_target_triple, default_manifest_url, sha256_path, + }; + use std::{collections::BTreeMap, fs, path::PathBuf}; + use tar::Builder; + use taskers_paths::HostPlatform; + use tempfile::tempdir; + use xz2::write::XzEncoder; + + #[test] + fn default_manifest_url_uses_exact_version() { + let url = default_manifest_url("0.2.1"); + assert!(url.ends_with("/releases/download/v0.2.1/taskers-manifest-v0.2.1.json")); + } + + #[test] + fn bundle_roots_match_platform_layout() { + let root = PathBuf::from("/tmp/taskers"); + assert_eq!( + bundle_root( + &root, + "0.2.1", + "x86_64-unknown-linux-gnu", + HostPlatform::Linux + ), + PathBuf::from("/tmp/taskers/0.2.1/x86_64-unknown-linux-gnu") + ); + assert_eq!( + bundle_root(&root, "0.2.1", "aarch64-apple-darwin", HostPlatform::Macos), + PathBuf::from("/tmp/taskers/0.2.1") + ); + } + + #[test] + fn linux_bundle_install_from_local_manifest() { + let temp = tempdir().expect("tempdir"); + let install_root = temp.path().join("install"); + let bundle_dir = temp.path().join("bundle"); + fs::create_dir_all(bundle_dir.join("bin")).expect("bin dir"); + fs::create_dir_all(bundle_dir.join("ghostty").join("lib")).expect("ghostty dir"); + fs::create_dir_all(bundle_dir.join("terminfo").join("g")).expect("terminfo dir"); + fs::write(bundle_dir.join("bin").join("taskers"), "#!/bin/sh\nexit 0\n").expect("taskers"); + fs::write(bundle_dir.join("bin").join("taskersctl"), "#!/bin/sh\nexit 0\n") + .expect("taskersctl"); + fs::write(bundle_dir.join("ghostty").join(".taskers-runtime-version"), "0.2.1") + .expect("ghostty version"); + fs::write( + bundle_dir + .join("ghostty") + .join("lib") + .join("libtaskers_ghostty_bridge.so"), + "bridge", + ) + .expect("bridge"); + fs::write(bundle_dir.join("terminfo").join("g").join("ghostty"), "ghostty") + .expect("terminfo"); + + let archive_path = temp.path().join("taskers-linux-bundle.tar.xz"); + { + let file = fs::File::create(&archive_path).expect("archive"); + let encoder = XzEncoder::new(file, 6); + let mut tar = Builder::new(encoder); + tar.append_dir_all(".", &bundle_dir).expect("append"); + tar.into_inner() + .expect("encoder") + .finish() + .expect("finish"); + } + + let checksum = sha256_path(&archive_path).expect("sha"); + let target = current_target_triple(HostPlatform::Linux).expect("target"); + let manifest_path = temp.path().join("manifest.json"); + let manifest = ReleaseManifest { + version: env!("CARGO_PKG_VERSION").to_string(), + artifacts: BTreeMap::from([( + target.to_string(), + ReleaseArtifact { + kind: ArtifactKind::LinuxBundleV1, + url: archive_path.display().to_string(), + sha256: checksum, + size_bytes: None, + minimum_os_version: None, + }, + )]), + manual_downloads: BTreeMap::new(), + }; + fs::write( + &manifest_path, + serde_json::to_vec_pretty(&manifest).expect("manifest json"), + ) + .expect("manifest write"); + + unsafe { + std::env::set_var("TASKERS_INSTALL_ROOT", &install_root); + std::env::set_var("TASKERS_RELEASE_MANIFEST_URL", &manifest_path); + } + let installation = + ManagedInstallation::ensure_installed(env!("CARGO_PKG_VERSION")).expect("install"); + unsafe { + std::env::remove_var("TASKERS_INSTALL_ROOT"); + std::env::remove_var("TASKERS_RELEASE_MANIFEST_URL"); + } + + assert!(installation.executable_path().is_file()); + assert!(installation.taskersctl_path().is_file()); + assert!(installation.ghostty_resources_path().is_dir()); + assert!(installation.terminfo_path().is_dir()); + } +} diff --git a/crates/taskers-launcher/src/main.rs b/crates/taskers-launcher/src/main.rs new file mode 100644 index 0000000..d154385 --- /dev/null +++ b/crates/taskers-launcher/src/main.rs @@ -0,0 +1,11 @@ +fn main() { + let exit_code = match taskers::run() { + Ok(status) => status.code().unwrap_or(0), + Err(error) => { + eprintln!("taskers launcher failed: {error:#}"); + 1 + } + }; + + std::process::exit(exit_code); +} diff --git a/crates/taskers-paths/src/lib.rs b/crates/taskers-paths/src/lib.rs index 7970ff0..bb708f2 100644 --- a/crates/taskers-paths/src/lib.rs +++ b/crates/taskers-paths/src/lib.rs @@ -211,6 +211,15 @@ pub fn default_ghostty_runtime_dir() -> PathBuf { TaskersPaths::detect().ghostty_runtime_dir } +pub fn default_release_install_root() -> PathBuf { + TaskersPaths::detect().data_dir.join("releases") +} + +pub fn default_macos_applications_link_path() -> Option { + (HostPlatform::detect() == HostPlatform::Macos) + .then_some(home_applications_link_path(&EnvPaths::current())) +} + fn platform_config_dir(platform: HostPlatform, env_paths: &EnvPaths) -> PathBuf { match platform { HostPlatform::Macos => home_library_dir(env_paths, "Application Support"), @@ -338,6 +347,14 @@ fn home_library_cache_dir(env_paths: &EnvPaths) -> PathBuf { .unwrap_or_else(|| temp_root().join("cache")) } +fn home_applications_link_path(env_paths: &EnvPaths) -> PathBuf { + env_paths + .home + .clone() + .map(|path| path.join("Applications").join("Taskers.app")) + .unwrap_or_else(|| temp_root().join("applications").join("Taskers.app")) +} + fn temp_root() -> PathBuf { env::temp_dir().join("taskers") } @@ -432,4 +449,45 @@ mod tests { ); assert_eq!(paths.ghostty_runtime_dir(), &PathBuf::from("/work/ghostty")); } + + #[test] + fn release_install_roots_follow_platform_defaults() { + let mac = EnvPaths { + home: Some(PathBuf::from("/Users/notes")), + ..EnvPaths::default() + }; + let linux = EnvPaths { + home: Some(PathBuf::from("/home/notes")), + xdg_data_home: Some(PathBuf::from("/tmp/data")), + ..EnvPaths::default() + }; + + assert_eq!( + TaskersPaths::from_env(HostPlatform::Macos, &mac) + .data_dir() + .join("releases"), + PathBuf::from(format!( + "/Users/notes/Library/Application Support/{APP_ID}/releases" + )) + ); + assert_eq!( + TaskersPaths::from_env(HostPlatform::Linux, &linux) + .data_dir() + .join("releases"), + PathBuf::from("/tmp/data/taskers/releases") + ); + } + + #[test] + fn macos_applications_link_uses_home_applications() { + let env = EnvPaths { + home: Some(PathBuf::from("/Users/notes")), + ..EnvPaths::default() + }; + + assert_eq!( + super::home_applications_link_path(&env), + PathBuf::from("/Users/notes/Applications/Taskers.app") + ); + } } diff --git a/docs/release.md b/docs/release.md index 15d7848..98bc151 100644 --- a/docs/release.md +++ b/docs/release.md @@ -15,7 +15,7 @@ Use this checklist before publishing a new `taskers` release. - Regenerate the README screenshots: ```bash -./scripts/capture_demo_screenshots.sh +bash scripts/capture_demo_screenshots.sh ``` - Review the updated files in `docs/screenshots/`. @@ -32,20 +32,45 @@ cargo test - Run the GTK smoke checks: ```bash -./scripts/smoke_taskers_ui.sh -./scripts/smoke_taskers_focus_churn.sh +bash scripts/smoke_taskers_ui.sh +bash scripts/smoke_taskers_focus_churn.sh ``` -- Build the Ghostty runtime asset that the published crate expects: +- Build the Linux app bundle that the published launcher expects: ```bash -./scripts/build_ghostty_runtime_bundle.sh +bash scripts/build_linux_bundle.sh ``` The output asset name must match: ```text -taskers-ghostty-runtime-v-.tar.xz +taskers-linux-bundle-v-.tar.xz +``` + +- Build the macOS release assets on macOS: + +```bash +bash scripts/generate_macos_project.sh +xcodebuild build \ + -project macos/Taskers.xcodeproj \ + -scheme TaskersMac \ + -configuration Release \ + -derivedDataPath build/macos/DerivedData \ + CODE_SIGNING_ALLOWED=NO \ + CODE_SIGNING_REQUIRED=NO +bash scripts/sign_macos_app.sh +bash scripts/package_macos_app_zip.sh +bash scripts/build_macos_dmg.sh +``` + +- By default `scripts/sign_macos_app.sh` applies an ad hoc signature so the published launcher can verify the app bundle before launch. +- Set `TASKERS_MACOS_CODESIGN_IDENTITY` to a Developer ID Application certificate name when producing release assets you intend to distribute outside local testing. + +- Build the release manifest from the generated assets: + +```bash +python3 scripts/build_release_manifest.py ``` - Dry-run crate publishing in dependency order: @@ -62,7 +87,12 @@ cargo publish --dry-run -p taskers ## 4. Publish - Create a GitHub release draft tagged `v`. -- Upload the matching Ghostty runtime bundle from `dist/`. +- Upload the generated assets from `dist/`: + - `taskers-manifest-v.json` + - `taskers-linux-bundle-v-x86_64-unknown-linux-gnu.tar.xz` + - `taskers-macos-app-v-aarch64-apple-darwin.zip` + - `taskers-macos-app-v-x86_64-apple-darwin.zip` + - `Taskers-v-universal2.dmg` - Publish the crates to crates.io in the same order as the dry-run: ```bash @@ -76,11 +106,12 @@ cargo publish -p taskers ## 5. Post-Publish Check -- Verify a clean install path: +- Verify clean launcher installs: ```bash cargo install taskers --locked taskers --demo ``` -- Confirm the published crate can bootstrap the matching runtime asset on first launch. +- Confirm the published launcher downloads the exact version-matched bundle on first launch. +- Confirm `cargo install taskers-cli --bin taskersctl --locked` still works as the standalone helper path. diff --git a/scripts/__pycache__/build_release_manifest.cpython-314.pyc b/scripts/__pycache__/build_release_manifest.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..be3a7dc352e4bd1cf2f8c82ce702231023a65f32 GIT binary patch literal 4722 zcmb6bTTmO<^{#gH7Ls^a5+IO(7$XZIn~>P`GfrZsHa3;lgxC$sc99lXqgQvY4Aw`g zW-?RL=@jyq37tu&bfzGx^FN9=GF8zk2RU%M$0=i@o>U zd*0`shvMEA7lQTl_nT4-pfAY5SdJ#}G-E}mf@Tm)T|~ICQ<&1v7Homta*_TqgP90o z=^$d6Aj#CDj#RseF(B5?F@1=0jZux8RM5bf zyCEYLGVcuNo9;McsY3|t1`R9IyP#AGO);sJrF{uF)HXzo=`i~i}aL~9lcf&$Ix&R<< z=h{;-6KgHYTLt|Y?UT#yav*v{v$VJ@6HX-GA9<2?*-Gc9_IY*0;M zwri}J*<3ft~;d;TA-cjpUY>9A@AEK@Ks_>p1(HLPPY?SRY zs*daCdWqI?u$#Of_!kAe{kqVv@Zx)}=tg7=`=LCy*m z^%;4Rv~W@OkRF2}&d!$; z_3AOZ$so{o85Th2qy|vz$d`nEB1UOUO27+gQftk6Lkz&AV?8CQS3y%~#&Y~Ugfe>V zT%{H$mYN1kW3iU$rY3}v7LZJTNzNKm1hJY;7I8_=$1E}tsL=|RL8Wc3Am;?E5QY6( zpOTiPB36>KB`KTXWicxXipb}Lyfh~&c%*n+^YFZo&lj+OMV^;Q?;sOHAq84N&ybbs zeL_ATb?lPulL7to6tGBli~wZW;lJ+KAZQlo3NqbD?Kz=S>K}7p6sakcg3W22KvW2veRn7p=z!0AmGT;AGK^udAj{h-!cX7M_gzSMU{WB4> zY4!y{S;$JWnyn#5V+utvuQ3u9WzAM9W+0U`=YqJLk>(-EVvKAf>TQjqW=$`Y@{4jS zQ3Zyh!D#dq-9Y&O(Lz3Y24(>OGd^$WSe^Ox%vMWJM`AuZ~!siD% zx4TYkzV`6ID|gPi|5qW--;13)YW@3B_p5&ASL&8nq^I{30RK%a+N%cnEH^NE$ z(F@v75DEB@f_74^PsryJC}`OJbWPv>n!f+~-)`#Q_T+V)K!5Lavm$_*6iPD)9{^B6 zEL6$33`jG>nz9zM5{Op%_lh#)PD-Wp)7 zq*kVAJ%Bv`YII4?YE)9sB+aErWs#q~jYUN!Keu95%`r=QAR%J%LQZVZF$#1*&8}dc zFf?;kCh~MP%_`=xe7ljbgM-mVMR^4oOLIaR^Ey_J0l6M*7Jv$R?C`EJbw|rbLu*~@ zN7pC+aN~D3*2h)fP~GAGXmGvb!{NH4V=eQs^uF}rVl5D>24cHTYm2RNe%FH>T@UOB z>x0KtZ-3P>Pg^ZM+FT0xD z(bG|l>MtLj6Qrz|iQ+;OG^GoGjT#hlav>KrrIFNBaQWB*#zo~sGC42dh0^Rux{yo$ zBri@Bu$W0=L0N>u5RjZ5-uxLrG3Z7nFoh zEGBMgRHDp`l%=BP*Kc{a<>5#qF-ONnjzHK+qLrPK4bQ+RA6O%i7zx?YkxXu0?gIk3 zpL|HW(>fZGOd=kLG$p$y@o>*Cel+vs^R&!mutza>Cep$krgJd2L zRUp4WMA;gtuDKgKPn?oTlhHi;(?PR|%fP2-HV{59LPH?oBTx4dp>5fFA@V@-L2~CX za#M3H$uI&N_F6NgTv5@iMOn(@PaCt-n8LY2rj!-W$k&0L40q*IXqme-MNwZM-)G4C z8FK#n`u=@XBz-UZ-8}o~^fbtxxZ?#%lhts();A zUiFV{`7hM`SF8T35B=BfFm<9;>u#pJFa@i*RQLGPi?-ndGY?6>d^R3&v?cC z*y9CPd_grldhgi1n0oSUwQG9EH(l}6XDLVbZhwS%0<;X zRApjccu&Kx6K_E69=W@8SGxD}&A_JPKBIP?-|?QWyivD%fA!8U->KPy+xFn2@Zq|r zz3%C)M-KkW&h)tMIM)WZ9G&2@@62u+>IkUFU-yUBZ)_}WNOymscAwqxpRKgiX@89l zR_S1k?tDOZK6bRMHRcLe>q%C7l4|&f>N^Uf<^93+o~^b+yH@&$O=Tc-@hUS66UFMi zvs7n%Yn^o_poWHa?Tp7#aR7qy)KH*`0&Do=@(1OOp?gQ~Guy%Q|6q24m({msR20}o P{3B$oEIveS`Vjgbtlj5? literal 0 HcmV?d00001 diff --git a/scripts/build_linux_bundle.sh b/scripts/build_linux_bundle.sh new file mode 100644 index 0000000..35abb5a --- /dev/null +++ b/scripts/build_linux_bundle.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "$0")/.." && pwd)" +version="$(sed -n 's/^version = "\(.*\)"/\1/p' "$repo_root/Cargo.toml" | head -n1)" +target="${1:-$(rustc -vV | sed -n 's/^host: //p')}" +out_dir="${2:-$repo_root/dist}" +asset_name="taskers-linux-bundle-v${version}-${target}.tar.xz" +stage_dir="$(mktemp -d "${TMPDIR:-/tmp}/taskers-linux-bundle.XXXXXX")" +prefix_dir="$stage_dir/prefix" +bundle_dir="$stage_dir/bundle" + +cleanup() { + rm -rf "$stage_dir" +} +trap cleanup EXIT + +( + cd "$repo_root" + cargo build --release -p taskers-gtk --bin taskers-gtk + cargo build --release -p taskers-cli --bin taskersctl +) + +( + cd "$repo_root/vendor/ghostty" + zig build taskers-bridge \ + -Dapp-runtime=gtk \ + -Demit-exe=false \ + -Dgtk-wayland=false \ + -Dstrip=true \ + -Di18n=false \ + --summary none \ + --prefix "$prefix_dir" +) + +mkdir -p "$bundle_dir/bin" "$bundle_dir/ghostty/lib" "$bundle_dir/ghostty/shell-integration" "$bundle_dir/terminfo" +cp "$repo_root/target/release/taskers-gtk" "$bundle_dir/bin/taskers" +cp "$repo_root/target/release/taskersctl" "$bundle_dir/bin/taskersctl" +chmod +x "$bundle_dir/bin/taskers" "$bundle_dir/bin/taskersctl" +cp "$prefix_dir/lib/libtaskers_ghostty_bridge.so" "$bundle_dir/ghostty/lib/" +cp -R "$prefix_dir/share/ghostty/shell-integration/." "$bundle_dir/ghostty/shell-integration/" +cp -R "$prefix_dir/share/terminfo/." "$bundle_dir/terminfo/" +printf '%s\n' "$version" > "$bundle_dir/ghostty/.taskers-runtime-version" + +mkdir -p "$out_dir" +XZ_OPT=-9 tar -C "$bundle_dir" -cJf "$out_dir/$asset_name" bin ghostty terminfo +printf '%s\n' "$out_dir/$asset_name" diff --git a/scripts/build_macos_dmg.sh b/scripts/build_macos_dmg.sh new file mode 100644 index 0000000..417d9a8 --- /dev/null +++ b/scripts/build_macos_dmg.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "$0")/.." && pwd)" +version="$(sed -n 's/^version = "\(.*\)"/\1/p' "$repo_root/Cargo.toml" | head -n1)" +derived_data_path="${1:-$repo_root/build/macos/DerivedData}" +app_path="${2:-$derived_data_path/Build/Products/Release/Taskers.app}" +out_dir="${3:-$repo_root/dist}" +asset_path="$out_dir/Taskers-v${version}-universal2.dmg" + +if [[ ! -d "$app_path" ]]; then + echo "expected Taskers.app at $app_path" >&2 + exit 1 +fi + +mkdir -p "$out_dir" +rm -f "$asset_path" +hdiutil create \ + -volname "Taskers" \ + -srcfolder "$app_path" \ + -ov \ + -format UDZO \ + "$asset_path" +printf '%s\n' "$asset_path" diff --git a/scripts/build_release_manifest.py b/scripts/build_release_manifest.py new file mode 100644 index 0000000..8bad7aa --- /dev/null +++ b/scripts/build_release_manifest.py @@ -0,0 +1,79 @@ +#!/usr/bin/env python3 +import argparse +import hashlib +import json +from pathlib import Path + + +def sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def artifact_entry(path: Path, kind: str, minimum_os_version: str | None = None) -> dict: + entry = { + "kind": kind, + "url": f"{base_url}/{path.name}", + "sha256": sha256(path), + "size_bytes": path.stat().st_size, + } + if minimum_os_version is not None: + entry["minimum_os_version"] = minimum_os_version + return entry + + +parser = argparse.ArgumentParser() +parser.add_argument("--repo-root", default=Path(__file__).resolve().parent.parent, type=Path) +parser.add_argument("--dist-dir", type=Path) +parser.add_argument("--version") +parser.add_argument("--base-url") +parser.add_argument("--output", type=Path) +args = parser.parse_args() + +repo_root = args.repo_root.resolve() +version = args.version +if version is None: + for line in (repo_root / "Cargo.toml").read_text(encoding="utf-8").splitlines(): + if line.startswith("version = "): + version = line.split('"')[1] + break +if version is None: + raise SystemExit("failed to discover version from Cargo.toml") + +dist_dir = (args.dist_dir or repo_root / "dist").resolve() +base_url = args.base_url or f"https://github.com/OneNoted/taskers/releases/download/v{version}" +output_path = args.output or dist_dir / f"taskers-manifest-v{version}.json" + +artifacts = {} +linux_bundle = dist_dir / f"taskers-linux-bundle-v{version}-x86_64-unknown-linux-gnu.tar.xz" +if linux_bundle.exists(): + artifacts["x86_64-unknown-linux-gnu"] = artifact_entry(linux_bundle, "linux_bundle_v1") + +for target in ("aarch64-apple-darwin", "x86_64-apple-darwin"): + archive = dist_dir / f"taskers-macos-app-v{version}-{target}.zip" + if archive.exists(): + artifacts[target] = artifact_entry( + archive, + "macos_app_zip_v1", + minimum_os_version="14.0", + ) + +manual_downloads = {} +universal_dmg = dist_dir / f"Taskers-v{version}-universal2.dmg" +if universal_dmg.exists(): + manual_downloads["macos_universal2_dmg"] = { + "url": f"{base_url}/{universal_dmg.name}", + "sha256": sha256(universal_dmg), + } + +manifest = { + "version": version, + "artifacts": artifacts, + "manual_downloads": manual_downloads, +} + +output_path.write_text(json.dumps(manifest, indent=2, sort_keys=True) + "\n", encoding="utf-8") +print(output_path) diff --git a/scripts/capture_demo_screenshots.sh b/scripts/capture_demo_screenshots.sh index 081937f..3f65254 100755 --- a/scripts/capture_demo_screenshots.sh +++ b/scripts/capture_demo_screenshots.sh @@ -138,7 +138,7 @@ start_scene() { export XDG_DATA_HOME="$temp_dir/data" export XDG_CACHE_HOME="$temp_dir/cache" export HOME="$temp_dir/home" - exec "$TARGET_DIR/taskers" \ + exec "$TARGET_DIR/taskers-gtk" \ --raw-shell \ --socket "$socket_path" \ --session "$session_path" @@ -279,7 +279,7 @@ mkdir -p "$OUT_DIR" ( cd "$REPO_ROOT" - cargo build -p taskers -p taskers-cli >/dev/null + cargo build -p taskers-gtk -p taskers-cli >/dev/null ) capture_attention_scene diff --git a/scripts/package_macos_app_zip.sh b/scripts/package_macos_app_zip.sh new file mode 100644 index 0000000..01d06e3 --- /dev/null +++ b/scripts/package_macos_app_zip.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "$0")/.." && pwd)" +version="$(sed -n 's/^version = "\(.*\)"/\1/p' "$repo_root/Cargo.toml" | head -n1)" +derived_data_path="${1:-$repo_root/build/macos/DerivedData}" +app_path="${2:-$derived_data_path/Build/Products/Release/Taskers.app}" +out_dir="${3:-$repo_root/dist}" +arch="${4:-$(uname -m)}" + +case "$arch" in + arm64|aarch64) + target="aarch64-apple-darwin" + ;; + x86_64) + target="x86_64-apple-darwin" + ;; + *) + echo "unsupported macOS architecture: $arch" >&2 + exit 1 + ;; +esac + +if [[ ! -d "$app_path" ]]; then + echo "expected Taskers.app at $app_path" >&2 + exit 1 +fi + +mkdir -p "$out_dir" +ditto -c -k --sequesterRsrc --keepParent \ + "$app_path" \ + "$out_dir/taskers-macos-app-v${version}-${target}.zip" +printf '%s\n' "$out_dir/taskers-macos-app-v${version}-${target}.zip" diff --git a/scripts/sign_macos_app.sh b/scripts/sign_macos_app.sh new file mode 100644 index 0000000..1918aaa --- /dev/null +++ b/scripts/sign_macos_app.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "$0")/.." && pwd)" +derived_data_path="${1:-$repo_root/build/macos/DerivedData}" +app_path="${2:-$derived_data_path/Build/Products/Release/Taskers.app}" +identity="${TASKERS_MACOS_CODESIGN_IDENTITY:--}" + +if [[ ! -d "$app_path" ]]; then + echo "expected Taskers.app at $app_path" >&2 + exit 1 +fi + +codesign_args=( + --force + --deep + --sign "$identity" +) + +if [[ "$identity" != "-" ]]; then + codesign_args+=( + --options runtime + --timestamp + ) +fi + +codesign "${codesign_args[@]}" "$app_path" +codesign --verify --deep --strict "$app_path" +printf '%s\n' "$app_path" diff --git a/scripts/smoke_taskers_focus_churn.sh b/scripts/smoke_taskers_focus_churn.sh index 31c5374..fe148ab 100755 --- a/scripts/smoke_taskers_focus_churn.sh +++ b/scripts/smoke_taskers_focus_churn.sh @@ -118,7 +118,7 @@ status_path="$temp_dir/status.json" ( cd "$REPO_ROOT" - cargo build -p taskers -p taskers-cli + cargo build -p taskers-gtk -p taskers-cli ) >/dev/null Xvfb "$display" -screen 0 1440x960x24 >"$temp_dir/xvfb.log" 2>&1 & @@ -134,7 +134,7 @@ wait_for_path "/tmp/.X11-unix/X${display_number}" export TASKERS_NON_UNIQUE=1 export TASKERS_TERMINAL_BACKEND=mock export TASKERS_UI_INTEGRITY_PATH="$integrity_path" - exec "$TARGET_DIR/taskers" \ + exec "$TARGET_DIR/taskers-gtk" \ --demo \ --socket "$socket_path" \ --session "$session_path" diff --git a/scripts/smoke_taskers_ui.sh b/scripts/smoke_taskers_ui.sh index 7768b8b..4b60ca7 100755 --- a/scripts/smoke_taskers_ui.sh +++ b/scripts/smoke_taskers_ui.sh @@ -64,7 +64,7 @@ session_path="$temp_dir/session.json" ( cd "$REPO_ROOT" - cargo build -p taskers -p taskers-cli + cargo build -p taskers-gtk -p taskers-cli ) >/dev/null Xvfb "$display" -screen 0 1440x960x24 >"$temp_dir/xvfb.log" 2>&1 & @@ -79,7 +79,7 @@ wait_for_path "/tmp/.X11-unix/X${display_number}" export LIBGL_ALWAYS_SOFTWARE=1 export TASKERS_NON_UNIQUE=1 export TASKERS_TERMINAL_BACKEND=mock - exec "$TARGET_DIR/taskers" \ + exec "$TARGET_DIR/taskers-gtk" \ --demo \ --socket "$socket_path" \ --session "$session_path" From 60bdf300dfcfe367222a8f49896462973cda8fa1 Mon Sep 17 00:00:00 2001 From: OneNoted Date: Tue, 17 Mar 2026 01:25:00 +0100 Subject: [PATCH 05/40] fix: polish launcher release flow --- .github/workflows/macos-preview.yml | 2 +- .github/workflows/release-assets.yml | 33 +++++ ...kers-codex-notify.sh => taskers-notify.sh} | 2 +- crates/taskers-launcher/src/lib.rs | 4 +- crates/taskers-launcher/src/main.rs | 45 ++++++- docs/release.md | 5 +- scripts/build_linux_bundle.sh | 0 scripts/build_macos_dmg.sh | 0 scripts/build_release_manifest.py | 0 scripts/package_macos_app_zip.sh | 0 scripts/sign_macos_app.sh | 0 scripts/smoke_linux_release_launcher.sh | 115 ++++++++++++++++++ 12 files changed, 199 insertions(+), 7 deletions(-) rename crates/taskers-launcher/assets/{taskers-codex-notify.sh => taskers-notify.sh} (85%) mode change 100644 => 100755 scripts/build_linux_bundle.sh mode change 100644 => 100755 scripts/build_macos_dmg.sh mode change 100644 => 100755 scripts/build_release_manifest.py mode change 100644 => 100755 scripts/package_macos_app_zip.sh mode change 100644 => 100755 scripts/sign_macos_app.sh create mode 100644 scripts/smoke_linux_release_launcher.sh diff --git a/.github/workflows/macos-preview.yml b/.github/workflows/macos-preview.yml index 6056963..57bb395 100644 --- a/.github/workflows/macos-preview.yml +++ b/.github/workflows/macos-preview.yml @@ -68,7 +68,7 @@ jobs: TASKERS_SMOKE_TEST=1 \ "${APP_PATH}/Contents/MacOS/Taskers" - - name: Package unsigned app + - name: Package signed app if: always() run: | APP_PATH="build/macos/DerivedData/Build/Products/Debug/Taskers.app" diff --git a/.github/workflows/release-assets.yml b/.github/workflows/release-assets.yml index d2f9f62..03ab26b 100644 --- a/.github/workflows/release-assets.yml +++ b/.github/workflows/release-assets.yml @@ -6,6 +6,9 @@ on: - "v*" workflow_dispatch: +permissions: + contents: write + jobs: linux-bundle: runs-on: ubuntu-latest @@ -32,6 +35,7 @@ jobs: run: | bash scripts/smoke_taskers_ui.sh bash scripts/smoke_taskers_focus_churn.sh + bash scripts/smoke_linux_release_launcher.sh - name: Upload Linux bundle uses: actions/upload-artifact@v4 @@ -96,9 +100,11 @@ jobs: run: bash scripts/package_macos_app_zip.sh - name: Upload macOS app zip + if: always() uses: actions/upload-artifact@v4 with: name: macos-app-${{ matrix.runner }} + if-no-files-found: ignore path: | dist/taskers-macos-app-v*.zip build/macos/Taskers-${{ matrix.runner }}.xcresult @@ -178,3 +184,30 @@ jobs: with: name: release-manifest path: dist/release/taskers-manifest-v*.json + + upload-github-release: + if: startsWith(github.ref, 'refs/tags/v') + needs: + - release-manifest + runs-on: ubuntu-latest + + steps: + - name: Download built assets + uses: actions/download-artifact@v4 + with: + path: dist + + - name: Flatten downloaded artifacts + run: | + mkdir -p dist/release + find dist -type f -exec cp {} dist/release/ \; + + - name: Publish draft GitHub release with assets + uses: softprops/action-gh-release@v2 + with: + draft: true + files: | + dist/release/taskers-manifest-v*.json + dist/release/taskers-linux-bundle-v*.tar.xz + dist/release/taskers-macos-app-v*.zip + dist/release/Taskers-v*-universal2.dmg diff --git a/crates/taskers-launcher/assets/taskers-codex-notify.sh b/crates/taskers-launcher/assets/taskers-notify.sh similarity index 85% rename from crates/taskers-launcher/assets/taskers-codex-notify.sh rename to crates/taskers-launcher/assets/taskers-notify.sh index 4527b79..b3a92b2 100644 --- a/crates/taskers-launcher/assets/taskers-codex-notify.sh +++ b/crates/taskers-launcher/assets/taskers-notify.sh @@ -24,5 +24,5 @@ if [ -z "$taskers_ctl" ] && command -v taskersctl >/dev/null 2>&1; then fi if [ -n "$taskers_ctl" ] && [ -x "$taskers_ctl" ]; then - "$taskers_ctl" notify --title Codex --body "$message" --agent codex >/dev/null 2>&1 || true + "$taskers_ctl" notify --title Taskers --body "$message" >/dev/null 2>&1 || true fi diff --git a/crates/taskers-launcher/src/lib.rs b/crates/taskers-launcher/src/lib.rs index 95a21ab..699bac3 100644 --- a/crates/taskers-launcher/src/lib.rs +++ b/crates/taskers-launcher/src/lib.rs @@ -285,12 +285,12 @@ impl ManagedInstallation { ) .with_context(|| format!("failed to write {}", icons_dir.display()))?; - let notify_path = xdg_bin_home.join("taskers-codex-notify"); + let notify_path = xdg_bin_home.join("taskers-notify"); write_executable( ¬ify_path, include_str!(concat!( env!("CARGO_MANIFEST_DIR"), - "/assets/taskers-codex-notify.sh" + "/assets/taskers-notify.sh" )), )?; diff --git a/crates/taskers-launcher/src/main.rs b/crates/taskers-launcher/src/main.rs index d154385..1916f79 100644 --- a/crates/taskers-launcher/src/main.rs +++ b/crates/taskers-launcher/src/main.rs @@ -1,6 +1,6 @@ fn main() { let exit_code = match taskers::run() { - Ok(status) => status.code().unwrap_or(0), + Ok(status) => exit_code_from_status(status), Err(error) => { eprintln!("taskers launcher failed: {error:#}"); 1 @@ -9,3 +9,46 @@ fn main() { std::process::exit(exit_code); } + +fn exit_code_from_status(status: std::process::ExitStatus) -> i32 { + if let Some(code) = status.code() { + return code; + } + + #[cfg(unix)] + { + use std::os::unix::process::ExitStatusExt; + + return status.signal().map_or(1, |signal| 128 + signal); + } + + #[cfg(not(unix))] + { + 1 + } +} + +#[cfg(test)] +mod tests { + use super::exit_code_from_status; + use std::process::Command; + + #[test] + fn preserves_normal_exit_codes() { + let status = Command::new("sh") + .args(["-c", "exit 7"]) + .status() + .expect("spawn shell"); + assert_eq!(exit_code_from_status(status), 7); + } + + #[cfg(unix)] + #[test] + fn maps_signals_to_failure_exit_codes() { + let status = Command::new("sh") + .args(["-c", "kill -TERM $$"]) + .status() + .expect("spawn shell"); + assert_eq!(exit_code_from_status(status), 143); + } +} diff --git a/docs/release.md b/docs/release.md index 98bc151..7eb020b 100644 --- a/docs/release.md +++ b/docs/release.md @@ -40,6 +40,7 @@ bash scripts/smoke_taskers_focus_churn.sh ```bash bash scripts/build_linux_bundle.sh +bash scripts/smoke_linux_release_launcher.sh ``` The output asset name must match: @@ -86,8 +87,8 @@ cargo publish --dry-run -p taskers ## 4. Publish -- Create a GitHub release draft tagged `v`. -- Upload the generated assets from `dist/`: +- Push the release tag so GitHub Actions can assemble the assets and attach them to a draft GitHub release. +- Confirm the draft release tagged `v` contains: - `taskers-manifest-v.json` - `taskers-linux-bundle-v-x86_64-unknown-linux-gnu.tar.xz` - `taskers-macos-app-v-aarch64-apple-darwin.zip` diff --git a/scripts/build_linux_bundle.sh b/scripts/build_linux_bundle.sh old mode 100644 new mode 100755 diff --git a/scripts/build_macos_dmg.sh b/scripts/build_macos_dmg.sh old mode 100644 new mode 100755 diff --git a/scripts/build_release_manifest.py b/scripts/build_release_manifest.py old mode 100644 new mode 100755 diff --git a/scripts/package_macos_app_zip.sh b/scripts/package_macos_app_zip.sh old mode 100644 new mode 100755 diff --git a/scripts/sign_macos_app.sh b/scripts/sign_macos_app.sh old mode 100644 new mode 100755 diff --git a/scripts/smoke_linux_release_launcher.sh b/scripts/smoke_linux_release_launcher.sh new file mode 100644 index 0000000..ec1195d --- /dev/null +++ b/scripts/smoke_linux_release_launcher.sh @@ -0,0 +1,115 @@ +#!/usr/bin/env bash +set -euo pipefail + +REPO_ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +TARGET_DIR="$REPO_ROOT/target/debug" +POLL_INTERVAL_SECONDS=0.1 +WAIT_TIMEOUT_SECONDS=30 + +choose_display_number() { + local display_number + for display_number in $(seq 99 119); do + if [[ ! -e "/tmp/.X11-unix/X${display_number}" ]]; then + printf '%s\n' "$display_number" + return 0 + fi + done + + printf '%s\n' 'could not find a free X display number between :99 and :119' >&2 + return 1 +} + +wait_for_path() { + local path=$1 + local attempts=$((WAIT_TIMEOUT_SECONDS * 10)) + + while (( attempts > 0 )); do + if [[ -e "$path" ]]; then + return 0 + fi + sleep "$POLL_INTERVAL_SECONDS" + attempts=$((attempts - 1)) + done + + printf 'timed out waiting for %s\n' "$path" >&2 + return 1 +} + +cleanup() { + local status=$? + if [[ -n "${app_pid:-}" ]]; then + kill "$app_pid" >/dev/null 2>&1 || true + fi + if [[ -n "${xvfb_pid:-}" ]]; then + kill "$xvfb_pid" >/dev/null 2>&1 || true + fi + if [[ -n "${temp_dir:-}" ]] && [[ -d "$temp_dir" ]]; then + rm -rf "$temp_dir" + fi + exit "$status" +} + +trap cleanup EXIT + +if ! command -v Xvfb >/dev/null 2>&1; then + printf '%s\n' 'Xvfb is required for the launcher smoke test.' >&2 + exit 1 +fi + +temp_dir=$(mktemp -d -t taskers-release-launcher.XXXXXX) +display_number=$(choose_display_number) +display=":${display_number}" +socket_path="$temp_dir/taskers.sock" +session_path="$temp_dir/session.json" +install_root="$temp_dir/install" +version="$(sed -n 's/^version = "\(.*\)"/\1/p' "$REPO_ROOT/Cargo.toml" | head -n1)" +target="$(rustc -vV | sed -n 's/^host: //p')" +manifest_path="$temp_dir/taskers-manifest-v${version}.json" +bundle_taskersctl="$install_root/$version/$target/bin/taskersctl" + +( + cd "$REPO_ROOT" + cargo build -p taskers --bin taskers + python3 scripts/build_release_manifest.py \ + --dist-dir "$REPO_ROOT/dist" \ + --base-url "$REPO_ROOT/dist" \ + --output "$manifest_path" +) >/dev/null + +Xvfb "$display" -screen 0 1440x960x24 >"$temp_dir/xvfb.log" 2>&1 & +xvfb_pid=$! +wait_for_path "/tmp/.X11-unix/X${display_number}" + +( + cd "$REPO_ROOT" + export DISPLAY="$display" + export GDK_BACKEND=x11 + export GSK_RENDERER=cairo + export LIBGL_ALWAYS_SOFTWARE=1 + export TASKERS_INSTALL_ROOT="$install_root" + export TASKERS_NON_UNIQUE=1 + export TASKERS_RELEASE_MANIFEST_URL="$manifest_path" + export TASKERS_TERMINAL_BACKEND=mock + exec "$TARGET_DIR/taskers" \ + --demo \ + --socket "$socket_path" \ + --session "$session_path" +) >"$temp_dir/app.log" 2>&1 & +app_pid=$! + +wait_for_path "$socket_path" +wait_for_path "$session_path" + +if [[ ! -x "$bundle_taskersctl" ]]; then + printf 'expected bundled taskersctl at %s\n' "$bundle_taskersctl" >&2 + exit 1 +fi + +sleep 5 +kill -0 "$app_pid" + +"$bundle_taskersctl" workspace new --label "Release Smoke" --socket "$socket_path" >/dev/null +sleep 1 +kill -0 "$app_pid" + +printf '%s\n' 'Taskers launcher smoke passed: release bundle installed and responded to control commands.' From 49326a536b784b0b5953599249873f5f5be15a39 Mon Sep 17 00:00:00 2001 From: OneNoted Date: Tue, 17 Mar 2026 09:59:33 +0100 Subject: [PATCH 06/40] fix: harden launcher release publication --- .github/workflows/release-assets.yml | 4 +- crates/taskers-launcher/src/lib.rs | 82 +++++++++++++++++- docs/release.md | 4 +- .../build_release_manifest.cpython-314.pyc | Bin 4722 -> 0 bytes scripts/build_release_manifest.py | 7 -- 5 files changed, 84 insertions(+), 13 deletions(-) delete mode 100644 scripts/__pycache__/build_release_manifest.cpython-314.pyc diff --git a/.github/workflows/release-assets.yml b/.github/workflows/release-assets.yml index 03ab26b..79bcc44 100644 --- a/.github/workflows/release-assets.yml +++ b/.github/workflows/release-assets.yml @@ -159,7 +159,6 @@ jobs: needs: - linux-bundle - macos-preview - - macos-universal-dmg runs-on: ubuntu-latest steps: @@ -202,7 +201,7 @@ jobs: mkdir -p dist/release find dist -type f -exec cp {} dist/release/ \; - - name: Publish draft GitHub release with assets + - name: Create draft GitHub release with assets uses: softprops/action-gh-release@v2 with: draft: true @@ -210,4 +209,3 @@ jobs: dist/release/taskers-manifest-v*.json dist/release/taskers-linux-bundle-v*.tar.xz dist/release/taskers-macos-app-v*.zip - dist/release/Taskers-v*-universal2.dmg diff --git a/crates/taskers-launcher/src/lib.rs b/crates/taskers-launcher/src/lib.rs index 699bac3..a745877 100644 --- a/crates/taskers-launcher/src/lib.rs +++ b/crates/taskers-launcher/src/lib.rs @@ -106,6 +106,7 @@ impl ManagedInstallation { installation.target_triple ) })?; + validate_minimum_os_version(platform, artifact)?; installation.install_artifact(&manifest, artifact)?; installation.install_platform_integrations()?; Ok(installation) @@ -453,6 +454,71 @@ fn current_target_triple(platform: HostPlatform) -> Result<&'static str> { } } +fn validate_minimum_os_version(platform: HostPlatform, artifact: &ReleaseArtifact) -> Result<()> { + if platform != HostPlatform::Macos { + return Ok(()); + } + + let Some(minimum_version) = artifact.minimum_os_version.as_deref() else { + return Ok(()); + }; + + let current_version = current_macos_version()?; + if version_meets_minimum(¤t_version, minimum_version)? { + return Ok(()); + } + + bail!( + "taskers requires macOS {minimum_version} or newer, but this machine reports macOS {current_version}" + ); +} + +fn current_macos_version() -> Result { + let output = Command::new("sw_vers") + .arg("-productVersion") + .output() + .context("failed to invoke sw_vers to detect the current macOS version")?; + if !output.status.success() { + bail!("sw_vers -productVersion exited with {}", output.status); + } + + let version = String::from_utf8(output.stdout) + .context("sw_vers -productVersion returned a non-UTF-8 version string")?; + let version = version.trim(); + if version.is_empty() { + bail!("sw_vers -productVersion returned an empty version string"); + } + Ok(version.to_string()) +} + +fn version_meets_minimum(current: &str, minimum: &str) -> Result { + let mut current_components = parse_version_components(current)?; + let mut minimum_components = parse_version_components(minimum)?; + let component_count = current_components.len().max(minimum_components.len()); + current_components.resize(component_count, 0); + minimum_components.resize(component_count, 0); + Ok(current_components >= minimum_components) +} + +fn parse_version_components(version: &str) -> Result> { + let mut components = Vec::new(); + for component in version.split('.') { + if component.is_empty() { + bail!("invalid version {version}: empty component"); + } + let value = component + .parse::() + .with_context(|| format!("invalid version {version}: {component} is not numeric"))?; + components.push(value); + } + + if components.is_empty() { + bail!("invalid version: expected at least one component"); + } + + Ok(components) +} + fn bundle_root( install_root: &Path, version: &str, @@ -714,7 +780,7 @@ where mod tests { use super::{ ArtifactKind, ManagedInstallation, ReleaseArtifact, ReleaseManifest, bundle_root, - current_target_triple, default_manifest_url, sha256_path, + current_target_triple, default_manifest_url, sha256_path, version_meets_minimum, }; use std::{collections::BTreeMap, fs, path::PathBuf}; use tar::Builder; @@ -821,4 +887,18 @@ mod tests { assert!(installation.ghostty_resources_path().is_dir()); assert!(installation.terminfo_path().is_dir()); } + + #[test] + fn macos_version_check_pads_missing_components() { + assert!(version_meets_minimum("15", "14.0").expect("version compare")); + assert!(version_meets_minimum("14.0", "14").expect("version compare")); + assert!(!version_meets_minimum("14", "14.1").expect("version compare")); + assert!(!version_meets_minimum("14.0.5", "14.1").expect("version compare")); + } + + #[test] + fn macos_version_check_rejects_invalid_versions() { + assert!(version_meets_minimum("14.a", "14.0").is_err()); + assert!(version_meets_minimum("14.1", "14..0").is_err()); + } } diff --git a/docs/release.md b/docs/release.md index 7eb020b..20d56d4 100644 --- a/docs/release.md +++ b/docs/release.md @@ -62,11 +62,11 @@ xcodebuild build \ CODE_SIGNING_REQUIRED=NO bash scripts/sign_macos_app.sh bash scripts/package_macos_app_zip.sh -bash scripts/build_macos_dmg.sh ``` - By default `scripts/sign_macos_app.sh` applies an ad hoc signature so the published launcher can verify the app bundle before launch. - Set `TASKERS_MACOS_CODESIGN_IDENTITY` to a Developer ID Application certificate name when producing release assets you intend to distribute outside local testing. +- `scripts/build_macos_dmg.sh` remains internal-only until notarization/stapling is wired into the release flow. Do not publish the DMG yet. - Build the release manifest from the generated assets: @@ -93,7 +93,7 @@ cargo publish --dry-run -p taskers - `taskers-linux-bundle-v-x86_64-unknown-linux-gnu.tar.xz` - `taskers-macos-app-v-aarch64-apple-darwin.zip` - `taskers-macos-app-v-x86_64-apple-darwin.zip` - - `Taskers-v-universal2.dmg` +- Publish the GitHub release so the launcher assets are publicly downloadable before publishing the crates. - Publish the crates to crates.io in the same order as the dry-run: ```bash diff --git a/scripts/__pycache__/build_release_manifest.cpython-314.pyc b/scripts/__pycache__/build_release_manifest.cpython-314.pyc deleted file mode 100644 index be3a7dc352e4bd1cf2f8c82ce702231023a65f32..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4722 zcmb6bTTmO<^{#gH7Ls^a5+IO(7$XZIn~>P`GfrZsHa3;lgxC$sc99lXqgQvY4Aw`g zW-?RL=@jyq37tu&bfzGx^FN9=GF8zk2RU%M$0=i@o>U zd*0`shvMEA7lQTl_nT4-pfAY5SdJ#}G-E}mf@Tm)T|~ICQ<&1v7Homta*_TqgP90o z=^$d6Aj#CDj#RseF(B5?F@1=0jZux8RM5bf zyCEYLGVcuNo9;McsY3|t1`R9IyP#AGO);sJrF{uF)HXzo=`i~i}aL~9lcf&$Ix&R<< z=h{;-6KgHYTLt|Y?UT#yav*v{v$VJ@6HX-GA9<2?*-Gc9_IY*0;M zwri}J*<3ft~;d;TA-cjpUY>9A@AEK@Ks_>p1(HLPPY?SRY zs*daCdWqI?u$#Of_!kAe{kqVv@Zx)}=tg7=`=LCy*m z^%;4Rv~W@OkRF2}&d!$; z_3AOZ$so{o85Th2qy|vz$d`nEB1UOUO27+gQftk6Lkz&AV?8CQS3y%~#&Y~Ugfe>V zT%{H$mYN1kW3iU$rY3}v7LZJTNzNKm1hJY;7I8_=$1E}tsL=|RL8Wc3Am;?E5QY6( zpOTiPB36>KB`KTXWicxXipb}Lyfh~&c%*n+^YFZo&lj+OMV^;Q?;sOHAq84N&ybbs zeL_ATb?lPulL7to6tGBli~wZW;lJ+KAZQlo3NqbD?Kz=S>K}7p6sakcg3W22KvW2veRn7p=z!0AmGT;AGK^udAj{h-!cX7M_gzSMU{WB4> zY4!y{S;$JWnyn#5V+utvuQ3u9WzAM9W+0U`=YqJLk>(-EVvKAf>TQjqW=$`Y@{4jS zQ3Zyh!D#dq-9Y&O(Lz3Y24(>OGd^$WSe^Ox%vMWJM`AuZ~!siD% zx4TYkzV`6ID|gPi|5qW--;13)YW@3B_p5&ASL&8nq^I{30RK%a+N%cnEH^NE$ z(F@v75DEB@f_74^PsryJC}`OJbWPv>n!f+~-)`#Q_T+V)K!5Lavm$_*6iPD)9{^B6 zEL6$33`jG>nz9zM5{Op%_lh#)PD-Wp)7 zq*kVAJ%Bv`YII4?YE)9sB+aErWs#q~jYUN!Keu95%`r=QAR%J%LQZVZF$#1*&8}dc zFf?;kCh~MP%_`=xe7ljbgM-mVMR^4oOLIaR^Ey_J0l6M*7Jv$R?C`EJbw|rbLu*~@ zN7pC+aN~D3*2h)fP~GAGXmGvb!{NH4V=eQs^uF}rVl5D>24cHTYm2RNe%FH>T@UOB z>x0KtZ-3P>Pg^ZM+FT0xD z(bG|l>MtLj6Qrz|iQ+;OG^GoGjT#hlav>KrrIFNBaQWB*#zo~sGC42dh0^Rux{yo$ zBri@Bu$W0=L0N>u5RjZ5-uxLrG3Z7nFoh zEGBMgRHDp`l%=BP*Kc{a<>5#qF-ONnjzHK+qLrPK4bQ+RA6O%i7zx?YkxXu0?gIk3 zpL|HW(>fZGOd=kLG$p$y@o>*Cel+vs^R&!mutza>Cep$krgJd2L zRUp4WMA;gtuDKgKPn?oTlhHi;(?PR|%fP2-HV{59LPH?oBTx4dp>5fFA@V@-L2~CX za#M3H$uI&N_F6NgTv5@iMOn(@PaCt-n8LY2rj!-W$k&0L40q*IXqme-MNwZM-)G4C z8FK#n`u=@XBz-UZ-8}o~^fbtxxZ?#%lhts();A zUiFV{`7hM`SF8T35B=BfFm<9;>u#pJFa@i*RQLGPi?-ndGY?6>d^R3&v?cC z*y9CPd_grldhgi1n0oSUwQG9EH(l}6XDLVbZhwS%0<;X zRApjccu&Kx6K_E69=W@8SGxD}&A_JPKBIP?-|?QWyivD%fA!8U->KPy+xFn2@Zq|r zz3%C)M-KkW&h)tMIM)WZ9G&2@@62u+>IkUFU-yUBZ)_}WNOymscAwqxpRKgiX@89l zR_S1k?tDOZK6bRMHRcLe>q%C7l4|&f>N^Uf<^93+o~^b+yH@&$O=Tc-@hUS66UFMi zvs7n%Yn^o_poWHa?Tp7#aR7qy)KH*`0&Do=@(1OOp?gQ~Guy%Q|6q24m({msR20}o P{3B$oEIveS`Vjgbtlj5? diff --git a/scripts/build_release_manifest.py b/scripts/build_release_manifest.py index 8bad7aa..4841233 100755 --- a/scripts/build_release_manifest.py +++ b/scripts/build_release_manifest.py @@ -62,13 +62,6 @@ def artifact_entry(path: Path, kind: str, minimum_os_version: str | None = None) ) manual_downloads = {} -universal_dmg = dist_dir / f"Taskers-v{version}-universal2.dmg" -if universal_dmg.exists(): - manual_downloads["macos_universal2_dmg"] = { - "url": f"{base_url}/{universal_dmg.name}", - "sha256": sha256(universal_dmg), - } - manifest = { "version": version, "artifacts": artifacts, From 3fdd8c03186084cee2430fb06a3aae4385f58467 Mon Sep 17 00:00:00 2001 From: OneNoted Date: Tue, 17 Mar 2026 10:09:57 +0100 Subject: [PATCH 07/40] fix: scrub inherited terminal env --- crates/taskers-app/src/main.rs | 4 +- crates/taskers-runtime/src/lib.rs | 2 +- crates/taskers-runtime/src/shell.rs | 43 ++++++++++++++++++- .../Sources/TaskersEnvironment.swift | 26 +++++++++++ macos/TaskersMac/Sources/main.swift | 1 + 5 files changed, 73 insertions(+), 3 deletions(-) diff --git a/crates/taskers-app/src/main.rs b/crates/taskers-app/src/main.rs index 996f6d7..bb4e91e 100644 --- a/crates/taskers-app/src/main.rs +++ b/crates/taskers-app/src/main.rs @@ -43,7 +43,8 @@ use taskers_ghostty::{ ensure_runtime_installed, }; use taskers_runtime::{ - ShellLaunchSpec, default_shell_program, install_shell_integration, validate_shell_program, + ShellLaunchSpec, default_shell_program, install_shell_integration, + scrub_inherited_terminal_env, validate_shell_program, }; use terminal_transitions::{ PaneSceneSnapshot, PresentedTransitionRect, TERMINAL_MOTION_SPEC, TransitionItemId, @@ -1861,6 +1862,7 @@ impl UiHandle { fn main() -> gtk::glib::ExitCode { let cli = Cli::parse(); + scrub_inherited_terminal_env(); if cli.internal_ghostty_probe { return run_internal_ghostty_probe(); } diff --git a/crates/taskers-runtime/src/lib.rs b/crates/taskers-runtime/src/lib.rs index c5ad9e8..2304dd6 100644 --- a/crates/taskers-runtime/src/lib.rs +++ b/crates/taskers-runtime/src/lib.rs @@ -5,6 +5,6 @@ pub mod signals; pub use pty::{CommandSpec, PtyReader, PtySession, SpawnedPty}; pub use shell::{ ShellIntegration, ShellLaunchSpec, default_shell_program, install_shell_integration, - validate_shell_program, + scrub_inherited_terminal_env, validate_shell_program, }; pub use signals::{ParsedSignal, SignalStreamParser, parse_signal_frames}; diff --git a/crates/taskers-runtime/src/shell.rs b/crates/taskers-runtime/src/shell.rs index 02659eb..5f64eab 100644 --- a/crates/taskers-runtime/src/shell.rs +++ b/crates/taskers-runtime/src/shell.rs @@ -18,6 +18,26 @@ enum ShellKind { Other, } +const INHERITED_TERMINAL_ENV_KEYS: &[&str] = &[ + "TERM", + "TERMINFO", + "TERMINFO_DIRS", + "TERM_PROGRAM", + "TERM_PROGRAM_VERSION", + "COLORTERM", + "NO_COLOR", + "CLICOLOR", + "CLICOLOR_FORCE", + "KITTY_INSTALLATION_DIR", + "KITTY_LISTEN_ON", + "KITTY_PUBLIC_KEY", + "KITTY_WINDOW_ID", + "GHOSTTY_BIN_DIR", + "GHOSTTY_RESOURCES_DIR", + "GHOSTTY_SHELL_FEATURES", + "GHOSTTY_SHELL_INTEGRATION_XDG_DIR", +]; + #[derive(Debug, Clone)] pub struct ShellIntegration { root: PathBuf, @@ -208,6 +228,14 @@ pub fn install_shell_integration(configured_shell: Option<&str>) -> Result PathBuf { login_shell_from_passwd() .or_else(shell_from_env) @@ -454,7 +482,10 @@ fn fish_source_command() -> String { #[cfg(test)] mod tests { - use super::{expand_home_prefix, fish_source_command, normalize_shell_override}; + use super::{ + INHERITED_TERMINAL_ENV_KEYS, expand_home_prefix, fish_source_command, + normalize_shell_override, + }; #[test] fn shell_override_normalizes_blank_values() { @@ -484,4 +515,14 @@ mod tests { assert_eq!(expanded, original); } } + + #[test] + fn inherited_terminal_env_keys_cover_color_and_terminfo_leaks() { + for key in ["NO_COLOR", "TERMINFO", "TERMINFO_DIRS", "TERM_PROGRAM"] { + assert!( + INHERITED_TERMINAL_ENV_KEYS.contains(&key), + "expected {key} to be scrubbed from inherited terminal env" + ); + } + } } diff --git a/macos/TaskersMac/Sources/TaskersEnvironment.swift b/macos/TaskersMac/Sources/TaskersEnvironment.swift index 328307b..698c1c1 100644 --- a/macos/TaskersMac/Sources/TaskersEnvironment.swift +++ b/macos/TaskersMac/Sources/TaskersEnvironment.swift @@ -1,10 +1,36 @@ import Foundation enum TaskersEnvironment { + private static let inheritedTerminalEnvironmentKeys = [ + "TERM", + "TERMINFO", + "TERMINFO_DIRS", + "TERM_PROGRAM", + "TERM_PROGRAM_VERSION", + "COLORTERM", + "NO_COLOR", + "CLICOLOR", + "CLICOLOR_FORCE", + "KITTY_INSTALLATION_DIR", + "KITTY_LISTEN_ON", + "KITTY_PUBLIC_KEY", + "KITTY_WINDOW_ID", + "GHOSTTY_BIN_DIR", + "GHOSTTY_RESOURCES_DIR", + "GHOSTTY_SHELL_FEATURES", + "GHOSTTY_SHELL_INTEGRATION_XDG_DIR", + ] + static var isSmokeTestEnabled: Bool { ProcessInfo.processInfo.environment["TASKERS_SMOKE_TEST"] == "1" } + static func scrubInheritedTerminalEnvironment() { + for key in inheritedTerminalEnvironmentKeys { + unsetenv(key) + } + } + static func configureBundledPaths() { guard let resourceURL = Bundle.main.resourceURL else { return diff --git a/macos/TaskersMac/Sources/main.swift b/macos/TaskersMac/Sources/main.swift index 0000d26..201368e 100644 --- a/macos/TaskersMac/Sources/main.swift +++ b/macos/TaskersMac/Sources/main.swift @@ -9,6 +9,7 @@ final class TaskersMacApplication: NSObject, NSApplicationDelegate { func applicationDidFinishLaunching(_ notification: Notification) { _ = notification + TaskersEnvironment.scrubInheritedTerminalEnvironment() TaskersEnvironment.configureBundledPaths() do { From d382691a2f049da580c3481315887cf99caae792 Mon Sep 17 00:00:00 2001 From: OneNoted Date: Tue, 17 Mar 2026 11:07:09 +0100 Subject: [PATCH 08/40] build: split Linux launcher from macOS DMG releases --- .github/workflows/macos-preview.yml | 20 +- .github/workflows/release-assets.yml | 106 ++-- Cargo.lock | 25 - Cargo.toml | 1 - README.md | 9 +- crates/taskers-launcher/Cargo.toml | 3 +- crates/taskers-launcher/src/lib.rs | 472 ++++-------------- crates/taskers-paths/src/lib.rs | 26 - docs/release.md | 26 +- scripts/build_macos_dmg.sh | 8 +- scripts/build_release_manifest.py | 15 +- scripts/install_macos_codesign_certificate.sh | 68 +++ scripts/notarize_macos_dmg.sh | 40 ++ scripts/package_macos_app_zip.sh | 33 -- 14 files changed, 283 insertions(+), 569 deletions(-) create mode 100644 scripts/install_macos_codesign_certificate.sh create mode 100644 scripts/notarize_macos_dmg.sh delete mode 100755 scripts/package_macos_app_zip.sh diff --git a/.github/workflows/macos-preview.yml b/.github/workflows/macos-preview.yml index 57bb395..25a5f4b 100644 --- a/.github/workflows/macos-preview.yml +++ b/.github/workflows/macos-preview.yml @@ -35,8 +35,24 @@ jobs: - name: Run shared Rust validation run: | - cargo test -p taskers-control -p taskers-core -p taskers-macos-ffi - cargo check --workspace + cargo test -p taskers-control -p taskers-core -p taskers-macos-ffi -p taskers-paths + + - name: Confirm crates.io launcher is blocked on macOS + run: | + set +e + output="$(cargo install --locked --path crates/taskers-launcher 2>&1)" + status=$? + set -e + + printf '%s\n' "$output" + + if [[ $status -eq 0 ]]; then + echo "expected cargo install --path crates/taskers-launcher to fail on macOS" >&2 + exit 1 + fi + + grep -F "taskers on crates.io currently supports x86_64 Linux only" <<<"$output" + grep -F "Download the macOS DMG" <<<"$output" - name: Generate Xcode project run: bash scripts/generate_macos_project.sh diff --git a/.github/workflows/release-assets.yml b/.github/workflows/release-assets.yml index 79bcc44..65229e9 100644 --- a/.github/workflows/release-assets.yml +++ b/.github/workflows/release-assets.yml @@ -43,75 +43,15 @@ jobs: name: linux-bundle path: dist/taskers-linux-bundle-v*.tar.xz - macos-preview: - strategy: - fail-fast: false - matrix: - runner: - - macos-15 - - macos-15-intel - runs-on: ${{ matrix.runner }} - - steps: - - name: Check out repository - uses: actions/checkout@v4 - - - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable - - - name: Cache Rust artifacts - uses: Swatinem/rust-cache@v2 - - - name: Install macOS build tools - run: | - brew update - brew install xcodegen zig - - - name: Generate Xcode project - run: bash scripts/generate_macos_project.sh - - - name: Run AppKit tests - run: | - set -o pipefail - xcodebuild test \ - -project macos/Taskers.xcodeproj \ - -scheme TaskersMac \ - -configuration Debug \ - -derivedDataPath build/macos/DerivedData \ - -resultBundlePath build/macos/Taskers-${{ matrix.runner }}.xcresult \ - CODE_SIGNING_ALLOWED=NO \ - CODE_SIGNING_REQUIRED=NO \ - | tee build/macos/xcodebuild-test-${{ matrix.runner }}.log - - - name: Build Taskers.app - run: | - xcodebuild build \ - -project macos/Taskers.xcodeproj \ - -scheme TaskersMac \ - -configuration Release \ - -derivedDataPath build/macos/DerivedData \ - CODE_SIGNING_ALLOWED=NO \ - CODE_SIGNING_REQUIRED=NO - - - name: Sign Taskers.app - run: bash scripts/sign_macos_app.sh - - - name: Package macOS app zip - run: bash scripts/package_macos_app_zip.sh - - - name: Upload macOS app zip - if: always() - uses: actions/upload-artifact@v4 - with: - name: macos-app-${{ matrix.runner }} - if-no-files-found: ignore - path: | - dist/taskers-macos-app-v*.zip - build/macos/Taskers-${{ matrix.runner }}.xcresult - build/macos/xcodebuild-test-${{ matrix.runner }}.log - macos-universal-dmg: runs-on: macos-15 + env: + TASKERS_MACOS_CERTIFICATE_P12_BASE64: ${{ secrets.TASKERS_MACOS_CERTIFICATE_P12_BASE64 }} + TASKERS_MACOS_CERTIFICATE_PASSWORD: ${{ secrets.TASKERS_MACOS_CERTIFICATE_PASSWORD }} + TASKERS_MACOS_CODESIGN_IDENTITY: ${{ secrets.TASKERS_MACOS_CODESIGN_IDENTITY }} + TASKERS_MACOS_NOTARY_APPLE_ID: ${{ secrets.TASKERS_MACOS_NOTARY_APPLE_ID }} + TASKERS_MACOS_NOTARY_TEAM_ID: ${{ secrets.TASKERS_MACOS_NOTARY_TEAM_ID }} + TASKERS_MACOS_NOTARY_PASSWORD: ${{ secrets.TASKERS_MACOS_NOTARY_PASSWORD }} steps: - name: Check out repository @@ -128,6 +68,29 @@ jobs: brew update brew install xcodegen zig + - name: Validate macOS release credentials + run: | + missing=0 + for name in \ + TASKERS_MACOS_CERTIFICATE_P12_BASE64 \ + TASKERS_MACOS_CERTIFICATE_PASSWORD \ + TASKERS_MACOS_CODESIGN_IDENTITY \ + TASKERS_MACOS_NOTARY_APPLE_ID \ + TASKERS_MACOS_NOTARY_TEAM_ID \ + TASKERS_MACOS_NOTARY_PASSWORD; do + if [[ -z "${!name:-}" ]]; then + echo "::error::Missing required secret ${name}" + missing=1 + fi + done + + if [[ $missing -ne 0 ]]; then + exit 1 + fi + + - name: Install Developer ID certificate + run: bash scripts/install_macos_codesign_certificate.sh + - name: Generate Xcode project run: bash scripts/generate_macos_project.sh @@ -149,6 +112,11 @@ jobs: - name: Build universal DMG run: bash scripts/build_macos_dmg.sh + - name: Notarize and staple universal DMG + run: | + version="$(sed -n 's/^version = \"\\(.*\\)\"/\\1/p' Cargo.toml | head -n1)" + bash scripts/notarize_macos_dmg.sh "dist/Taskers-v${version}-universal2.dmg" + - name: Upload universal DMG uses: actions/upload-artifact@v4 with: @@ -158,7 +126,6 @@ jobs: release-manifest: needs: - linux-bundle - - macos-preview runs-on: ubuntu-latest steps: @@ -188,6 +155,7 @@ jobs: if: startsWith(github.ref, 'refs/tags/v') needs: - release-manifest + - macos-universal-dmg runs-on: ubuntu-latest steps: @@ -208,4 +176,4 @@ jobs: files: | dist/release/taskers-manifest-v*.json dist/release/taskers-linux-bundle-v*.tar.xz - dist/release/taskers-macos-app-v*.zip + dist/release/Taskers-v*-universal2.dmg diff --git a/Cargo.lock b/Cargo.lock index c5033d6..4cea574 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -109,12 +109,6 @@ version = "3.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" -[[package]] -name = "byteorder" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" - [[package]] name = "bytes" version = "1.11.1" @@ -240,12 +234,6 @@ dependencies = [ "cfg-if", ] -[[package]] -name = "crossbeam-utils" -version = "0.8.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" - [[package]] name = "crypto-common" version = "0.1.7" @@ -1530,7 +1518,6 @@ dependencies = [ "tempfile", "ureq", "xz2", - "zip", ] [[package]] @@ -2411,18 +2398,6 @@ dependencies = [ "syn", ] -[[package]] -name = "zip" -version = "0.6.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "760394e246e4c28189f19d488c058bf16f564016aefac5d32bb1f3b51d5e9261" -dependencies = [ - "byteorder", - "crc32fast", - "crossbeam-utils", - "flate2", -] - [[package]] name = "zmij" version = "1.0.21" diff --git a/Cargo.toml b/Cargo.toml index 0d77585..b60b8b6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -42,6 +42,5 @@ tokio = { version = "1.50.0", features = ["io-util", "macros", "net", "rt-multi- ureq = "2.12" uuid = { version = "1.22.0", features = ["serde", "v7"] } xz2 = "0.1" -zip = { version = "0.6", default-features = false, features = ["deflate"] } taskers-core = { version = "0.2.1", path = "crates/taskers-core" } taskers-paths = { version = "0.2.1", path = "crates/taskers-paths" } diff --git a/README.md b/README.md index 3603a18..9821e85 100644 --- a/README.md +++ b/README.md @@ -8,12 +8,19 @@ Taskers is a cross-platform terminal workspace for agent-heavy work. It gives yo ## Try it +Linux (`x86_64-unknown-linux-gnu`): + ```bash cargo install taskers --locked taskers --demo ``` -The first launch downloads the exact version-matched Taskers bundle for your platform when needed. +The first launch downloads the exact version-matched Linux bundle from the tagged GitHub release. + +macOS: + +- Download the signed `Taskers-v-universal2.dmg` from [GitHub Releases](https://github.com/OneNoted/taskers/releases). +- Drag `Taskers.app` into `Applications`, then launch it normally from Finder or Spotlight. ## Develop diff --git a/crates/taskers-launcher/Cargo.toml b/crates/taskers-launcher/Cargo.toml index d45658f..55ca370 100644 --- a/crates/taskers-launcher/Cargo.toml +++ b/crates/taskers-launcher/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "taskers" -description = "Cross-platform launcher for the taskers workspace app." +description = "Linux launcher for the taskers workspace app." edition.workspace = true homepage.workspace = true license.workspace = true @@ -21,7 +21,6 @@ tar.workspace = true taskers-paths.workspace = true ureq.workspace = true xz2.workspace = true -zip.workspace = true [dev-dependencies] tempfile.workspace = true diff --git a/crates/taskers-launcher/src/lib.rs b/crates/taskers-launcher/src/lib.rs index a745877..1b3ddbb 100644 --- a/crates/taskers-launcher/src/lib.rs +++ b/crates/taskers-launcher/src/lib.rs @@ -1,3 +1,8 @@ +#[cfg(not(all(target_os = "linux", target_arch = "x86_64")))] +compile_error!( + "taskers on crates.io currently supports x86_64 Linux only. Download the macOS DMG from https://github.com/OneNoted/taskers/releases if you are on macOS." +); + use std::{ collections::BTreeMap, env, @@ -13,24 +18,16 @@ use anyhow::{Context, Result, anyhow, bail}; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use tar::Archive; -use taskers_paths::{ - HostPlatform, default_macos_applications_link_path, default_release_install_root, -}; +use taskers_paths::default_release_install_root; use xz2::read::XzDecoder; -use zip::ZipArchive; const INSTALL_ROOT_ENV: &str = "TASKERS_INSTALL_ROOT"; const MANIFEST_URL_ENV: &str = "TASKERS_RELEASE_MANIFEST_URL"; -const SKIP_CODESIGN_VERIFY_ENV: &str = "TASKERS_SKIP_CODESIGN_VERIFY"; pub fn run() -> Result { let args = env::args_os().skip(1).collect::>(); let installation = ManagedInstallation::ensure_installed(env!("CARGO_PKG_VERSION"))?; - - if installation.platform == HostPlatform::Linux { - installation.install_linux_user_assets()?; - } - + installation.install_linux_user_assets()?; installation.launch(&args) } @@ -38,8 +35,6 @@ pub fn run() -> Result { struct ReleaseManifest { version: String, artifacts: BTreeMap, - #[serde(default)] - manual_downloads: BTreeMap, } #[derive(Debug, Clone, Deserialize, Serialize)] @@ -49,14 +44,6 @@ struct ReleaseArtifact { sha256: String, #[serde(default)] size_bytes: Option, - #[serde(default)] - minimum_os_version: Option, -} - -#[derive(Debug, Clone, Deserialize, Serialize)] -struct ManualDownload { - url: String, - sha256: String, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] @@ -64,13 +51,10 @@ struct ManualDownload { enum ArtifactKind { #[serde(rename = "linux_bundle_v1")] LinuxBundleV1, - #[serde(rename = "macos_app_zip_v1")] - MacosAppZipV1, } #[derive(Debug)] struct ManagedInstallation { - platform: HostPlatform, target_triple: String, version: String, bundle_root: PathBuf, @@ -78,21 +62,18 @@ struct ManagedInstallation { impl ManagedInstallation { fn ensure_installed(version: &str) -> Result { - let platform = HostPlatform::detect(); - let target_triple = current_target_triple(platform)?; + let target_triple = current_target_triple()?; let install_root = env::var_os(INSTALL_ROOT_ENV) .map(PathBuf::from) .unwrap_or_else(default_release_install_root); - let bundle_root = bundle_root(&install_root, version, &target_triple, platform); + let bundle_root = bundle_root(&install_root, version, target_triple); let installation = Self { - platform, target_triple: target_triple.to_string(), version: version.to_string(), bundle_root, }; if installation.is_complete() { - installation.install_platform_integrations()?; return Ok(installation); } @@ -106,9 +87,7 @@ impl ManagedInstallation { installation.target_triple ) })?; - validate_minimum_os_version(platform, artifact)?; - installation.install_artifact(&manifest, artifact)?; - installation.install_platform_integrations()?; + installation.install_artifact(artifact)?; Ok(installation) } @@ -116,14 +95,11 @@ impl ManagedInstallation { let executable = self.executable_path(); let mut command = Command::new(&executable); command.args(args); - - if self.platform == HostPlatform::Linux { - command.env("TASKERS_CTL_PATH", self.taskersctl_path()); - command.env("TASKERS_GHOSTTY_RUNTIME_DIR", self.ghostty_resources_path()); - command.env("GHOSTTY_RESOURCES_DIR", self.ghostty_resources_path()); - command.env("TERMINFO", self.terminfo_path()); - command.env("TASKERS_DISABLE_GHOSTTY_RUNTIME_BOOTSTRAP", "1"); - } + command.env("TASKERS_CTL_PATH", self.taskersctl_path()); + command.env("TASKERS_GHOSTTY_RUNTIME_DIR", self.ghostty_resources_path()); + command.env("GHOSTTY_RESOURCES_DIR", self.ghostty_resources_path()); + command.env("TERMINFO", self.terminfo_path()); + command.env("TASKERS_DISABLE_GHOSTTY_RUNTIME_BOOTSTRAP", "1"); command .status() @@ -131,8 +107,8 @@ impl ManagedInstallation { } fn load_manifest(&self) -> Result { - let manifest_url = env::var(MANIFEST_URL_ENV) - .unwrap_or_else(|_| default_manifest_url(&self.version)); + let manifest_url = + env::var(MANIFEST_URL_ENV).unwrap_or_else(|_| default_manifest_url(&self.version)); let manifest_bytes = read_source_bytes(&manifest_url) .with_context(|| format!("failed to load release manifest from {manifest_url}"))?; let manifest: ReleaseManifest = serde_json::from_slice(&manifest_bytes) @@ -144,16 +120,11 @@ impl ManagedInstallation { self.version ); } - let _ = &manifest.manual_downloads; Ok(manifest) } - fn install_artifact( - &self, - _manifest: &ReleaseManifest, - artifact: &ReleaseArtifact, - ) -> Result<()> { - validate_artifact_kind(self.platform, artifact.kind)?; + fn install_artifact(&self, artifact: &ReleaseArtifact) -> Result<()> { + validate_artifact_kind(artifact.kind)?; let version_root = self .bundle_root @@ -166,7 +137,8 @@ impl ManagedInstallation { .duration_since(UNIX_EPOCH) .unwrap_or_default() .as_millis(); - let staging_root = version_root.join(format!(".install-{}-{timestamp}", std::process::id())); + let staging_root = + version_root.join(format!(".install-{}-{timestamp}", std::process::id())); if staging_root.exists() { remove_path(&staging_root)?; } @@ -208,25 +180,16 @@ impl ManagedInstallation { let unpack_root = staging_root.join("unpacked"); fs::create_dir_all(&unpack_root) .with_context(|| format!("failed to create {}", unpack_root.display()))?; + unpack_linux_bundle(&download_path, &unpack_root)?; - match artifact.kind { - ArtifactKind::LinuxBundleV1 => unpack_linux_bundle(&download_path, &unpack_root)?, - ArtifactKind::MacosAppZipV1 => unpack_macos_zip(&download_path, &unpack_root)?, - } - - let staged_bundle_root = unpack_root; - if !validate_bundle_layout(self.platform, &staged_bundle_root) { + if !validate_bundle_layout(&unpack_root) { bail!( "artifact {} did not unpack the expected layout into {}", artifact.url, - staged_bundle_root.display() + unpack_root.display() ); } - if self.platform == HostPlatform::Macos { - verify_codesign(&staged_bundle_root.join("Taskers.app"))?; - } - if self.bundle_root.exists() { remove_path(&self.bundle_root)?; } @@ -234,10 +197,10 @@ impl ManagedInstallation { fs::create_dir_all(parent) .with_context(|| format!("failed to create {}", parent.display()))?; } - fs::rename(&staged_bundle_root, &self.bundle_root).with_context(|| { + fs::rename(&unpack_root, &self.bundle_root).with_context(|| { format!( "failed to move {} to {}", - staged_bundle_root.display(), + unpack_root.display(), self.bundle_root.display() ) })?; @@ -246,13 +209,6 @@ impl ManagedInstallation { Ok(()) } - fn install_platform_integrations(&self) -> Result<()> { - if self.platform == HostPlatform::Macos { - self.refresh_macos_app_link()?; - } - Ok(()) - } - fn install_linux_user_assets(&self) -> Result<()> { let launcher = env::current_exe().context("failed to resolve current launcher path")?; let xdg_data_home = xdg_data_home()?; @@ -299,244 +255,62 @@ impl ManagedInstallation { Ok(()) } - fn refresh_macos_app_link(&self) -> Result<()> { - let Some(link_path) = default_macos_applications_link_path() else { - return Ok(()); - }; - - let target = self.bundle_root.join("Taskers.app"); - if let Some(parent) = link_path.parent() { - fs::create_dir_all(parent) - .with_context(|| format!("failed to create {}", parent.display()))?; - } - - if let Ok(metadata) = fs::symlink_metadata(&link_path) { - if metadata.file_type().is_symlink() { - fs::remove_file(&link_path) - .with_context(|| format!("failed to remove {}", link_path.display()))?; - } else { - return Ok(()); - } - } - - #[cfg(unix)] - std::os::unix::fs::symlink(&target, &link_path).with_context(|| { - format!( - "failed to symlink {} -> {}", - link_path.display(), - target.display() - ) - })?; - - Ok(()) - } - fn is_complete(&self) -> bool { - validate_bundle_layout(self.platform, &self.bundle_root) + validate_bundle_layout(&self.bundle_root) } fn executable_path(&self) -> PathBuf { - match self.platform { - HostPlatform::Linux => self.bundle_root.join("bin").join("taskers"), - HostPlatform::Macos => self - .bundle_root - .join("Taskers.app") - .join("Contents") - .join("MacOS") - .join("Taskers"), - HostPlatform::Other => self.bundle_root.join("taskers"), - } + self.bundle_root.join("bin").join("taskers") } fn taskersctl_path(&self) -> PathBuf { - match self.platform { - HostPlatform::Linux => self.bundle_root.join("bin").join("taskersctl"), - HostPlatform::Macos => self - .bundle_root - .join("Taskers.app") - .join("Contents") - .join("Resources") - .join("bin") - .join("taskersctl"), - HostPlatform::Other => self.bundle_root.join("taskersctl"), - } + self.bundle_root.join("bin").join("taskersctl") } fn ghostty_resources_path(&self) -> PathBuf { - match self.platform { - HostPlatform::Linux => self.bundle_root.join("ghostty"), - HostPlatform::Macos => self - .bundle_root - .join("Taskers.app") - .join("Contents") - .join("Resources") - .join("ghostty"), - HostPlatform::Other => self.bundle_root.join("ghostty"), - } + self.bundle_root.join("ghostty") } fn terminfo_path(&self) -> PathBuf { - match self.platform { - HostPlatform::Linux => self.bundle_root.join("terminfo"), - HostPlatform::Macos => self - .bundle_root - .join("Taskers.app") - .join("Contents") - .join("Resources") - .join("terminfo"), - HostPlatform::Other => self.bundle_root.join("terminfo"), - } + self.bundle_root.join("terminfo") } } -fn validate_artifact_kind(platform: HostPlatform, kind: ArtifactKind) -> Result<()> { - match (platform, kind) { - (HostPlatform::Linux, ArtifactKind::LinuxBundleV1) - | (HostPlatform::Macos, ArtifactKind::MacosAppZipV1) => Ok(()), - _ => bail!("artifact kind {kind:?} is incompatible with platform {platform:?}"), +fn validate_artifact_kind(kind: ArtifactKind) -> Result<()> { + match kind { + ArtifactKind::LinuxBundleV1 => Ok(()), } } -fn validate_bundle_layout(platform: HostPlatform, bundle_root: &Path) -> bool { - match platform { - HostPlatform::Linux => { - bundle_root.join("bin").join("taskers").is_file() - && bundle_root.join("bin").join("taskersctl").is_file() - && bundle_root.join("ghostty").is_dir() - && bundle_root - .join("ghostty") - .join("lib") - .join("libtaskers_ghostty_bridge.so") - .is_file() - && bundle_root.join("terminfo").is_dir() - } - HostPlatform::Macos => { - bundle_root - .join("Taskers.app") - .join("Contents") - .join("MacOS") - .join("Taskers") - .is_file() - && bundle_root - .join("Taskers.app") - .join("Contents") - .join("Resources") - .join("bin") - .join("taskersctl") - .is_file() - && bundle_root - .join("Taskers.app") - .join("Contents") - .join("Resources") - .join("ghostty") - .is_dir() - && bundle_root - .join("Taskers.app") - .join("Contents") - .join("Resources") - .join("terminfo") - .is_dir() - } - HostPlatform::Other => false, - } +fn validate_bundle_layout(bundle_root: &Path) -> bool { + bundle_root.join("bin").join("taskers").is_file() + && bundle_root.join("bin").join("taskersctl").is_file() + && bundle_root.join("ghostty").is_dir() + && bundle_root + .join("ghostty") + .join("lib") + .join("libtaskers_ghostty_bridge.so") + .is_file() + && bundle_root.join("terminfo").is_dir() } -fn current_target_triple(platform: HostPlatform) -> Result<&'static str> { - match (platform, env::consts::ARCH) { - (HostPlatform::Linux, "x86_64") => Ok("x86_64-unknown-linux-gnu"), - (HostPlatform::Macos, "aarch64") => Ok("aarch64-apple-darwin"), - (HostPlatform::Macos, "x86_64") => Ok("x86_64-apple-darwin"), +fn current_target_triple() -> Result<&'static str> { + match env::consts::ARCH { + "x86_64" => Ok("x86_64-unknown-linux-gnu"), _ => bail!( - "unsupported platform/architecture combination: {:?}/{}", - platform, + "unsupported Linux architecture for the published taskers launcher: {}", env::consts::ARCH ), } } -fn validate_minimum_os_version(platform: HostPlatform, artifact: &ReleaseArtifact) -> Result<()> { - if platform != HostPlatform::Macos { - return Ok(()); - } - - let Some(minimum_version) = artifact.minimum_os_version.as_deref() else { - return Ok(()); - }; - - let current_version = current_macos_version()?; - if version_meets_minimum(¤t_version, minimum_version)? { - return Ok(()); - } - - bail!( - "taskers requires macOS {minimum_version} or newer, but this machine reports macOS {current_version}" - ); -} - -fn current_macos_version() -> Result { - let output = Command::new("sw_vers") - .arg("-productVersion") - .output() - .context("failed to invoke sw_vers to detect the current macOS version")?; - if !output.status.success() { - bail!("sw_vers -productVersion exited with {}", output.status); - } - - let version = String::from_utf8(output.stdout) - .context("sw_vers -productVersion returned a non-UTF-8 version string")?; - let version = version.trim(); - if version.is_empty() { - bail!("sw_vers -productVersion returned an empty version string"); - } - Ok(version.to_string()) -} - -fn version_meets_minimum(current: &str, minimum: &str) -> Result { - let mut current_components = parse_version_components(current)?; - let mut minimum_components = parse_version_components(minimum)?; - let component_count = current_components.len().max(minimum_components.len()); - current_components.resize(component_count, 0); - minimum_components.resize(component_count, 0); - Ok(current_components >= minimum_components) -} - -fn parse_version_components(version: &str) -> Result> { - let mut components = Vec::new(); - for component in version.split('.') { - if component.is_empty() { - bail!("invalid version {version}: empty component"); - } - let value = component - .parse::() - .with_context(|| format!("invalid version {version}: {component} is not numeric"))?; - components.push(value); - } - - if components.is_empty() { - bail!("invalid version: expected at least one component"); - } - - Ok(components) -} - -fn bundle_root( - install_root: &Path, - version: &str, - target_triple: &str, - platform: HostPlatform, -) -> PathBuf { - match platform { - HostPlatform::Linux => install_root.join(version).join(target_triple), - HostPlatform::Macos => install_root.join(version), - HostPlatform::Other => install_root.join(version).join(target_triple), - } +fn bundle_root(install_root: &Path, version: &str, target_triple: &str) -> PathBuf { + install_root.join(version).join(target_triple) } fn default_manifest_url(version: &str) -> String { let repository = env!("CARGO_PKG_REPOSITORY").trim_end_matches('/'); - format!( - "{repository}/releases/download/v{version}/taskers-manifest-v{version}.json" - ) + format!("{repository}/releases/download/v{version}/taskers-manifest-v{version}.json") } fn read_source_bytes(source: &str) -> Result> { @@ -566,7 +340,11 @@ fn fetch_source_to_path(source: &str, destination: &Path) -> Result<()> { if let Some(path) = file_url_to_path(source).or_else(|| local_path_source(source)) { fs::copy(&path, destination).with_context(|| { - format!("failed to copy {} to {}", path.display(), destination.display()) + format!( + "failed to copy {} to {}", + path.display(), + destination.display() + ) })?; return Ok(()); } @@ -583,9 +361,7 @@ fn fetch_source_to_path(source: &str, destination: &Path) -> Result<()> { } fn file_url_to_path(value: &str) -> Option { - value - .strip_prefix("file://") - .map(|path| PathBuf::from(path)) + value.strip_prefix("file://").map(PathBuf::from) } fn local_path_source(value: &str) -> Option { @@ -617,8 +393,8 @@ fn sha256_path(path: &Path) -> Result { } fn unpack_linux_bundle(archive_path: &Path, destination: &Path) -> Result<()> { - let file = - fs::File::open(archive_path).with_context(|| format!("failed to open {}", archive_path.display()))?; + let file = fs::File::open(archive_path) + .with_context(|| format!("failed to open {}", archive_path.display()))?; let decoder = XzDecoder::new(file); let mut archive = Archive::new(decoder); archive @@ -626,67 +402,6 @@ fn unpack_linux_bundle(archive_path: &Path, destination: &Path) -> Result<()> { .with_context(|| format!("failed to unpack {}", archive_path.display())) } -fn unpack_macos_zip(zip_path: &Path, destination: &Path) -> Result<()> { - let file = - fs::File::open(zip_path).with_context(|| format!("failed to open {}", zip_path.display()))?; - let mut archive = ZipArchive::new(file) - .with_context(|| format!("failed to decode {}", zip_path.display()))?; - - for index in 0..archive.len() { - let mut entry = archive - .by_index(index) - .with_context(|| format!("failed to open zip entry {index}"))?; - let relative = entry - .enclosed_name() - .ok_or_else(|| anyhow!("zip entry {} escaped the destination", entry.name()))? - .to_path_buf(); - let output_path = destination.join(relative); - - if entry.name().ends_with('/') { - fs::create_dir_all(&output_path) - .with_context(|| format!("failed to create {}", output_path.display()))?; - continue; - } - - if let Some(parent) = output_path.parent() { - fs::create_dir_all(parent) - .with_context(|| format!("failed to create {}", parent.display()))?; - } - - let mut output = fs::File::create(&output_path) - .with_context(|| format!("failed to create {}", output_path.display()))?; - io::copy(&mut entry, &mut output) - .with_context(|| format!("failed to write {}", output_path.display()))?; - - #[cfg(unix)] - if let Some(mode) = entry.unix_mode() { - use std::os::unix::fs::PermissionsExt; - - fs::set_permissions(&output_path, fs::Permissions::from_mode(mode)).with_context( - || format!("failed to chmod {}", output_path.display()), - )?; - } - } - - Ok(()) -} - -fn verify_codesign(app_path: &Path) -> Result<()> { - if env::var_os(SKIP_CODESIGN_VERIFY_ENV).is_some() { - return Ok(()); - } - - let status = Command::new("codesign") - .args(["--verify", "--deep", "--strict"]) - .arg(app_path) - .status() - .with_context(|| format!("failed to invoke codesign for {}", app_path.display()))?; - if !status.success() { - bail!("codesign verification failed for {}", app_path.display()); - } - Ok(()) -} - fn remove_path(path: &Path) -> Result<()> { if !path.exists() { return Ok(()); @@ -697,8 +412,7 @@ fn remove_path(path: &Path) -> Result<()> { if metadata.file_type().is_symlink() || metadata.is_file() { fs::remove_file(path).with_context(|| format!("failed to remove {}", path.display()))?; } else { - fs::remove_dir_all(path) - .with_context(|| format!("failed to remove {}", path.display()))?; + fs::remove_dir_all(path).with_context(|| format!("failed to remove {}", path.display()))?; } Ok(()) } @@ -780,11 +494,10 @@ where mod tests { use super::{ ArtifactKind, ManagedInstallation, ReleaseArtifact, ReleaseManifest, bundle_root, - current_target_triple, default_manifest_url, sha256_path, version_meets_minimum, + current_target_triple, default_manifest_url, sha256_path, }; use std::{collections::BTreeMap, fs, path::PathBuf}; use tar::Builder; - use taskers_paths::HostPlatform; use tempfile::tempdir; use xz2::write::XzEncoder; @@ -795,21 +508,12 @@ mod tests { } #[test] - fn bundle_roots_match_platform_layout() { + fn bundle_roots_match_linux_layout() { let root = PathBuf::from("/tmp/taskers"); assert_eq!( - bundle_root( - &root, - "0.2.1", - "x86_64-unknown-linux-gnu", - HostPlatform::Linux - ), + bundle_root(&root, "0.2.1", "x86_64-unknown-linux-gnu"), PathBuf::from("/tmp/taskers/0.2.1/x86_64-unknown-linux-gnu") ); - assert_eq!( - bundle_root(&root, "0.2.1", "aarch64-apple-darwin", HostPlatform::Macos), - PathBuf::from("/tmp/taskers/0.2.1") - ); } #[test] @@ -820,11 +524,21 @@ mod tests { fs::create_dir_all(bundle_dir.join("bin")).expect("bin dir"); fs::create_dir_all(bundle_dir.join("ghostty").join("lib")).expect("ghostty dir"); fs::create_dir_all(bundle_dir.join("terminfo").join("g")).expect("terminfo dir"); - fs::write(bundle_dir.join("bin").join("taskers"), "#!/bin/sh\nexit 0\n").expect("taskers"); - fs::write(bundle_dir.join("bin").join("taskersctl"), "#!/bin/sh\nexit 0\n") - .expect("taskersctl"); - fs::write(bundle_dir.join("ghostty").join(".taskers-runtime-version"), "0.2.1") - .expect("ghostty version"); + fs::write( + bundle_dir.join("bin").join("taskers"), + "#!/bin/sh\nexit 0\n", + ) + .expect("taskers"); + fs::write( + bundle_dir.join("bin").join("taskersctl"), + "#!/bin/sh\nexit 0\n", + ) + .expect("taskersctl"); + fs::write( + bundle_dir.join("ghostty").join(".taskers-runtime-version"), + "0.2.1", + ) + .expect("ghostty version"); fs::write( bundle_dir .join("ghostty") @@ -833,8 +547,11 @@ mod tests { "bridge", ) .expect("bridge"); - fs::write(bundle_dir.join("terminfo").join("g").join("ghostty"), "ghostty") - .expect("terminfo"); + fs::write( + bundle_dir.join("terminfo").join("g").join("ghostty"), + "ghostty", + ) + .expect("terminfo"); let archive_path = temp.path().join("taskers-linux-bundle.tar.xz"); { @@ -842,14 +559,11 @@ mod tests { let encoder = XzEncoder::new(file, 6); let mut tar = Builder::new(encoder); tar.append_dir_all(".", &bundle_dir).expect("append"); - tar.into_inner() - .expect("encoder") - .finish() - .expect("finish"); + tar.into_inner().expect("encoder").finish().expect("finish"); } let checksum = sha256_path(&archive_path).expect("sha"); - let target = current_target_triple(HostPlatform::Linux).expect("target"); + let target = current_target_triple().expect("target"); let manifest_path = temp.path().join("manifest.json"); let manifest = ReleaseManifest { version: env!("CARGO_PKG_VERSION").to_string(), @@ -860,10 +574,8 @@ mod tests { url: archive_path.display().to_string(), sha256: checksum, size_bytes: None, - minimum_os_version: None, }, )]), - manual_downloads: BTreeMap::new(), }; fs::write( &manifest_path, @@ -887,18 +599,4 @@ mod tests { assert!(installation.ghostty_resources_path().is_dir()); assert!(installation.terminfo_path().is_dir()); } - - #[test] - fn macos_version_check_pads_missing_components() { - assert!(version_meets_minimum("15", "14.0").expect("version compare")); - assert!(version_meets_minimum("14.0", "14").expect("version compare")); - assert!(!version_meets_minimum("14", "14.1").expect("version compare")); - assert!(!version_meets_minimum("14.0.5", "14.1").expect("version compare")); - } - - #[test] - fn macos_version_check_rejects_invalid_versions() { - assert!(version_meets_minimum("14.a", "14.0").is_err()); - assert!(version_meets_minimum("14.1", "14..0").is_err()); - } } diff --git a/crates/taskers-paths/src/lib.rs b/crates/taskers-paths/src/lib.rs index bb708f2..17393c7 100644 --- a/crates/taskers-paths/src/lib.rs +++ b/crates/taskers-paths/src/lib.rs @@ -215,11 +215,6 @@ pub fn default_release_install_root() -> PathBuf { TaskersPaths::detect().data_dir.join("releases") } -pub fn default_macos_applications_link_path() -> Option { - (HostPlatform::detect() == HostPlatform::Macos) - .then_some(home_applications_link_path(&EnvPaths::current())) -} - fn platform_config_dir(platform: HostPlatform, env_paths: &EnvPaths) -> PathBuf { match platform { HostPlatform::Macos => home_library_dir(env_paths, "Application Support"), @@ -347,14 +342,6 @@ fn home_library_cache_dir(env_paths: &EnvPaths) -> PathBuf { .unwrap_or_else(|| temp_root().join("cache")) } -fn home_applications_link_path(env_paths: &EnvPaths) -> PathBuf { - env_paths - .home - .clone() - .map(|path| path.join("Applications").join("Taskers.app")) - .unwrap_or_else(|| temp_root().join("applications").join("Taskers.app")) -} - fn temp_root() -> PathBuf { env::temp_dir().join("taskers") } @@ -477,17 +464,4 @@ mod tests { PathBuf::from("/tmp/data/taskers/releases") ); } - - #[test] - fn macos_applications_link_uses_home_applications() { - let env = EnvPaths { - home: Some(PathBuf::from("/Users/notes")), - ..EnvPaths::default() - }; - - assert_eq!( - super::home_applications_link_path(&env), - PathBuf::from("/Users/notes/Applications/Taskers.app") - ); - } } diff --git a/docs/release.md b/docs/release.md index 20d56d4..9f41315 100644 --- a/docs/release.md +++ b/docs/release.md @@ -52,21 +52,30 @@ taskers-linux-bundle-v-.tar.xz - Build the macOS release assets on macOS: ```bash +bash scripts/install_macos_codesign_certificate.sh bash scripts/generate_macos_project.sh xcodebuild build \ -project macos/Taskers.xcodeproj \ -scheme TaskersMac \ -configuration Release \ -derivedDataPath build/macos/DerivedData \ + ARCHS="arm64 x86_64" \ + ONLY_ACTIVE_ARCH=NO \ CODE_SIGNING_ALLOWED=NO \ CODE_SIGNING_REQUIRED=NO bash scripts/sign_macos_app.sh -bash scripts/package_macos_app_zip.sh +bash scripts/build_macos_dmg.sh +bash scripts/notarize_macos_dmg.sh dist/Taskers-v-universal2.dmg ``` -- By default `scripts/sign_macos_app.sh` applies an ad hoc signature so the published launcher can verify the app bundle before launch. -- Set `TASKERS_MACOS_CODESIGN_IDENTITY` to a Developer ID Application certificate name when producing release assets you intend to distribute outside local testing. -- `scripts/build_macos_dmg.sh` remains internal-only until notarization/stapling is wired into the release flow. Do not publish the DMG yet. +- Set these env vars before importing the certificate or notarizing: + - `TASKERS_MACOS_CERTIFICATE_P12_BASE64` + - `TASKERS_MACOS_CERTIFICATE_PASSWORD` + - `TASKERS_MACOS_CODESIGN_IDENTITY` + - `TASKERS_MACOS_NOTARY_APPLE_ID` + - `TASKERS_MACOS_NOTARY_TEAM_ID` + - `TASKERS_MACOS_NOTARY_PASSWORD` +- `scripts/sign_macos_app.sh` still falls back to ad hoc signing when `TASKERS_MACOS_CODESIGN_IDENTITY` is unset, but public release builds should always use a Developer ID Application identity and notarize the DMG. - Build the release manifest from the generated assets: @@ -91,8 +100,7 @@ cargo publish --dry-run -p taskers - Confirm the draft release tagged `v` contains: - `taskers-manifest-v.json` - `taskers-linux-bundle-v-x86_64-unknown-linux-gnu.tar.xz` - - `taskers-macos-app-v-aarch64-apple-darwin.zip` - - `taskers-macos-app-v-x86_64-apple-darwin.zip` + - `Taskers-v-universal2.dmg` - Publish the GitHub release so the launcher assets are publicly downloadable before publishing the crates. - Publish the crates to crates.io in the same order as the dry-run: @@ -107,12 +115,14 @@ cargo publish -p taskers ## 5. Post-Publish Check -- Verify clean launcher installs: +- Verify the Linux launcher install: ```bash cargo install taskers --locked taskers --demo ``` -- Confirm the published launcher downloads the exact version-matched bundle on first launch. +- Confirm the published Linux launcher downloads the exact version-matched bundle on first launch. +- Confirm macOS installs from the published DMG and launches correctly after dragging `Taskers.app` into `Applications`. +- Confirm `cargo install taskers --locked` fails on macOS with guidance to use the GitHub Releases DMG. - Confirm `cargo install taskers-cli --bin taskersctl --locked` still works as the standalone helper path. diff --git a/scripts/build_macos_dmg.sh b/scripts/build_macos_dmg.sh index 417d9a8..fa44e19 100755 --- a/scripts/build_macos_dmg.sh +++ b/scripts/build_macos_dmg.sh @@ -7,6 +7,7 @@ derived_data_path="${1:-$repo_root/build/macos/DerivedData}" app_path="${2:-$derived_data_path/Build/Products/Release/Taskers.app}" out_dir="${3:-$repo_root/dist}" asset_path="$out_dir/Taskers-v${version}-universal2.dmg" +staging_dir="$out_dir/.taskers-dmg-staging" if [[ ! -d "$app_path" ]]; then echo "expected Taskers.app at $app_path" >&2 @@ -15,10 +16,15 @@ fi mkdir -p "$out_dir" rm -f "$asset_path" +rm -rf "$staging_dir" +mkdir -p "$staging_dir" +ditto "$app_path" "$staging_dir/Taskers.app" +ln -s /Applications "$staging_dir/Applications" hdiutil create \ -volname "Taskers" \ - -srcfolder "$app_path" \ + -srcfolder "$staging_dir" \ -ov \ -format UDZO \ "$asset_path" +rm -rf "$staging_dir" printf '%s\n' "$asset_path" diff --git a/scripts/build_release_manifest.py b/scripts/build_release_manifest.py index 4841233..84f5b5f 100755 --- a/scripts/build_release_manifest.py +++ b/scripts/build_release_manifest.py @@ -13,15 +13,13 @@ def sha256(path: Path) -> str: return digest.hexdigest() -def artifact_entry(path: Path, kind: str, minimum_os_version: str | None = None) -> dict: +def artifact_entry(path: Path, kind: str) -> dict: entry = { "kind": kind, "url": f"{base_url}/{path.name}", "sha256": sha256(path), "size_bytes": path.stat().st_size, } - if minimum_os_version is not None: - entry["minimum_os_version"] = minimum_os_version return entry @@ -52,20 +50,9 @@ def artifact_entry(path: Path, kind: str, minimum_os_version: str | None = None) if linux_bundle.exists(): artifacts["x86_64-unknown-linux-gnu"] = artifact_entry(linux_bundle, "linux_bundle_v1") -for target in ("aarch64-apple-darwin", "x86_64-apple-darwin"): - archive = dist_dir / f"taskers-macos-app-v{version}-{target}.zip" - if archive.exists(): - artifacts[target] = artifact_entry( - archive, - "macos_app_zip_v1", - minimum_os_version="14.0", - ) - -manual_downloads = {} manifest = { "version": version, "artifacts": artifacts, - "manual_downloads": manual_downloads, } output_path.write_text(json.dumps(manifest, indent=2, sort_keys=True) + "\n", encoding="utf-8") diff --git a/scripts/install_macos_codesign_certificate.sh b/scripts/install_macos_codesign_certificate.sh new file mode 100644 index 0000000..519f856 --- /dev/null +++ b/scripts/install_macos_codesign_certificate.sh @@ -0,0 +1,68 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "$0")/.." && pwd)" +build_dir="${repo_root}/build/macos" +certificate_path="${build_dir}/taskers-codesign.p12" +keychain_path="${build_dir}/taskers-signing.keychain-db" +keychain_password="${TASKERS_MACOS_KEYCHAIN_PASSWORD:-taskers-temporary-keychain}" +identity="${TASKERS_MACOS_CODESIGN_IDENTITY:-}" + +if [[ -z "${TASKERS_MACOS_CERTIFICATE_P12_BASE64:-}" ]]; then + echo "TASKERS_MACOS_CERTIFICATE_P12_BASE64 is required" >&2 + exit 1 +fi + +if [[ -z "${TASKERS_MACOS_CERTIFICATE_PASSWORD:-}" ]]; then + echo "TASKERS_MACOS_CERTIFICATE_PASSWORD is required" >&2 + exit 1 +fi + +if [[ -z "$identity" ]]; then + echo "TASKERS_MACOS_CODESIGN_IDENTITY is required" >&2 + exit 1 +fi + +mkdir -p "$build_dir" +rm -f "$certificate_path" "$keychain_path" + +python3 - "$certificate_path" <<'PY' +import base64 +import os +import sys + +decoded = base64.b64decode(os.environ["TASKERS_MACOS_CERTIFICATE_P12_BASE64"]) +with open(sys.argv[1], "wb") as handle: + handle.write(decoded) +PY + +security create-keychain -p "$keychain_password" "$keychain_path" +security set-keychain-settings -lut 21600 "$keychain_path" +security unlock-keychain -p "$keychain_password" "$keychain_path" +security import "$certificate_path" \ + -k "$keychain_path" \ + -P "$TASKERS_MACOS_CERTIFICATE_PASSWORD" \ + -T /usr/bin/codesign \ + -T /usr/bin/security \ + -T /usr/bin/productbuild +security set-key-partition-list -S apple-tool:,apple: -k "$keychain_password" "$keychain_path" + +existing_keychains=() +while IFS= read -r keychain; do + keychain="${keychain//\"/}" + keychain="${keychain#"${keychain%%[![:space:]]*}"}" + keychain="${keychain%"${keychain##*[![:space:]]}"}" + if [[ -n "$keychain" ]]; then + existing_keychains+=("$keychain") + fi +done < <(security list-keychains -d user) + +security list-keychains -d user -s "$keychain_path" "${existing_keychains[@]}" +security default-keychain -d user -s "$keychain_path" + +if ! security find-identity -v -p codesigning "$keychain_path" | grep -F "$identity" >/dev/null; then + echo "codesigning identity not found in imported keychain: $identity" >&2 + exit 1 +fi + +printf '%s\n' "$keychain_path" diff --git a/scripts/notarize_macos_dmg.sh b/scripts/notarize_macos_dmg.sh new file mode 100644 index 0000000..058fabe --- /dev/null +++ b/scripts/notarize_macos_dmg.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +set -euo pipefail + +dmg_path="${1:-}" + +if [[ -z "$dmg_path" ]]; then + echo "usage: $0 " >&2 + exit 1 +fi + +if [[ ! -f "$dmg_path" ]]; then + echo "expected dmg at $dmg_path" >&2 + exit 1 +fi + +if [[ -z "${TASKERS_MACOS_NOTARY_APPLE_ID:-}" ]]; then + echo "TASKERS_MACOS_NOTARY_APPLE_ID is required" >&2 + exit 1 +fi + +if [[ -z "${TASKERS_MACOS_NOTARY_TEAM_ID:-}" ]]; then + echo "TASKERS_MACOS_NOTARY_TEAM_ID is required" >&2 + exit 1 +fi + +if [[ -z "${TASKERS_MACOS_NOTARY_PASSWORD:-}" ]]; then + echo "TASKERS_MACOS_NOTARY_PASSWORD is required" >&2 + exit 1 +fi + +xcrun notarytool submit "$dmg_path" \ + --wait \ + --apple-id "$TASKERS_MACOS_NOTARY_APPLE_ID" \ + --team-id "$TASKERS_MACOS_NOTARY_TEAM_ID" \ + --password "$TASKERS_MACOS_NOTARY_PASSWORD" +xcrun stapler staple "$dmg_path" +xcrun stapler validate "$dmg_path" +spctl --assess --type open --context context:primary-signature --verbose=4 "$dmg_path" + +printf '%s\n' "$dmg_path" diff --git a/scripts/package_macos_app_zip.sh b/scripts/package_macos_app_zip.sh deleted file mode 100755 index 01d06e3..0000000 --- a/scripts/package_macos_app_zip.sh +++ /dev/null @@ -1,33 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -repo_root="$(cd "$(dirname "$0")/.." && pwd)" -version="$(sed -n 's/^version = "\(.*\)"/\1/p' "$repo_root/Cargo.toml" | head -n1)" -derived_data_path="${1:-$repo_root/build/macos/DerivedData}" -app_path="${2:-$derived_data_path/Build/Products/Release/Taskers.app}" -out_dir="${3:-$repo_root/dist}" -arch="${4:-$(uname -m)}" - -case "$arch" in - arm64|aarch64) - target="aarch64-apple-darwin" - ;; - x86_64) - target="x86_64-apple-darwin" - ;; - *) - echo "unsupported macOS architecture: $arch" >&2 - exit 1 - ;; -esac - -if [[ ! -d "$app_path" ]]; then - echo "expected Taskers.app at $app_path" >&2 - exit 1 -fi - -mkdir -p "$out_dir" -ditto -c -k --sequesterRsrc --keepParent \ - "$app_path" \ - "$out_dir/taskers-macos-app-v${version}-${target}.zip" -printf '%s\n' "$out_dir/taskers-macos-app-v${version}-${target}.zip" From 6ed4c827e6c40e295c816b2a98136a20a6f48223 Mon Sep 17 00:00:00 2001 From: OneNoted Date: Tue, 17 Mar 2026 11:54:50 +0100 Subject: [PATCH 09/40] fix: harden Linux desktop entry registration --- crates/taskers-launcher/src/lib.rs | 98 ++++++++++++++++++++++++- scripts/smoke_linux_release_launcher.sh | 5 ++ 2 files changed, 100 insertions(+), 3 deletions(-) diff --git a/crates/taskers-launcher/src/lib.rs b/crates/taskers-launcher/src/lib.rs index 1b3ddbb..8d13ecd 100644 --- a/crates/taskers-launcher/src/lib.rs +++ b/crates/taskers-launcher/src/lib.rs @@ -23,6 +23,7 @@ use xz2::read::XzDecoder; const INSTALL_ROOT_ENV: &str = "TASKERS_INSTALL_ROOT"; const MANIFEST_URL_ENV: &str = "TASKERS_RELEASE_MANIFEST_URL"; +const SKIP_DESKTOP_INTEGRATION_ENV: &str = "TASKERS_SKIP_DESKTOP_INTEGRATION"; pub fn run() -> Result { let args = env::args_os().skip(1).collect::>(); @@ -210,7 +211,13 @@ impl ManagedInstallation { } fn install_linux_user_assets(&self) -> Result<()> { - let launcher = env::current_exe().context("failed to resolve current launcher path")?; + if env::var_os(SKIP_DESKTOP_INTEGRATION_ENV).is_some() { + return Ok(()); + } + + let Some(launcher) = desktop_launcher_path()? else { + return Ok(()); + }; let xdg_data_home = xdg_data_home()?; let applications_dir = xdg_data_home.join("applications"); let icons_dir = xdg_data_home @@ -464,6 +471,63 @@ fn write_executable(path: &Path, contents: &str) -> Result<()> { Ok(()) } +fn desktop_launcher_path() -> Result> { + let current_exe = env::current_exe().context("failed to resolve current launcher path")?; + + if let Some(path_launcher) = path_taskers_executable(¤t_exe, env::var_os("PATH")) { + return Ok(Some(path_launcher)); + } + + if launcher_path_looks_installed(¤t_exe) { + return Ok(Some(current_exe)); + } + + Ok(None) +} + +fn path_taskers_executable(current_exe: &Path, path_env: Option) -> Option { + let path_env = path_env?; + let current_exe = fs::canonicalize(current_exe).ok()?; + + env::split_paths(&path_env).find_map(|entry| { + let candidate = entry.join("taskers"); + let candidate_exe = fs::canonicalize(&candidate).ok()?; + (candidate_exe == current_exe).then_some(candidate) + }) +} + +fn launcher_path_looks_installed(current_exe: &Path) -> bool { + let Some(parent) = current_exe.parent() else { + return false; + }; + + if xdg_bin_home().ok().as_deref() == Some(parent) { + return true; + } + + if cargo_bin_home().as_deref() == Some(parent) { + return true; + } + + matches!( + parent, + p if p == Path::new("/usr/local/bin") + || p == Path::new("/usr/bin") + || p == Path::new("/bin") + ) +} + +fn cargo_bin_home() -> Option { + env::var_os("CARGO_HOME") + .map(PathBuf::from) + .map(|path| path.join("bin")) + .or_else(|| { + env::var_os("HOME") + .map(PathBuf::from) + .map(|path| path.join(".cargo").join("bin")) + }) +} + fn refresh_desktop_indexes(applications_dir: &Path) { run_if_available("update-desktop-database", [applications_dir.as_os_str()]); } @@ -494,9 +558,12 @@ where mod tests { use super::{ ArtifactKind, ManagedInstallation, ReleaseArtifact, ReleaseManifest, bundle_root, - current_target_triple, default_manifest_url, sha256_path, + current_target_triple, default_manifest_url, launcher_path_looks_installed, + path_taskers_executable, sha256_path, }; - use std::{collections::BTreeMap, fs, path::PathBuf}; + #[cfg(unix)] + use std::os::unix::fs::symlink; + use std::{collections::BTreeMap, ffi::OsString, fs, path::PathBuf}; use tar::Builder; use tempfile::tempdir; use xz2::write::XzEncoder; @@ -599,4 +666,29 @@ mod tests { assert!(installation.ghostty_resources_path().is_dir()); assert!(installation.terminfo_path().is_dir()); } + + #[test] + fn prefers_path_taskers_entry_when_it_matches_current_exe() { + let temp = tempdir().expect("tempdir"); + let install_bin = temp.path().join("xdg-bin"); + let real_bin = temp.path().join("cargo-bin"); + fs::create_dir_all(&install_bin).expect("install bin"); + fs::create_dir_all(&real_bin).expect("real bin"); + + let current_exe = real_bin.join("taskers"); + fs::write(¤t_exe, "#!/bin/sh\n").expect("current exe"); + #[cfg(unix)] + symlink(¤t_exe, install_bin.join("taskers")).expect("taskers symlink"); + + let path_env = OsString::from(install_bin.as_os_str()); + let resolved = path_taskers_executable(¤t_exe, Some(path_env)).expect("path taskers"); + + assert_eq!(resolved, install_bin.join("taskers")); + } + + #[test] + fn repo_local_binaries_do_not_look_installed() { + let repo_binary = PathBuf::from("/home/notes/Projects/taskers/target/debug/taskers"); + assert!(!launcher_path_looks_installed(&repo_binary)); + } } diff --git a/scripts/smoke_linux_release_launcher.sh b/scripts/smoke_linux_release_launcher.sh index ec1195d..b9463fc 100644 --- a/scripts/smoke_linux_release_launcher.sh +++ b/scripts/smoke_linux_release_launcher.sh @@ -62,6 +62,8 @@ display=":${display_number}" socket_path="$temp_dir/taskers.sock" session_path="$temp_dir/session.json" install_root="$temp_dir/install" +xdg_data_home="$temp_dir/data" +xdg_bin_home="$temp_dir/bin" version="$(sed -n 's/^version = "\(.*\)"/\1/p' "$REPO_ROOT/Cargo.toml" | head -n1)" target="$(rustc -vV | sed -n 's/^host: //p')" manifest_path="$temp_dir/taskers-manifest-v${version}.json" @@ -89,7 +91,10 @@ wait_for_path "/tmp/.X11-unix/X${display_number}" export TASKERS_INSTALL_ROOT="$install_root" export TASKERS_NON_UNIQUE=1 export TASKERS_RELEASE_MANIFEST_URL="$manifest_path" + export TASKERS_SKIP_DESKTOP_INTEGRATION=1 export TASKERS_TERMINAL_BACKEND=mock + export XDG_BIN_HOME="$xdg_bin_home" + export XDG_DATA_HOME="$xdg_data_home" exec "$TARGET_DIR/taskers" \ --demo \ --socket "$socket_path" \ From a1c9e07268dbcf0fb92dbb640860486e2d9e627a Mon Sep 17 00:00:00 2001 From: OneNoted Date: Tue, 17 Mar 2026 12:06:14 +0100 Subject: [PATCH 10/40] chore: prepare 0.3.0 release --- Cargo.lock | 20 ++++++++++---------- Cargo.toml | 6 +++--- crates/taskers-app/Cargo.toml | 8 ++++---- crates/taskers-cli/Cargo.toml | 4 ++-- crates/taskers-control/Cargo.toml | 2 +- crates/taskers-core/Cargo.toml | 8 ++++---- crates/taskers-launcher/src/lib.rs | 14 +++++++++----- crates/taskers-macos-ffi/Cargo.toml | 8 ++++---- crates/taskers-runtime/Cargo.toml | 2 +- 9 files changed, 38 insertions(+), 34 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 4cea574..949c120 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1507,7 +1507,7 @@ checksum = "df7f62577c25e07834649fc3b39fafdc597c0a3527dc1c60129201ccfcbaa50c" [[package]] name = "taskers" -version = "0.2.1" +version = "0.3.0" dependencies = [ "anyhow", "serde", @@ -1522,7 +1522,7 @@ dependencies = [ [[package]] name = "taskers-cli" -version = "0.2.1" +version = "0.3.0" dependencies = [ "anyhow", "clap", @@ -1535,7 +1535,7 @@ dependencies = [ [[package]] name = "taskers-control" -version = "0.2.1" +version = "0.3.0" dependencies = [ "anyhow", "serde", @@ -1550,7 +1550,7 @@ dependencies = [ [[package]] name = "taskers-core" -version = "0.2.1" +version = "0.3.0" dependencies = [ "anyhow", "serde", @@ -1565,7 +1565,7 @@ dependencies = [ [[package]] name = "taskers-domain" -version = "0.2.1" +version = "0.3.0" dependencies = [ "indexmap", "serde", @@ -1577,7 +1577,7 @@ dependencies = [ [[package]] name = "taskers-ghostty" -version = "0.2.1" +version = "0.3.0" dependencies = [ "gtk4", "libloading", @@ -1592,7 +1592,7 @@ dependencies = [ [[package]] name = "taskers-gtk" -version = "0.2.1" +version = "0.3.0" dependencies = [ "anyhow", "clap", @@ -1615,7 +1615,7 @@ dependencies = [ [[package]] name = "taskers-macos-ffi" -version = "0.2.1" +version = "0.3.0" dependencies = [ "serde", "serde_json", @@ -1630,11 +1630,11 @@ dependencies = [ [[package]] name = "taskers-paths" -version = "0.2.1" +version = "0.3.0" [[package]] name = "taskers-runtime" -version = "0.2.1" +version = "0.3.0" dependencies = [ "anyhow", "base64", diff --git a/Cargo.toml b/Cargo.toml index b60b8b6..c3bd846 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,7 +18,7 @@ edition = "2024" homepage = "https://github.com/OneNoted/taskers" license = "MIT OR Apache-2.0" repository = "https://github.com/OneNoted/taskers" -version = "0.2.1" +version = "0.3.0" [workspace.dependencies] adw = { package = "libadwaita", version = "0.9.1" } @@ -42,5 +42,5 @@ tokio = { version = "1.50.0", features = ["io-util", "macros", "net", "rt-multi- ureq = "2.12" uuid = { version = "1.22.0", features = ["serde", "v7"] } xz2 = "0.1" -taskers-core = { version = "0.2.1", path = "crates/taskers-core" } -taskers-paths = { version = "0.2.1", path = "crates/taskers-paths" } +taskers-core = { version = "0.3.0", path = "crates/taskers-core" } +taskers-paths = { version = "0.3.0", path = "crates/taskers-paths" } diff --git a/crates/taskers-app/Cargo.toml b/crates/taskers-app/Cargo.toml index 7a5cdeb..c34142f 100644 --- a/crates/taskers-app/Cargo.toml +++ b/crates/taskers-app/Cargo.toml @@ -23,11 +23,11 @@ serde_json.workspace = true svgtypes.workspace = true taskers-core.workspace = true taskers-paths.workspace = true -taskers-runtime = { version = "0.2.1", path = "../taskers-runtime" } +taskers-runtime = { version = "0.3.0", path = "../taskers-runtime" } tokio.workspace = true -taskers-control = { version = "0.2.1", path = "../taskers-control" } -taskers-domain = { version = "0.2.1", path = "../taskers-domain" } -taskers-ghostty = { version = "0.2.1", path = "../taskers-ghostty" } +taskers-control = { version = "0.3.0", path = "../taskers-control" } +taskers-domain = { version = "0.3.0", path = "../taskers-domain" } +taskers-ghostty = { version = "0.3.0", path = "../taskers-ghostty" } time.workspace = true toml.workspace = true diff --git a/crates/taskers-cli/Cargo.toml b/crates/taskers-cli/Cargo.toml index 9afb51e..551ecc2 100644 --- a/crates/taskers-cli/Cargo.toml +++ b/crates/taskers-cli/Cargo.toml @@ -18,5 +18,5 @@ clap.workspace = true serde_json.workspace = true time.workspace = true tokio.workspace = true -taskers-control = { version = "0.2.1", path = "../taskers-control" } -taskers-domain = { version = "0.2.1", path = "../taskers-domain" } +taskers-control = { version = "0.3.0", path = "../taskers-control" } +taskers-domain = { version = "0.3.0", path = "../taskers-domain" } diff --git a/crates/taskers-control/Cargo.toml b/crates/taskers-control/Cargo.toml index f6f441e..ad5d454 100644 --- a/crates/taskers-control/Cargo.toml +++ b/crates/taskers-control/Cargo.toml @@ -16,7 +16,7 @@ thiserror.workspace = true tokio.workspace = true uuid.workspace = true taskers-paths.workspace = true -taskers-domain = { version = "0.2.1", path = "../taskers-domain" } +taskers-domain = { version = "0.3.0", path = "../taskers-domain" } [dev-dependencies] tempfile.workspace = true diff --git a/crates/taskers-core/Cargo.toml b/crates/taskers-core/Cargo.toml index b624387..a89788b 100644 --- a/crates/taskers-core/Cargo.toml +++ b/crates/taskers-core/Cargo.toml @@ -12,11 +12,11 @@ version.workspace = true anyhow.workspace = true serde.workspace = true serde_json.workspace = true -taskers-control = { version = "0.2.1", path = "../taskers-control" } -taskers-domain = { version = "0.2.1", path = "../taskers-domain" } -taskers-ghostty = { version = "0.2.1", path = "../taskers-ghostty" } +taskers-control = { version = "0.3.0", path = "../taskers-control" } +taskers-domain = { version = "0.3.0", path = "../taskers-domain" } +taskers-ghostty = { version = "0.3.0", path = "../taskers-ghostty" } taskers-paths.workspace = true -taskers-runtime = { version = "0.2.1", path = "../taskers-runtime" } +taskers-runtime = { version = "0.3.0", path = "../taskers-runtime" } [dev-dependencies] tempfile.workspace = true diff --git a/crates/taskers-launcher/src/lib.rs b/crates/taskers-launcher/src/lib.rs index 8d13ecd..06e3962 100644 --- a/crates/taskers-launcher/src/lib.rs +++ b/crates/taskers-launcher/src/lib.rs @@ -570,16 +570,20 @@ mod tests { #[test] fn default_manifest_url_uses_exact_version() { - let url = default_manifest_url("0.2.1"); - assert!(url.ends_with("/releases/download/v0.2.1/taskers-manifest-v0.2.1.json")); + let version = env!("CARGO_PKG_VERSION"); + let url = default_manifest_url(version); + assert!(url.ends_with(&format!( + "/releases/download/v{version}/taskers-manifest-v{version}.json" + ))); } #[test] fn bundle_roots_match_linux_layout() { let root = PathBuf::from("/tmp/taskers"); + let version = env!("CARGO_PKG_VERSION"); assert_eq!( - bundle_root(&root, "0.2.1", "x86_64-unknown-linux-gnu"), - PathBuf::from("/tmp/taskers/0.2.1/x86_64-unknown-linux-gnu") + bundle_root(&root, version, "x86_64-unknown-linux-gnu"), + PathBuf::from(format!("/tmp/taskers/{version}/x86_64-unknown-linux-gnu")) ); } @@ -603,7 +607,7 @@ mod tests { .expect("taskersctl"); fs::write( bundle_dir.join("ghostty").join(".taskers-runtime-version"), - "0.2.1", + env!("CARGO_PKG_VERSION"), ) .expect("ghostty version"); fs::write( diff --git a/crates/taskers-macos-ffi/Cargo.toml b/crates/taskers-macos-ffi/Cargo.toml index 2afceba..4236987 100644 --- a/crates/taskers-macos-ffi/Cargo.toml +++ b/crates/taskers-macos-ffi/Cargo.toml @@ -16,11 +16,11 @@ crate-type = ["staticlib", "rlib"] serde.workspace = true serde_json.workspace = true taskers-core.workspace = true -taskers-control = { version = "0.2.1", path = "../taskers-control" } -taskers-domain = { version = "0.2.1", path = "../taskers-domain" } -taskers-ghostty = { version = "0.2.1", path = "../taskers-ghostty" } +taskers-control = { version = "0.3.0", path = "../taskers-control" } +taskers-domain = { version = "0.3.0", path = "../taskers-domain" } +taskers-ghostty = { version = "0.3.0", path = "../taskers-ghostty" } taskers-paths.workspace = true -taskers-runtime = { version = "0.2.1", path = "../taskers-runtime" } +taskers-runtime = { version = "0.3.0", path = "../taskers-runtime" } [dev-dependencies] tempfile.workspace = true diff --git a/crates/taskers-runtime/Cargo.toml b/crates/taskers-runtime/Cargo.toml index 42a6619..3b6c415 100644 --- a/crates/taskers-runtime/Cargo.toml +++ b/crates/taskers-runtime/Cargo.toml @@ -14,4 +14,4 @@ base64.workspace = true libc.workspace = true portable-pty.workspace = true taskers-paths.workspace = true -taskers-domain = { version = "0.2.1", path = "../taskers-domain" } +taskers-domain = { version = "0.3.0", path = "../taskers-domain" } From 8b1aa23587d1590344614da9837ffc562ab7d2c0 Mon Sep 17 00:00:00 2001 From: OneNoted Date: Tue, 17 Mar 2026 12:11:11 +0100 Subject: [PATCH 11/40] chore: validate 0.3.0 publish dry-runs --- docs/release.md | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/docs/release.md b/docs/release.md index 9f41315..dcf2f3c 100644 --- a/docs/release.md +++ b/docs/release.md @@ -83,17 +83,21 @@ bash scripts/notarize_macos_dmg.sh dist/Taskers-v-universal2.dmg python3 scripts/build_release_manifest.py ``` -- Dry-run crate publishing in dependency order: +- Dry-run the leaf crates that do not depend on unpublished workspace siblings: ```bash cargo publish --dry-run -p taskers-domain -cargo publish --dry-run -p taskers-control -cargo publish --dry-run -p taskers-runtime -cargo publish --dry-run -p taskers-ghostty -cargo publish --dry-run -p taskers-cli -cargo publish --dry-run -p taskers +cargo publish --dry-run -p taskers-paths ``` +- After you bump the workspace to a new unpublished version, `cargo publish --dry-run` for dependent crates will still resolve dependencies from crates.io and fail until the earlier crates are actually published. That failure is expected for: + - `taskers-control` + - `taskers-runtime` + - `taskers-ghostty` + - `taskers-cli` + - `taskers` +- Use the full local test/smoke suite as the pre-publish validation for those dependent crates, then publish them in order once the earlier versions are live on crates.io. + ## 4. Publish - Push the release tag so GitHub Actions can assemble the assets and attach them to a draft GitHub release. @@ -102,10 +106,11 @@ cargo publish --dry-run -p taskers - `taskers-linux-bundle-v-x86_64-unknown-linux-gnu.tar.xz` - `Taskers-v-universal2.dmg` - Publish the GitHub release so the launcher assets are publicly downloadable before publishing the crates. -- Publish the crates to crates.io in the same order as the dry-run: +- Publish the crates to crates.io in dependency order: ```bash cargo publish -p taskers-domain +cargo publish -p taskers-paths cargo publish -p taskers-control cargo publish -p taskers-runtime cargo publish -p taskers-ghostty @@ -113,6 +118,8 @@ cargo publish -p taskers-cli cargo publish -p taskers ``` +- Wait for crates.io to index each published version before publishing the next dependent crate, or Cargo will reject the dependency resolution for the later package. + ## 5. Post-Publish Check - Verify the Linux launcher install: From 1a80445d62ee185df9a5e3d3e3fe9825827b3336 Mon Sep 17 00:00:00 2001 From: OneNoted Date: Tue, 17 Mar 2026 12:45:15 +0100 Subject: [PATCH 12/40] fix: stop macOS preview from pulling GTK --- crates/taskers-ghostty/Cargo.toml | 4 +++- crates/taskers-ghostty/src/lib.rs | 2 ++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/crates/taskers-ghostty/Cargo.toml b/crates/taskers-ghostty/Cargo.toml index 56da84f..508b723 100644 --- a/crates/taskers-ghostty/Cargo.toml +++ b/crates/taskers-ghostty/Cargo.toml @@ -10,7 +10,6 @@ version.workspace = true build = "build.rs" [dependencies] -gtk.workspace = true libloading = "0.8" serde.workspace = true tar = "0.4" @@ -19,6 +18,9 @@ thiserror.workspace = true ureq = "2.12" xz2 = "0.1" +[target.'cfg(target_os = "linux")'.dependencies] +gtk.workspace = true + [features] default = [] libghostty = [] diff --git a/crates/taskers-ghostty/src/lib.rs b/crates/taskers-ghostty/src/lib.rs index 569a422..c9e2cad 100644 --- a/crates/taskers-ghostty/src/lib.rs +++ b/crates/taskers-ghostty/src/lib.rs @@ -1,4 +1,5 @@ pub mod backend; +#[cfg(target_os = "linux")] pub mod bridge; pub mod runtime; @@ -6,6 +7,7 @@ pub use backend::{ AdapterError, BackendAvailability, BackendChoice, BackendProbe, DefaultBackend, SurfaceDescriptor, TerminalBackend, }; +#[cfg(target_os = "linux")] pub use bridge::{GhosttyError, GhosttyHost}; pub use runtime::{ RuntimeBootstrap, RuntimeBootstrapError, configure_runtime_environment, From 2be6e41e785b6706d8169d5292b8fc1ec05ffcd9 Mon Sep 17 00:00:00 2001 From: OneNoted Date: Tue, 17 Mar 2026 12:48:14 +0100 Subject: [PATCH 13/40] fix: unblock macOS preview workflow --- scripts/generate_macos_project.sh | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/scripts/generate_macos_project.sh b/scripts/generate_macos_project.sh index e09286f..6a6954f 100755 --- a/scripts/generate_macos_project.sh +++ b/scripts/generate_macos_project.sh @@ -4,4 +4,5 @@ set -euo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" cd "${ROOT_DIR}" -xcodegen generate --spec macos/project.yml --project macos/Taskers.xcodeproj +rm -rf macos/Taskers.xcodeproj +xcodegen generate --spec macos/project.yml --project macos From 62e260b32cf25c2121137fba9bfc2181ad8f50bc Mon Sep 17 00:00:00 2001 From: OneNoted Date: Tue, 17 Mar 2026 12:52:40 +0100 Subject: [PATCH 14/40] chore: follow up after macOS workflow unblock --- .github/workflows/macos-preview.yml | 1 + macos/project.yml | 1 + 2 files changed, 2 insertions(+) diff --git a/.github/workflows/macos-preview.yml b/.github/workflows/macos-preview.yml index 25a5f4b..b77b959 100644 --- a/.github/workflows/macos-preview.yml +++ b/.github/workflows/macos-preview.yml @@ -59,6 +59,7 @@ jobs: - name: Build and test Taskers.app run: | + mkdir -p build/macos set -o pipefail xcodebuild test \ -project macos/Taskers.xcodeproj \ diff --git a/macos/project.yml b/macos/project.yml index 7d5e46f..3140285 100644 --- a/macos/project.yml +++ b/macos/project.yml @@ -1,6 +1,7 @@ name: Taskers options: bundleIdPrefix: dev.taskers + projectFormat: xcode15_3 deploymentTarget: macOS: "14.0" settings: From b2f15ced106fe6b05fec74b096b7e00d261cedaa Mon Sep 17 00:00:00 2001 From: OneNoted Date: Tue, 17 Mar 2026 12:56:04 +0100 Subject: [PATCH 15/40] fix: set macOS test host explicitly --- macos/project.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/macos/project.yml b/macos/project.yml index 3140285..f0e4614 100644 --- a/macos/project.yml +++ b/macos/project.yml @@ -51,7 +51,9 @@ targets: - target: TaskersMac settings: base: + BUNDLE_LOADER: $(TEST_HOST) PRODUCT_BUNDLE_IDENTIFIER: dev.taskers.tests + TEST_HOST: $(BUILT_PRODUCTS_DIR)/Taskers.app/Contents/MacOS/Taskers schemes: TaskersMac: build: From 8852ba156af35f84f9fac2b981c8c2825667ba28 Mon Sep 17 00:00:00 2001 From: OneNoted Date: Tue, 17 Mar 2026 13:00:57 +0100 Subject: [PATCH 16/40] fix: bootstrap macOS preview dependencies before xcodebuild --- scripts/generate_macos_project.sh | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/scripts/generate_macos_project.sh b/scripts/generate_macos_project.sh index 6a6954f..d3e5c2a 100755 --- a/scripts/generate_macos_project.sh +++ b/scripts/generate_macos_project.sh @@ -4,5 +4,11 @@ set -euo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" cd "${ROOT_DIR}" +if [[ ! -d build/macos/GhosttyKit.xcframework ]] \ + || [[ ! -f build/macos/libtaskers_macos_ffi.a ]] \ + || [[ ! -x build/macos/bin/taskersctl ]]; then + bash scripts/macos-build-preview-deps.sh +fi + rm -rf macos/Taskers.xcodeproj xcodegen generate --spec macos/project.yml --project macos From 5055c334263ce0689202a0374ef03de3b1d88165 Mon Sep 17 00:00:00 2001 From: OneNoted Date: Tue, 17 Mar 2026 13:06:19 +0100 Subject: [PATCH 17/40] fix: guard Ghostty pixel format for older macOS SDKs --- vendor/ghostty/pkg/macos/video/pixel_format.zig | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/vendor/ghostty/pkg/macos/video/pixel_format.zig b/vendor/ghostty/pkg/macos/video/pixel_format.zig index 78091da..3cff0c3 100644 --- a/vendor/ghostty/pkg/macos/video/pixel_format.zig +++ b/vendor/ghostty/pkg/macos/video/pixel_format.zig @@ -1,4 +1,8 @@ const c = @import("c.zig").c; +const thirty_rgb_r210: c_int = if (@hasDecl(c, "kCVPixelFormatType_30RGB_r210")) + c.kCVPixelFormatType_30RGB_r210 +else + c.kCVPixelFormatType_30RGB; pub const PixelFormat = enum(c_int) { /// 1 bit indexed @@ -52,7 +56,7 @@ pub const PixelFormat = enum(c_int) { /// 30 bit RGB, 10-bit big-endian samples, 2 unused padding bits (at least significant end). @"30RGB" = c.kCVPixelFormatType_30RGB, /// 30 bit RGB, 10-bit big-endian samples, 2 unused padding bits (at most significant end), video-range (64-940). - @"30RGB_r210" = c.kCVPixelFormatType_30RGB_r210, + @"30RGB_r210" = thirty_rgb_r210, /// Component Y'CbCr 8-bit 4:2:2, ordered Cb Y'0 Cr Y'1 @"422YpCbCr8" = c.kCVPixelFormatType_422YpCbCr8, /// Component Y'CbCrA 8-bit 4:4:4:4, ordered Cb Y' Cr A From fd5985342dfb620ca1f091c8e267783aa36854dc Mon Sep 17 00:00:00 2001 From: OneNoted Date: Tue, 17 Mar 2026 13:13:39 +0100 Subject: [PATCH 18/40] fix: drop unsupported Ghostty pixel format enum tag --- vendor/ghostty/pkg/macos/video/pixel_format.zig | 6 ------ 1 file changed, 6 deletions(-) diff --git a/vendor/ghostty/pkg/macos/video/pixel_format.zig b/vendor/ghostty/pkg/macos/video/pixel_format.zig index 3cff0c3..3672358 100644 --- a/vendor/ghostty/pkg/macos/video/pixel_format.zig +++ b/vendor/ghostty/pkg/macos/video/pixel_format.zig @@ -1,8 +1,4 @@ const c = @import("c.zig").c; -const thirty_rgb_r210: c_int = if (@hasDecl(c, "kCVPixelFormatType_30RGB_r210")) - c.kCVPixelFormatType_30RGB_r210 -else - c.kCVPixelFormatType_30RGB; pub const PixelFormat = enum(c_int) { /// 1 bit indexed @@ -55,8 +51,6 @@ pub const PixelFormat = enum(c_int) { @"16Gray" = c.kCVPixelFormatType_16Gray, /// 30 bit RGB, 10-bit big-endian samples, 2 unused padding bits (at least significant end). @"30RGB" = c.kCVPixelFormatType_30RGB, - /// 30 bit RGB, 10-bit big-endian samples, 2 unused padding bits (at most significant end), video-range (64-940). - @"30RGB_r210" = thirty_rgb_r210, /// Component Y'CbCr 8-bit 4:2:2, ordered Cb Y'0 Cr Y'1 @"422YpCbCr8" = c.kCVPixelFormatType_422YpCbCr8, /// Component Y'CbCrA 8-bit 4:4:4:4, ordered Cb Y' Cr A From f91b2fe391bcd68b92d820e191ee3a123227ce9f Mon Sep 17 00:00:00 2001 From: OneNoted Date: Tue, 17 Mar 2026 13:18:58 +0100 Subject: [PATCH 19/40] fix: skip Ghostty app build during macOS preview bootstrap --- scripts/macos-build-preview-deps.sh | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/scripts/macos-build-preview-deps.sh b/scripts/macos-build-preview-deps.sh index 9548853..550624a 100755 --- a/scripts/macos-build-preview-deps.sh +++ b/scripts/macos-build-preview-deps.sh @@ -17,7 +17,13 @@ cp "${ROOT_DIR}/target/release/taskersctl" "${BUILD_DIR}/bin/taskersctl" chmod +x "${BUILD_DIR}/bin/taskersctl" pushd "${GHOSTTY_DIR}" >/dev/null -zig build -Dapp-runtime=none -Demit-xcframework=true -Dxcframework-target=native +# Ghostty defaults `emit_macos_app` to `emit_xcframework`, but Taskers only +# needs the embedded framework/resources for preview builds. +zig build \ + -Dapp-runtime=none \ + -Demit-xcframework=true \ + -Demit-macos-app=false \ + -Dxcframework-target=native popd >/dev/null rm -rf "${BUILD_DIR}/GhosttyKit.xcframework" From 51b23f07b2ad45056ac88c1776ac8d3b8ed83d80 Mon Sep 17 00:00:00 2001 From: OneNoted Date: Tue, 17 Mar 2026 13:34:35 +0100 Subject: [PATCH 20/40] fix: copy Ghostty xcframework from the actual macOS build path --- scripts/macos-build-preview-deps.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/macos-build-preview-deps.sh b/scripts/macos-build-preview-deps.sh index 550624a..886bac7 100755 --- a/scripts/macos-build-preview-deps.sh +++ b/scripts/macos-build-preview-deps.sh @@ -27,7 +27,7 @@ zig build \ popd >/dev/null rm -rf "${BUILD_DIR}/GhosttyKit.xcframework" -cp -R "${GHOSTTY_DIR}/zig-out/macos/GhosttyKit.xcframework" "${BUILD_DIR}/GhosttyKit.xcframework" +cp -R "${GHOSTTY_DIR}/macos/GhosttyKit.xcframework" "${BUILD_DIR}/GhosttyKit.xcframework" rm -rf "${BUILD_DIR}/resources/ghostty" "${BUILD_DIR}/resources/terminfo" cp -R "${GHOSTTY_DIR}/zig-out/share/ghostty" "${BUILD_DIR}/resources/ghostty" From 326051c016304d4d6140895baff0ec354b063a4d Mon Sep 17 00:00:00 2001 From: OneNoted Date: Tue, 17 Mar 2026 13:47:35 +0100 Subject: [PATCH 21/40] fix: import GhosttyKit through the xcframework module --- macos/TaskersMac/Sources/TaskersMac-Bridging-Header.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/macos/TaskersMac/Sources/TaskersMac-Bridging-Header.h b/macos/TaskersMac/Sources/TaskersMac-Bridging-Header.h index eaa63f6..eee521f 100644 --- a/macos/TaskersMac/Sources/TaskersMac-Bridging-Header.h +++ b/macos/TaskersMac/Sources/TaskersMac-Bridging-Header.h @@ -1,2 +1,2 @@ #import "../../../crates/taskers-macos-ffi/include/taskers_macos_ffi.h" -#import "../../../vendor/ghostty/include/ghostty.h" +@import GhosttyKit; From f2ad2567c8bed067de37db0615d04db595947028 Mon Sep 17 00:00:00 2001 From: OneNoted Date: Tue, 17 Mar 2026 13:56:44 +0100 Subject: [PATCH 22/40] fix: resolve macOS host Swift compile errors --- .../Sources/TaskersGhosttyHost.swift | 2 +- .../TaskersMac/Sources/TaskersSnapshot.swift | 2 +- .../Sources/TaskersTerminalView.swift | 25 ++++++++++--------- macos/TaskersMac/Sources/main.swift | 7 +++++- 4 files changed, 21 insertions(+), 15 deletions(-) diff --git a/macos/TaskersMac/Sources/TaskersGhosttyHost.swift b/macos/TaskersMac/Sources/TaskersGhosttyHost.swift index 17e0016..6948625 100644 --- a/macos/TaskersMac/Sources/TaskersGhosttyHost.swift +++ b/macos/TaskersMac/Sources/TaskersGhosttyHost.swift @@ -131,7 +131,7 @@ final class TaskersGhosttyHost: NSObject { } } - fileprivate func surfaceDidClose(workspaceID: String, paneID: String, surfaceID: String) { + func surfaceDidClose(workspaceID: String, paneID: String, surfaceID: String) { onSurfaceClosed?(workspaceID, paneID, surfaceID) } diff --git a/macos/TaskersMac/Sources/TaskersSnapshot.swift b/macos/TaskersMac/Sources/TaskersSnapshot.swift index bd76292..6dcfb3d 100644 --- a/macos/TaskersMac/Sources/TaskersSnapshot.swift +++ b/macos/TaskersMac/Sources/TaskersSnapshot.swift @@ -156,7 +156,7 @@ enum TaskersSplitAxis: String, Decodable { case vertical } -enum TaskersLayoutNode: Decodable { +indirect enum TaskersLayoutNode: Decodable { case leaf(paneID: String) case split(axis: TaskersSplitAxis, ratio: UInt16, first: TaskersLayoutNode, second: TaskersLayoutNode) diff --git a/macos/TaskersMac/Sources/TaskersTerminalView.swift b/macos/TaskersMac/Sources/TaskersTerminalView.swift index 19757c4..1adde94 100644 --- a/macos/TaskersMac/Sources/TaskersTerminalView.swift +++ b/macos/TaskersMac/Sources/TaskersTerminalView.swift @@ -313,7 +313,8 @@ final class TaskersTerminalView: NSView { ) rethrows -> T { let entries = Array(environment) return try withCStringPairs(entries) { envVars in - try envVars.withUnsafeMutableBufferPointer { buffer in + var envVars = envVars + return try envVars.withUnsafeMutableBufferPointer { buffer in try body(buffer.baseAddress, buffer.count) } } @@ -359,35 +360,35 @@ final class TaskersTerminalView: NSView { } fileprivate static func modifiers(from flags: NSEvent.ModifierFlags) -> ghostty_input_mods_e { - var mods = Int32(GHOSTTY_MODS_NONE.rawValue) + var mods = GHOSTTY_MODS_NONE.rawValue if flags.contains(.shift) { - mods |= Int32(GHOSTTY_MODS_SHIFT.rawValue) + mods |= GHOSTTY_MODS_SHIFT.rawValue } if flags.contains(.control) { - mods |= Int32(GHOSTTY_MODS_CTRL.rawValue) + mods |= GHOSTTY_MODS_CTRL.rawValue } if flags.contains(.option) { - mods |= Int32(GHOSTTY_MODS_ALT.rawValue) + mods |= GHOSTTY_MODS_ALT.rawValue } if flags.contains(.command) { - mods |= Int32(GHOSTTY_MODS_SUPER.rawValue) + mods |= GHOSTTY_MODS_SUPER.rawValue } - return ghostty_input_mods_e(mods) + return ghostty_input_mods_e(rawValue: mods) ?? GHOSTTY_MODS_NONE } private static func modifierFlags(from mods: ghostty_input_mods_e) -> NSEvent.ModifierFlags { - let raw = Int32(mods.rawValue) + let raw = mods.rawValue var flags: NSEvent.ModifierFlags = [] - if raw & Int32(GHOSTTY_MODS_SHIFT.rawValue) != 0 { + if raw & GHOSTTY_MODS_SHIFT.rawValue != 0 { flags.insert(.shift) } - if raw & Int32(GHOSTTY_MODS_CTRL.rawValue) != 0 { + if raw & GHOSTTY_MODS_CTRL.rawValue != 0 { flags.insert(.control) } - if raw & Int32(GHOSTTY_MODS_ALT.rawValue) != 0 { + if raw & GHOSTTY_MODS_ALT.rawValue != 0 { flags.insert(.option) } - if raw & Int32(GHOSTTY_MODS_SUPER.rawValue) != 0 { + if raw & GHOSTTY_MODS_SUPER.rawValue != 0 { flags.insert(.command) } return flags diff --git a/macos/TaskersMac/Sources/main.swift b/macos/TaskersMac/Sources/main.swift index 201368e..1f670f2 100644 --- a/macos/TaskersMac/Sources/main.swift +++ b/macos/TaskersMac/Sources/main.swift @@ -1,7 +1,6 @@ import AppKit import Foundation -@main final class TaskersMacApplication: NSObject, NSApplicationDelegate { private var core: TaskersCoreBridge? private var ghosttyHost: TaskersGhosttyHost? @@ -50,3 +49,9 @@ final class TaskersMacApplication: NSObject, NSApplicationDelegate { return true } } + +let app = NSApplication.shared +let delegate = TaskersMacApplication() +app.delegate = delegate +app.setActivationPolicy(.regular) +app.run() From eb2cefbfe0414c1f0b4deb308f9430b4d1077a0a Mon Sep 17 00:00:00 2001 From: OneNoted Date: Tue, 17 Mar 2026 14:05:52 +0100 Subject: [PATCH 23/40] fix: link macOS host against system deps --- macos/project.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/macos/project.yml b/macos/project.yml index f0e4614..55d9f0d 100644 --- a/macos/project.yml +++ b/macos/project.yml @@ -22,6 +22,9 @@ targets: path: TaskersMac/Info.plist dependencies: - framework: ../build/macos/GhosttyKit.xcframework + - sdk: Carbon.framework + - sdk: libc++.tbd + - sdk: liblzma.tbd settings: base: PRODUCT_BUNDLE_IDENTIFIER: dev.taskers.app From abc7fd9b68ed5171e3d72e824fc687386263f832 Mon Sep 17 00:00:00 2001 From: OneNoted Date: Tue, 17 Mar 2026 14:14:17 +0100 Subject: [PATCH 24/40] fix: keep macOS test host alive under XCTest --- macos/TaskersMac/Sources/TaskersEnvironment.swift | 6 ++++++ macos/TaskersMac/Sources/main.swift | 5 ++++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/macos/TaskersMac/Sources/TaskersEnvironment.swift b/macos/TaskersMac/Sources/TaskersEnvironment.swift index 698c1c1..d81d1bd 100644 --- a/macos/TaskersMac/Sources/TaskersEnvironment.swift +++ b/macos/TaskersMac/Sources/TaskersEnvironment.swift @@ -25,6 +25,12 @@ enum TaskersEnvironment { ProcessInfo.processInfo.environment["TASKERS_SMOKE_TEST"] == "1" } + static var isRunningUnderXCTest: Bool { + let environment = ProcessInfo.processInfo.environment + return environment["XCTestConfigurationFilePath"] != nil + || environment["XCTestBundlePath"] != nil + } + static func scrubInheritedTerminalEnvironment() { for key in inheritedTerminalEnvironmentKeys { unsetenv(key) diff --git a/macos/TaskersMac/Sources/main.swift b/macos/TaskersMac/Sources/main.swift index 1f670f2..d353b66 100644 --- a/macos/TaskersMac/Sources/main.swift +++ b/macos/TaskersMac/Sources/main.swift @@ -10,6 +10,9 @@ final class TaskersMacApplication: NSObject, NSApplicationDelegate { _ = notification TaskersEnvironment.scrubInheritedTerminalEnvironment() TaskersEnvironment.configureBundledPaths() + if TaskersEnvironment.isRunningUnderXCTest { + return + } do { let core = try TaskersCoreBridge(options: TaskersEnvironment.defaultCoreOptions()) @@ -46,7 +49,7 @@ final class TaskersMacApplication: NSObject, NSApplicationDelegate { func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { _ = sender - return true + return !TaskersEnvironment.isRunningUnderXCTest } } From b2725cfa7060d5cbeb35abb5571e982354094c06 Mon Sep 17 00:00:00 2001 From: OneNoted Date: Tue, 17 Mar 2026 14:23:57 +0100 Subject: [PATCH 25/40] fix: preserve macOS snapshot map order --- crates/taskers-macos-ffi/src/lib.rs | 168 +++++++++++++++++- .../Sources/TaskersCoreBridge.swift | 2 +- .../TaskersMac/Sources/TaskersSnapshot.swift | 82 +++++---- .../TaskersSnapshotTests.swift | 50 +++--- 4 files changed, 235 insertions(+), 67 deletions(-) diff --git a/crates/taskers-macos-ffi/src/lib.rs b/crates/taskers-macos-ffi/src/lib.rs index 02051c9..aeea9f5 100644 --- a/crates/taskers-macos-ffi/src/lib.rs +++ b/crates/taskers-macos-ffi/src/lib.rs @@ -6,10 +6,14 @@ use std::{ str::FromStr, }; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use taskers_control::{ControlCommand, default_socket_path}; use taskers_core::{AppState, default_session_path, load_or_bootstrap}; -use taskers_domain::{PaneId, WorkspaceId}; +use taskers_domain::{ + AppModel, PaneId, PaneRecord, SurfaceId, SurfaceRecord, WindowId, WindowRecord, Workspace, + WorkspaceColumnId, WorkspaceColumnRecord, WorkspaceId, WorkspaceWindowId, + WorkspaceWindowRecord, +}; use taskers_ghostty::BackendChoice; use taskers_runtime::{ShellLaunchSpec, install_shell_integration}; @@ -32,6 +36,152 @@ struct CoreOptions { backend: Option, } +#[derive(Serialize)] +struct MacosSnapshot { + active_window: WindowId, + windows: Vec, + workspaces: Vec, +} + +#[derive(Serialize)] +struct MacosWindowRecord { + id: WindowId, + workspace_order: Vec, + active_workspace: WorkspaceId, +} + +#[derive(Serialize)] +struct MacosWorkspace { + id: WorkspaceId, + label: String, + columns: Vec, + windows: Vec, + active_window: WorkspaceWindowId, + panes: Vec, + active_pane: PaneId, +} + +#[derive(Serialize)] +struct MacosWorkspaceColumn { + id: WorkspaceColumnId, + width: i32, + window_order: Vec, + active_window: WorkspaceWindowId, +} + +#[derive(Serialize)] +struct MacosWorkspaceWindow { + id: WorkspaceWindowId, + height: i32, + layout: taskers_domain::LayoutNode, + active_pane: PaneId, +} + +#[derive(Serialize)] +struct MacosPane { + id: PaneId, + surfaces: Vec, + active_surface: SurfaceId, +} + +#[derive(Serialize)] +struct MacosSurface { + id: SurfaceId, + metadata: MacosSurfaceMetadata, +} + +#[derive(Serialize)] +struct MacosSurfaceMetadata { + title: Option, + cwd: Option, +} + +impl From for MacosSnapshot { + fn from(value: AppModel) -> Self { + Self { + active_window: value.active_window, + windows: value.windows.into_values().map(MacosWindowRecord::from).collect(), + workspaces: value.workspaces.into_values().map(MacosWorkspace::from).collect(), + } + } +} + +impl From for MacosWindowRecord { + fn from(value: WindowRecord) -> Self { + Self { + id: value.id, + workspace_order: value.workspace_order, + active_workspace: value.active_workspace, + } + } +} + +impl From for MacosWorkspace { + fn from(value: Workspace) -> Self { + Self { + id: value.id, + label: value.label, + columns: value + .columns + .into_values() + .map(MacosWorkspaceColumn::from) + .collect(), + windows: value + .windows + .into_values() + .map(MacosWorkspaceWindow::from) + .collect(), + active_window: value.active_window, + panes: value.panes.into_values().map(MacosPane::from).collect(), + active_pane: value.active_pane, + } + } +} + +impl From for MacosWorkspaceColumn { + fn from(value: WorkspaceColumnRecord) -> Self { + Self { + id: value.id, + width: value.width, + window_order: value.window_order, + active_window: value.active_window, + } + } +} + +impl From for MacosWorkspaceWindow { + fn from(value: WorkspaceWindowRecord) -> Self { + Self { + id: value.id, + height: value.height, + layout: value.layout, + active_pane: value.active_pane, + } + } +} + +impl From for MacosPane { + fn from(value: PaneRecord) -> Self { + Self { + id: value.id, + surfaces: value.surfaces.into_values().map(MacosSurface::from).collect(), + active_surface: value.active_surface, + } + } +} + +impl From for MacosSurface { + fn from(value: SurfaceRecord) -> Self { + Self { + id: value.id, + metadata: MacosSurfaceMetadata { + title: value.metadata.title, + cwd: value.metadata.cwd, + }, + } + } +} + thread_local! { static LAST_ERROR: RefCell> = const { RefCell::new(None) }; } @@ -99,7 +249,7 @@ impl TaskersMacosCore { } fn snapshot_json(&self) -> Result { - serde_json::to_string(&self.app_state.snapshot_model()) + serde_json::to_string(&MacosSnapshot::from(self.app_state.snapshot_model())) .map_err(|error| format!("failed to serialize snapshot: {error}")) } @@ -399,7 +549,17 @@ mod tests { let snapshot = core.snapshot_json().expect("snapshot"); let snapshot: Value = serde_json::from_str(&snapshot).expect("snapshot json"); - assert!(snapshot.get("workspaces").is_some()); + let workspaces = snapshot + .get("workspaces") + .and_then(Value::as_array) + .expect("workspaces array"); + assert!(!workspaces.is_empty()); + assert!( + workspaces[0] + .get("columns") + .and_then(Value::as_array) + .is_some() + ); let response = core .dispatch_json(r#"{"command":"create_workspace","label":"Docs"}"#) diff --git a/macos/TaskersMac/Sources/TaskersCoreBridge.swift b/macos/TaskersMac/Sources/TaskersCoreBridge.swift index e1c397e..f88eb1b 100644 --- a/macos/TaskersMac/Sources/TaskersCoreBridge.swift +++ b/macos/TaskersMac/Sources/TaskersCoreBridge.swift @@ -80,7 +80,7 @@ final class TaskersCoreBridge { let json = try callString { taskers_macos_core_snapshot_json(handle) } - return try decode(TaskersSnapshot.self, from: json) + return try TaskersSnapshot.parse(data: Data(json.utf8)) } @discardableResult diff --git a/macos/TaskersMac/Sources/TaskersSnapshot.swift b/macos/TaskersMac/Sources/TaskersSnapshot.swift index 6dcfb3d..36e2fd3 100644 --- a/macos/TaskersMac/Sources/TaskersSnapshot.swift +++ b/macos/TaskersMac/Sources/TaskersSnapshot.swift @@ -1,42 +1,21 @@ import Foundation -struct OrderedMap: Decodable { +protocol TaskersIdentifiedRecord { + var id: String { get } +} + +struct OrderedMap { let elements: [(String, Value)] private let storage: [String: Value] - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: DynamicCodingKey.self) - var elements: [(String, Value)] = [] - var storage: [String: Value] = [:] - storage.reserveCapacity(container.allKeys.count) - - for key in container.allKeys { - let value = try container.decode(Value.self, forKey: key) - elements.append((key.stringValue, value)) - storage[key.stringValue] = value - } - - self.elements = elements - self.storage = storage + init(values: [Value]) { + self.elements = values.map { ($0.id, $0) } + self.storage = Dictionary(uniqueKeysWithValues: elements) } subscript(key: String) -> Value? { storage[key] } - - struct DynamicCodingKey: CodingKey { - var stringValue: String - var intValue: Int? - - init?(stringValue: String) { - self.stringValue = stringValue - } - - init?(intValue: Int) { - self.stringValue = String(intValue) - self.intValue = intValue - } - } } struct TaskersSnapshot: Decodable { @@ -50,6 +29,17 @@ struct TaskersSnapshot: Decodable { case workspaces } + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + activeWindow = try container.decode(String.self, forKey: .activeWindow) + windows = OrderedMap(values: try container.decode([TaskersWindowRecord].self, forKey: .windows)) + workspaces = OrderedMap(values: try container.decode([TaskersWorkspace].self, forKey: .workspaces)) + } + + static func parse(data: Data) throws -> TaskersSnapshot { + try JSONDecoder().decode(Self.self, from: data) + } + var activeWorkspace: TaskersWorkspace? { guard let window = windows[activeWindow] else { return nil @@ -69,7 +59,7 @@ struct TaskersSnapshot: Decodable { } } -struct TaskersWindowRecord: Decodable { +struct TaskersWindowRecord: Decodable, TaskersIdentifiedRecord { let id: String let workspaceOrder: [String] let activeWorkspace: String @@ -81,7 +71,7 @@ struct TaskersWindowRecord: Decodable { } } -struct TaskersWorkspace: Decodable { +struct TaskersWorkspace: Decodable, TaskersIdentifiedRecord { let id: String let label: String let columns: OrderedMap @@ -99,9 +89,20 @@ struct TaskersWorkspace: Decodable { case panes case activePane = "active_pane" } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decode(String.self, forKey: .id) + label = try container.decode(String.self, forKey: .label) + columns = OrderedMap(values: try container.decode([TaskersWorkspaceColumn].self, forKey: .columns)) + windows = OrderedMap(values: try container.decode([TaskersWorkspaceWindow].self, forKey: .windows)) + activeWindow = try container.decode(String.self, forKey: .activeWindow) + panes = OrderedMap(values: try container.decode([TaskersPane].self, forKey: .panes)) + activePane = try container.decode(String.self, forKey: .activePane) + } } -struct TaskersWorkspaceColumn: Decodable { +struct TaskersWorkspaceColumn: Decodable, TaskersIdentifiedRecord { let id: String let width: Int let windowOrder: [String] @@ -115,7 +116,7 @@ struct TaskersWorkspaceColumn: Decodable { } } -struct TaskersWorkspaceWindow: Decodable { +struct TaskersWorkspaceWindow: Decodable, TaskersIdentifiedRecord { let id: String let height: Int let layout: TaskersLayoutNode @@ -129,7 +130,7 @@ struct TaskersWorkspaceWindow: Decodable { } } -struct TaskersPane: Decodable { +struct TaskersPane: Decodable, TaskersIdentifiedRecord { let id: String let surfaces: OrderedMap let activeSurface: String @@ -139,9 +140,16 @@ struct TaskersPane: Decodable { case surfaces case activeSurface = "active_surface" } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decode(String.self, forKey: .id) + surfaces = OrderedMap(values: try container.decode([TaskersSurface].self, forKey: .surfaces)) + activeSurface = try container.decode(String.self, forKey: .activeSurface) + } } -struct TaskersSurface: Decodable { +struct TaskersSurface: Decodable, TaskersIdentifiedRecord { let id: String let metadata: TaskersSurfaceMetadata } @@ -169,14 +177,14 @@ indirect enum TaskersLayoutNode: Decodable { case second } - enum NodeKind: String, Decodable { + enum Kind: String, Decodable { case leaf case split } init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) - switch try container.decode(NodeKind.self, forKey: .kind) { + switch try container.decode(Kind.self, forKey: .kind) { case .leaf: self = .leaf(paneID: try container.decode(String.self, forKey: .paneID)) case .split: diff --git a/macos/TaskersMacTests/TaskersSnapshotTests.swift b/macos/TaskersMacTests/TaskersSnapshotTests.swift index 6033dcb..ab815cb 100644 --- a/macos/TaskersMacTests/TaskersSnapshotTests.swift +++ b/macos/TaskersMacTests/TaskersSnapshotTests.swift @@ -6,75 +6,75 @@ final class TaskersSnapshotTests: XCTestCase { let json = """ { "active_window": "window-a", - "windows": { - "window-a": { + "windows": [ + { "id": "window-a", "workspace_order": ["workspace-a"], "active_workspace": "workspace-a" } - }, - "workspaces": { - "workspace-a": { + ], + "workspaces": [ + { "id": "workspace-a", "label": "Main", - "columns": { - "column-a": { + "columns": [ + { "id": "column-a", "width": 520, "window_order": ["window-1"], "active_window": "window-1" }, - "column-b": { + { "id": "column-b", "width": 360, "window_order": ["window-2"], "active_window": "window-2" } - }, - "windows": { - "window-1": { + ], + "windows": [ + { "id": "window-1", "height": 400, "layout": { "kind": "leaf", "pane_id": "pane-1" }, "active_pane": "pane-1" }, - "window-2": { + { "id": "window-2", "height": 400, "layout": { "kind": "leaf", "pane_id": "pane-2" }, "active_pane": "pane-2" } - }, + ], "active_window": "window-1", - "panes": { - "pane-1": { + "panes": [ + { "id": "pane-1", - "surfaces": { - "surface-1": { + "surfaces": [ + { "id": "surface-1", "metadata": { "title": "One", "cwd": null } } - }, + ], "active_surface": "surface-1" }, - "pane-2": { + { "id": "pane-2", - "surfaces": { - "surface-2": { + "surfaces": [ + { "id": "surface-2", "metadata": { "title": "Two", "cwd": null } } - }, + ], "active_surface": "surface-2" } - }, + ], "active_pane": "pane-1" } - } + ] } """ - let snapshot = try JSONDecoder().decode(TaskersSnapshot.self, from: Data(json.utf8)) + let snapshot = try TaskersSnapshot.parse(data: Data(json.utf8)) let columns = snapshot.activeWorkspace?.columns.elements.map(\.0) XCTAssertEqual(columns, ["column-a", "column-b"]) XCTAssertEqual(snapshot.liveSurfaceIDs, Set(["surface-1", "surface-2"])) From e2d8d01b9c2452c3ccbfb6366e407013767af4f8 Mon Sep 17 00:00:00 2001 From: OneNoted Date: Tue, 17 Mar 2026 14:43:12 +0100 Subject: [PATCH 26/40] fix: harden macOS Ghostty teardown --- .../Sources/TaskersGhosttyHost.swift | 6 +- .../Sources/TaskersTerminalView.swift | 127 ++++++++++++++---- .../Sources/TaskersWorkspaceController.swift | 32 ++++- 3 files changed, 136 insertions(+), 29 deletions(-) diff --git a/macos/TaskersMac/Sources/TaskersGhosttyHost.swift b/macos/TaskersMac/Sources/TaskersGhosttyHost.swift index 6948625..315ac26 100644 --- a/macos/TaskersMac/Sources/TaskersGhosttyHost.swift +++ b/macos/TaskersMac/Sources/TaskersGhosttyHost.swift @@ -61,7 +61,7 @@ final class TaskersGhosttyHost: NSObject { confirm_read_clipboard_cb: { _, _, _, _ in }, write_clipboard_cb: { _, _, _, _, _ in }, close_surface_cb: { userdata, _ in - TaskersTerminalView.from(userdata: userdata)?.handleSurfaceClosed() + TaskersGhosttySurfaceContext.from(userdata: userdata)?.handleSurfaceClosed() } ) @@ -144,13 +144,13 @@ final class TaskersGhosttyHost: NSObject { return false } - guard let view = TaskersTerminalView.from(surface: surface) else { + guard let context = TaskersGhosttySurfaceContext.from(surface: surface) else { return false } switch action.tag { case GHOSTTY_ACTION_SHOW_CHILD_EXITED: - view.handleChildExited(exitCode: action.action.child_exited.exit_code) + context.handleChildExited(exitCode: action.action.child_exited.exit_code) return true default: return false diff --git a/macos/TaskersMac/Sources/TaskersTerminalView.swift b/macos/TaskersMac/Sources/TaskersTerminalView.swift index 1adde94..80907c3 100644 --- a/macos/TaskersMac/Sources/TaskersTerminalView.swift +++ b/macos/TaskersMac/Sources/TaskersTerminalView.swift @@ -1,12 +1,73 @@ import AppKit import Foundation +final class TaskersGhosttySurfaceContext { + private weak var host: TaskersGhosttyHost? + private(set) var isClosing = false + + let workspaceID: String + let paneID: String + let surfaceID: String + + init(host: TaskersGhosttyHost, workspaceID: String, paneID: String, surfaceID: String) { + self.host = host + self.workspaceID = workspaceID + self.paneID = paneID + self.surfaceID = surfaceID + } + + func retainForUserdata() -> UnsafeMutableRawPointer { + Unmanaged.passRetained(self).toOpaque() + } + + func beginTeardown() { + isClosing = true + } + + func handleChildExited(exitCode: UInt32) { + _ = exitCode + closeSurfaceIfNeeded() + } + + func handleSurfaceClosed() { + closeSurfaceIfNeeded() + } + + private func closeSurfaceIfNeeded() { + guard !isClosing else { + return + } + + isClosing = true + host?.surfaceDidClose(workspaceID: workspaceID, paneID: paneID, surfaceID: surfaceID) + } + + static func from(surface: ghostty_surface_t) -> TaskersGhosttySurfaceContext? { + from(userdata: ghostty_surface_userdata(surface)) + } + + static func from(userdata: UnsafeMutableRawPointer?) -> TaskersGhosttySurfaceContext? { + guard let userdata else { + return nil + } + + return Unmanaged.fromOpaque(userdata).takeUnretainedValue() + } + + static func releaseUserdata(_ userdata: UnsafeMutableRawPointer) { + Unmanaged.fromOpaque(userdata).release() + } +} + final class TaskersTerminalView: NSView { let workspaceID: String let paneID: String let surfaceID: String private weak var host: TaskersGhosttyHost? + private let callbackContext: TaskersGhosttySurfaceContext + private var callbackContextHandle: UnsafeMutableRawPointer? + private var isDisposed = false private var surface: ghostty_surface_t? private var commandString: String @@ -26,6 +87,13 @@ final class TaskersTerminalView: NSView { self.workspaceID = workspaceID self.paneID = paneID self.surfaceID = surfaceID + self.callbackContext = TaskersGhosttySurfaceContext( + host: host, + workspaceID: workspaceID, + paneID: paneID, + surfaceID: surfaceID + ) + self.callbackContextHandle = callbackContext.retainForUserdata() self.commandString = Self.commandString(for: descriptor.commandArgv) super.init(frame: NSRect(x: 0, y: 0, width: 640, height: 420)) @@ -36,7 +104,8 @@ final class TaskersTerminalView: NSView { view: self, app: app, descriptor: descriptor, - commandString: commandString + commandString: commandString, + userdata: self.callbackContextHandle! ) updateSurfaceMetrics() } @@ -46,10 +115,7 @@ final class TaskersTerminalView: NSView { } deinit { - if let surface { - ghostty_surface_free(surface) - } - host?.unregisterSurface(self) + dispose() } override func becomeFirstResponder() -> Bool { @@ -135,13 +201,38 @@ final class TaskersTerminalView: NSView { needsDisplay = true } - func handleChildExited(exitCode: UInt32) { - _ = exitCode - host?.surfaceDidClose(workspaceID: workspaceID, paneID: paneID, surfaceID: surfaceID) + func beginTeardown() { + callbackContext.beginTeardown() } - func handleSurfaceClosed() { - host?.surfaceDidClose(workspaceID: workspaceID, paneID: paneID, surfaceID: surfaceID) + func dispose() { + guard !isDisposed else { + return + } + + isDisposed = true + callbackContext.beginTeardown() + host?.unregisterSurface(self) + + let surface = self.surface + self.surface = nil + + guard let callbackContextHandle else { + return + } + self.callbackContextHandle = nil + + let cleanup = { + if let surface { + ghostty_surface_free(surface) + } + TaskersGhosttySurfaceContext.releaseUserdata(callbackContextHandle) + } + if Thread.isMainThread { + cleanup() + } else { + DispatchQueue.main.async(execute: cleanup) + } } private func setFocused(_ focused: Bool) { @@ -254,7 +345,8 @@ final class TaskersTerminalView: NSView { view: TaskersTerminalView, app: ghostty_app_t, descriptor: TaskersSurfaceDescriptor, - commandString: String + commandString: String, + userdata: UnsafeMutableRawPointer ) throws -> ghostty_surface_t { let scale = NSScreen.main?.backingScaleFactor ?? 2.0 var config = ghostty_surface_config_new() @@ -262,7 +354,7 @@ final class TaskersTerminalView: NSView { config.platform = ghostty_platform_u( macos: ghostty_platform_macos_s(nsview: Unmanaged.passUnretained(view).toOpaque()) ) - config.userdata = Unmanaged.passUnretained(view).toOpaque() + config.userdata = userdata config.scale_factor = scale config.context = GHOSTTY_SURFACE_CONTEXT_SPLIT @@ -394,17 +486,6 @@ final class TaskersTerminalView: NSView { return flags } - static func from(surface: ghostty_surface_t) -> TaskersTerminalView? { - from(userdata: ghostty_surface_userdata(surface)) - } - - static func from(userdata: UnsafeMutableRawPointer?) -> TaskersTerminalView? { - guard let userdata else { - return nil - } - - return Unmanaged.fromOpaque(userdata).takeUnretainedValue() - } } private extension NSEvent { diff --git a/macos/TaskersMac/Sources/TaskersWorkspaceController.swift b/macos/TaskersMac/Sources/TaskersWorkspaceController.swift index 8cace14..1133092 100644 --- a/macos/TaskersMac/Sources/TaskersWorkspaceController.swift +++ b/macos/TaskersMac/Sources/TaskersWorkspaceController.swift @@ -40,6 +40,7 @@ final class TaskersWorkspaceController: NSWindowController { private var surfaceRegistry: [String: TaskersTerminalView] = [:] private var pollTimer: Timer? private var lastRevision: UInt64? + private var didShutdown = false var surfaceCount: Int { surfaceRegistry.count @@ -71,7 +72,12 @@ final class TaskersWorkspaceController: NSWindowController { } deinit { - pollTimer?.invalidate() + shutdown() + } + + override func close() { + shutdown() + super.close() } func start() throws { @@ -208,8 +214,11 @@ final class TaskersWorkspaceController: NSWindowController { private func pruneSurfaceRegistry(keeping liveSurfaceIDs: Set) { for surfaceID in Array(surfaceRegistry.keys) where !liveSurfaceIDs.contains(surfaceID) { - surfaceRegistry[surfaceID]?.removeFromSuperview() - surfaceRegistry.removeValue(forKey: surfaceID) + guard let surface = surfaceRegistry.removeValue(forKey: surfaceID) else { + continue + } + surface.dispose() + surface.removeFromSuperview() } } @@ -238,4 +247,21 @@ final class TaskersWorkspaceController: NSWindowController { ]) return view } + + private func shutdown() { + guard !didShutdown else { + return + } + + didShutdown = true + pollTimer?.invalidate() + pollTimer = nil + ghosttyHost.onSurfaceClosed = nil + for surface in surfaceRegistry.values { + surface.dispose() + surface.removeFromSuperview() + } + surfaceRegistry.removeAll() + window?.contentView = nil + } } From 306c09730ca594beb460726a11d2d999c50e2e72 Mon Sep 17 00:00:00 2001 From: OneNoted Date: Tue, 17 Mar 2026 15:04:53 +0100 Subject: [PATCH 27/40] fix: keep macOS Ghostty launch strings alive --- .../Sources/TaskersTerminalView.swift | 148 ++++++++++-------- 1 file changed, 82 insertions(+), 66 deletions(-) diff --git a/macos/TaskersMac/Sources/TaskersTerminalView.swift b/macos/TaskersMac/Sources/TaskersTerminalView.swift index 80907c3..0302c98 100644 --- a/macos/TaskersMac/Sources/TaskersTerminalView.swift +++ b/macos/TaskersMac/Sources/TaskersTerminalView.swift @@ -1,4 +1,5 @@ import AppKit +import Darwin import Foundation final class TaskersGhosttySurfaceContext { @@ -59,6 +60,70 @@ final class TaskersGhosttySurfaceContext { } } +final class TaskersGhosttyLaunchStorage { + private var workingDirectoryStorage: UnsafeMutablePointer? + private var commandStorage: UnsafeMutablePointer? + private var envKeyStorage: [UnsafeMutablePointer] = [] + private var envValueStorage: [UnsafeMutablePointer] = [] + private var envVars: [ghostty_env_var_s] = [] + + init(cwd: String?, command: String, environment: [String: String]) { + if let cwd { + workingDirectoryStorage = strdup(cwd) + } + commandStorage = strdup(command) + + let entries = environment.sorted { $0.key < $1.key } + envKeyStorage.reserveCapacity(entries.count) + envValueStorage.reserveCapacity(entries.count) + envVars.reserveCapacity(entries.count) + + for entry in entries { + guard + let key = strdup(entry.key), + let value = strdup(entry.value) + else { + continue + } + + envKeyStorage.append(key) + envValueStorage.append(value) + envVars.append(ghostty_env_var_s(key: UnsafePointer(key), value: UnsafePointer(value))) + } + } + + deinit { + if let workingDirectoryStorage { + free(workingDirectoryStorage) + } + if let commandStorage { + free(commandStorage) + } + for key in envKeyStorage { + free(key) + } + for value in envValueStorage { + free(value) + } + } + + var workingDirectoryPointer: UnsafePointer? { + workingDirectoryStorage.map(UnsafePointer.init) + } + + var commandPointer: UnsafePointer? { + commandStorage.map(UnsafePointer.init) + } + + func withEnvironment( + _ body: (UnsafeMutablePointer?, Int) throws -> T + ) rethrows -> T { + try envVars.withUnsafeMutableBufferPointer { buffer in + try body(buffer.baseAddress, buffer.count) + } + } +} + final class TaskersTerminalView: NSView { let workspaceID: String let paneID: String @@ -67,6 +132,7 @@ final class TaskersTerminalView: NSView { private weak var host: TaskersGhosttyHost? private let callbackContext: TaskersGhosttySurfaceContext private var callbackContextHandle: UnsafeMutableRawPointer? + private let launchStorage: TaskersGhosttyLaunchStorage private var isDisposed = false private var surface: ghostty_surface_t? private var commandString: String @@ -95,6 +161,11 @@ final class TaskersTerminalView: NSView { ) self.callbackContextHandle = callbackContext.retainForUserdata() self.commandString = Self.commandString(for: descriptor.commandArgv) + self.launchStorage = TaskersGhosttyLaunchStorage( + cwd: descriptor.cwd, + command: commandString, + environment: descriptor.env + ) super.init(frame: NSRect(x: 0, y: 0, width: 640, height: 420)) wantsLayer = true @@ -103,8 +174,7 @@ final class TaskersTerminalView: NSView { self.surface = try Self.createSurface( view: self, app: app, - descriptor: descriptor, - commandString: commandString, + launchStorage: launchStorage, userdata: self.callbackContextHandle! ) updateSurfaceMetrics() @@ -344,8 +414,7 @@ final class TaskersTerminalView: NSView { private static func createSurface( view: TaskersTerminalView, app: ghostty_app_t, - descriptor: TaskersSurfaceDescriptor, - commandString: String, + launchStorage: TaskersGhosttyLaunchStorage, userdata: UnsafeMutableRawPointer ) throws -> ghostty_surface_t { let scale = NSScreen.main?.backingScaleFactor ?? 2.0 @@ -357,20 +426,16 @@ final class TaskersTerminalView: NSView { config.userdata = userdata config.scale_factor = scale config.context = GHOSTTY_SURFACE_CONTEXT_SPLIT - - return try withCString(descriptor.cwd) { workingDirectory in - config.working_directory = workingDirectory - return try commandString.withCString { command in - config.command = command - return try withEnvironment(descriptor.env) { envVars, count in - config.env_vars = envVars - config.env_var_count = count - guard let surface = ghostty_surface_new(app, &config) else { - throw TaskersGhosttyHostError.appCreationFailed - } - return surface - } + config.working_directory = launchStorage.workingDirectoryPointer + config.command = launchStorage.commandPointer + + return try launchStorage.withEnvironment { envVars, count in + config.env_vars = envVars + config.env_var_count = count + guard let surface = ghostty_surface_new(app, &config) else { + throw TaskersGhosttyHostError.appCreationFailed } + return surface } } @@ -391,55 +456,6 @@ final class TaskersTerminalView: NSView { return "'" + value.replacingOccurrences(of: "'", with: "'\"'\"'") + "'" } - private static func withCString(_ value: String?, body: (UnsafePointer?) throws -> T) rethrows -> T { - guard let value else { - return try body(nil) - } - - return try value.withCString(body) - } - - private static func withEnvironment( - _ environment: [String: String], - body: (UnsafeMutablePointer?, Int) throws -> T - ) rethrows -> T { - let entries = Array(environment) - return try withCStringPairs(entries) { envVars in - var envVars = envVars - return try envVars.withUnsafeMutableBufferPointer { buffer in - try body(buffer.baseAddress, buffer.count) - } - } - } - - private static func withCStringPairs( - _ entries: [(key: String, value: String)], - body: ([ghostty_env_var_s]) throws -> T - ) rethrows -> T { - func recurse( - _ index: Int, - _ envVars: inout [ghostty_env_var_s], - _ body: ([ghostty_env_var_s]) throws -> T - ) rethrows -> T { - if index == entries.count { - return try body(envVars) - } - - let entry = entries[index] - return try entry.key.withCString { keyPointer in - try entry.value.withCString { valuePointer in - envVars.append(ghostty_env_var_s(key: keyPointer, value: valuePointer)) - defer { envVars.removeLast() } - return try recurse(index + 1, &envVars, body) - } - } - } - - var envVars: [ghostty_env_var_s] = [] - envVars.reserveCapacity(entries.count) - return try recurse(0, &envVars, body) - } - private static func mouseButton(from buttonNumber: Int) -> ghostty_input_mouse_button_e { switch buttonNumber { case 1: From 1f6436b3bfd2ffb4c1bc3c3a79a947effd17e382 Mon Sep 17 00:00:00 2001 From: OneNoted Date: Tue, 17 Mar 2026 15:13:31 +0100 Subject: [PATCH 28/40] fix: unblock macOS preview Swift build --- macos/TaskersMac/Sources/TaskersTerminalView.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/macos/TaskersMac/Sources/TaskersTerminalView.swift b/macos/TaskersMac/Sources/TaskersTerminalView.swift index 0302c98..1642903 100644 --- a/macos/TaskersMac/Sources/TaskersTerminalView.swift +++ b/macos/TaskersMac/Sources/TaskersTerminalView.swift @@ -108,11 +108,11 @@ final class TaskersGhosttyLaunchStorage { } var workingDirectoryPointer: UnsafePointer? { - workingDirectoryStorage.map(UnsafePointer.init) + workingDirectoryStorage.map { UnsafePointer($0) } } var commandPointer: UnsafePointer? { - commandStorage.map(UnsafePointer.init) + commandStorage.map { UnsafePointer($0) } } func withEnvironment( @@ -481,7 +481,7 @@ final class TaskersTerminalView: NSView { if flags.contains(.command) { mods |= GHOSTTY_MODS_SUPER.rawValue } - return ghostty_input_mods_e(rawValue: mods) ?? GHOSTTY_MODS_NONE + ghostty_input_mods_e(rawValue: mods) } private static func modifierFlags(from mods: ghostty_input_mods_e) -> NSEvent.ModifierFlags { From a21d194a47764c681cf33f3280e9e4b3c2c7989c Mon Sep 17 00:00:00 2001 From: OneNoted Date: Tue, 17 Mar 2026 15:14:08 +0100 Subject: [PATCH 29/40] fix: restore macOS modifier return value --- macos/TaskersMac/Sources/TaskersTerminalView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/macos/TaskersMac/Sources/TaskersTerminalView.swift b/macos/TaskersMac/Sources/TaskersTerminalView.swift index 1642903..3d8c61a 100644 --- a/macos/TaskersMac/Sources/TaskersTerminalView.swift +++ b/macos/TaskersMac/Sources/TaskersTerminalView.swift @@ -481,7 +481,7 @@ final class TaskersTerminalView: NSView { if flags.contains(.command) { mods |= GHOSTTY_MODS_SUPER.rawValue } - ghostty_input_mods_e(rawValue: mods) + return ghostty_input_mods_e(rawValue: mods) } private static func modifierFlags(from mods: ghostty_input_mods_e) -> NSEvent.ModifierFlags { From e89aad5ded0fd8d03f872a0e7fd042adc80a4533 Mon Sep 17 00:00:00 2001 From: OneNoted Date: Tue, 17 Mar 2026 15:22:38 +0100 Subject: [PATCH 30/40] fix: marshal macOS Ghostty close callbacks to main --- macos/TaskersMac/Sources/TaskersGhosttyHost.swift | 9 ++++++++- macos/TaskersMac/Sources/TaskersTerminalView.swift | 7 +++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/macos/TaskersMac/Sources/TaskersGhosttyHost.swift b/macos/TaskersMac/Sources/TaskersGhosttyHost.swift index 315ac26..dc61271 100644 --- a/macos/TaskersMac/Sources/TaskersGhosttyHost.swift +++ b/macos/TaskersMac/Sources/TaskersGhosttyHost.swift @@ -132,7 +132,14 @@ final class TaskersGhosttyHost: NSObject { } func surfaceDidClose(workspaceID: String, paneID: String, surfaceID: String) { - onSurfaceClosed?(workspaceID, paneID, surfaceID) + if Thread.isMainThread { + onSurfaceClosed?(workspaceID, paneID, surfaceID) + return + } + + DispatchQueue.main.async { [weak self] in + self?.onSurfaceClosed?(workspaceID, paneID, surfaceID) + } } private static func handleAction(target: ghostty_target_s, action: ghostty_action_s) -> Bool { diff --git a/macos/TaskersMac/Sources/TaskersTerminalView.swift b/macos/TaskersMac/Sources/TaskersTerminalView.swift index 3d8c61a..40ddc1f 100644 --- a/macos/TaskersMac/Sources/TaskersTerminalView.swift +++ b/macos/TaskersMac/Sources/TaskersTerminalView.swift @@ -35,6 +35,13 @@ final class TaskersGhosttySurfaceContext { } private func closeSurfaceIfNeeded() { + if !Thread.isMainThread { + DispatchQueue.main.async { [self] in + closeSurfaceIfNeeded() + } + return + } + guard !isClosing else { return } From d53b1750e3193ad0fe6a17dfb12b57509d5af47e Mon Sep 17 00:00:00 2001 From: OneNoted Date: Tue, 17 Mar 2026 15:36:00 +0100 Subject: [PATCH 31/40] fix: keep macOS Ghostty views in stable host containers --- .../Sources/TaskersWorkspaceController.swift | 35 ++++++++++++++++--- 1 file changed, 31 insertions(+), 4 deletions(-) diff --git a/macos/TaskersMac/Sources/TaskersWorkspaceController.swift b/macos/TaskersMac/Sources/TaskersWorkspaceController.swift index 1133092..e6329e1 100644 --- a/macos/TaskersMac/Sources/TaskersWorkspaceController.swift +++ b/macos/TaskersMac/Sources/TaskersWorkspaceController.swift @@ -1,6 +1,33 @@ import AppKit import Foundation +final class TaskersSurfaceHostView: NSView { + let terminalView: TaskersTerminalView + + init(terminalView: TaskersTerminalView) { + self.terminalView = terminalView + super.init(frame: .zero) + translatesAutoresizingMaskIntoConstraints = false + + terminalView.translatesAutoresizingMaskIntoConstraints = false + addSubview(terminalView) + NSLayoutConstraint.activate([ + terminalView.leadingAnchor.constraint(equalTo: leadingAnchor), + terminalView.trailingAnchor.constraint(equalTo: trailingAnchor), + terminalView.topAnchor.constraint(equalTo: topAnchor), + terminalView.bottomAnchor.constraint(equalTo: bottomAnchor) + ]) + } + + required init?(coder: NSCoder) { + return nil + } + + func dispose() { + terminalView.dispose() + } +} + final class WeightedSplitView: NSSplitView { private let weights: [CGFloat] @@ -37,7 +64,7 @@ final class WeightedSplitView: NSSplitView { final class TaskersWorkspaceController: NSWindowController { private let core: TaskersCoreBridge private let ghosttyHost: TaskersGhosttyHost - private var surfaceRegistry: [String: TaskersTerminalView] = [:] + private var surfaceRegistry: [String: TaskersSurfaceHostView] = [:] private var pollTimer: Timer? private var lastRevision: UInt64? private var didShutdown = false @@ -197,7 +224,6 @@ final class TaskersWorkspaceController: NSWindowController { let activeSurfaceID = pane.activeSurface if let existing = surfaceRegistry[activeSurfaceID] { - existing.removeFromSuperview() return existing } @@ -208,8 +234,9 @@ final class TaskersWorkspaceController: NSWindowController { surfaceID: activeSurfaceID, descriptor: descriptor ) - surfaceRegistry[activeSurfaceID] = terminalView - return terminalView + let hostView = TaskersSurfaceHostView(terminalView: terminalView) + surfaceRegistry[activeSurfaceID] = hostView + return hostView } private func pruneSurfaceRegistry(keeping liveSurfaceIDs: Set) { From 4ad7e1975630cfc7b9ad2e5a74c1318bf20ccded Mon Sep 17 00:00:00 2001 From: OneNoted Date: Tue, 17 Mar 2026 15:48:13 +0100 Subject: [PATCH 32/40] fix: defer macOS Ghostty surface frees --- macos/TaskersMac/Sources/TaskersTerminalView.swift | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/macos/TaskersMac/Sources/TaskersTerminalView.swift b/macos/TaskersMac/Sources/TaskersTerminalView.swift index 40ddc1f..1b1c19f 100644 --- a/macos/TaskersMac/Sources/TaskersTerminalView.swift +++ b/macos/TaskersMac/Sources/TaskersTerminalView.swift @@ -293,6 +293,7 @@ final class TaskersTerminalView: NSView { let surface = self.surface self.surface = nil + let launchStorage = self.launchStorage guard let callbackContextHandle else { return @@ -300,16 +301,13 @@ final class TaskersTerminalView: NSView { self.callbackContextHandle = nil let cleanup = { + _ = launchStorage if let surface { ghostty_surface_free(surface) } TaskersGhosttySurfaceContext.releaseUserdata(callbackContextHandle) } - if Thread.isMainThread { - cleanup() - } else { - DispatchQueue.main.async(execute: cleanup) - } + DispatchQueue.main.async(execute: cleanup) } private func setFocused(_ focused: Bool) { From f4312445638ad401f3f8fe0fb8cf19448a424714 Mon Sep 17 00:00:00 2001 From: OneNoted Date: Tue, 17 Mar 2026 15:56:15 +0100 Subject: [PATCH 33/40] chore: trace macOS smoke lifecycle --- .../Sources/TaskersTerminalView.swift | 15 +++++++++++++ .../Sources/TaskersWorkspaceController.swift | 2 ++ macos/TaskersMacTests/TaskersSmokeTests.swift | 21 +++++++++++++++++++ 3 files changed, 38 insertions(+) diff --git a/macos/TaskersMac/Sources/TaskersTerminalView.swift b/macos/TaskersMac/Sources/TaskersTerminalView.swift index 1b1c19f..65c399c 100644 --- a/macos/TaskersMac/Sources/TaskersTerminalView.swift +++ b/macos/TaskersMac/Sources/TaskersTerminalView.swift @@ -2,6 +2,16 @@ import AppKit import Darwin import Foundation +#if DEBUG +private func taskersMacDebugLog(_ message: @autoclosure () -> String) { + fputs("[taskers-macos] \(message())\n", stderr) +} +#else +private func taskersMacDebugLog(_ message: @autoclosure () -> String) { + _ = message() +} +#endif + final class TaskersGhosttySurfaceContext { private weak var host: TaskersGhosttyHost? private(set) var isClosing = false @@ -27,10 +37,12 @@ final class TaskersGhosttySurfaceContext { func handleChildExited(exitCode: UInt32) { _ = exitCode + taskersMacDebugLog("surface \(surfaceID) child exited") closeSurfaceIfNeeded() } func handleSurfaceClosed() { + taskersMacDebugLog("surface \(surfaceID) close callback") closeSurfaceIfNeeded() } @@ -290,6 +302,7 @@ final class TaskersTerminalView: NSView { isDisposed = true callbackContext.beginTeardown() host?.unregisterSurface(self) + taskersMacDebugLog("dispose start surface=\(surfaceID)") let surface = self.surface self.surface = nil @@ -302,10 +315,12 @@ final class TaskersTerminalView: NSView { let cleanup = { _ = launchStorage + taskersMacDebugLog("dispose cleanup begin surface=\(self.surfaceID)") if let surface { ghostty_surface_free(surface) } TaskersGhosttySurfaceContext.releaseUserdata(callbackContextHandle) + taskersMacDebugLog("dispose cleanup end surface=\(self.surfaceID)") } DispatchQueue.main.async(execute: cleanup) } diff --git a/macos/TaskersMac/Sources/TaskersWorkspaceController.swift b/macos/TaskersMac/Sources/TaskersWorkspaceController.swift index e6329e1..fdc8675 100644 --- a/macos/TaskersMac/Sources/TaskersWorkspaceController.swift +++ b/macos/TaskersMac/Sources/TaskersWorkspaceController.swift @@ -241,6 +241,7 @@ final class TaskersWorkspaceController: NSWindowController { private func pruneSurfaceRegistry(keeping liveSurfaceIDs: Set) { for surfaceID in Array(surfaceRegistry.keys) where !liveSurfaceIDs.contains(surfaceID) { + taskersMacDebugLog("prune surface=\(surfaceID)") guard let surface = surfaceRegistry.removeValue(forKey: surfaceID) else { continue } @@ -250,6 +251,7 @@ final class TaskersWorkspaceController: NSWindowController { } private func closeSurface(workspaceID: String, paneID: String, surfaceID: String) { + taskersMacDebugLog("close surface command workspace=\(workspaceID) pane=\(paneID) surface=\(surfaceID)") _ = try? core.dispatch(command: [ "command": "close_surface", "workspace_id": workspaceID, diff --git a/macos/TaskersMacTests/TaskersSmokeTests.swift b/macos/TaskersMacTests/TaskersSmokeTests.swift index 4366e15..4d73b0d 100644 --- a/macos/TaskersMacTests/TaskersSmokeTests.swift +++ b/macos/TaskersMacTests/TaskersSmokeTests.swift @@ -2,6 +2,16 @@ import AppKit import XCTest @testable import TaskersMac +#if DEBUG +private func smokeLog(_ message: @autoclosure () -> String) { + fputs("[taskers-smoke] \(message())\n", stderr) +} +#else +private func smokeLog(_ message: @autoclosure () -> String) { + _ = message() +} +#endif + @MainActor final class TaskersSmokeTests: XCTestCase { private var tempDirectory: URL! @@ -34,15 +44,19 @@ final class TaskersSmokeTests: XCTestCase { let host = TaskersGhosttyHost() let controller = TaskersWorkspaceController(core: core, ghosttyHost: host) + smokeLog("show window") controller.showWindow(nil) controller.window?.makeKeyAndOrderFront(nil) drainMainRunLoop() + smokeLog("initial refresh begin") try controller.refresh(force: true) + smokeLog("initial refresh end surfaces=\(controller.surfaceCount)") XCTAssertEqual(controller.surfaceCount, 1) let initialSurfaceIDs = controller.lastRenderedSurfaceIDs let initialWorkspace = try XCTUnwrap(try core.snapshot().activeWorkspace) + smokeLog("split pane dispatch") _ = try core.dispatch(command: [ "command": "split_pane", "workspace_id": initialWorkspace.id, @@ -50,23 +64,30 @@ final class TaskersSmokeTests: XCTestCase { "axis": "vertical" ]) drainMainRunLoop() + smokeLog("split refresh begin") try controller.refresh(force: true) + smokeLog("split refresh end surfaces=\(controller.surfaceCount)") XCTAssertEqual(controller.surfaceCount, 2) XCTAssertTrue(initialSurfaceIDs.isSubset(of: controller.lastRenderedSurfaceIDs)) let updatedWorkspace = try XCTUnwrap(try core.snapshot().activeWorkspace) + smokeLog("close pane dispatch") _ = try core.dispatch(command: [ "command": "close_pane", "workspace_id": updatedWorkspace.id, "pane_id": updatedWorkspace.activePane ]) drainMainRunLoop() + smokeLog("close refresh begin") try controller.refresh(force: true) + smokeLog("close refresh end surfaces=\(controller.surfaceCount)") XCTAssertEqual(controller.surfaceCount, 1) XCTAssertEqual(controller.lastRenderedSurfaceIDs.count, 1) + smokeLog("controller close begin") controller.close() drainMainRunLoop() + smokeLog("controller close end") } private func bootstrapTestEnvironment() throws { From d4a41b277f8558a75083479bc1d85084a09e703b Mon Sep 17 00:00:00 2001 From: OneNoted Date: Tue, 17 Mar 2026 16:05:56 +0100 Subject: [PATCH 34/40] chore: trace macOS smoke lifecycle via NSLog --- macos/TaskersMac/Sources/TaskersTerminalView.swift | 2 +- macos/TaskersMacTests/TaskersSmokeTests.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/macos/TaskersMac/Sources/TaskersTerminalView.swift b/macos/TaskersMac/Sources/TaskersTerminalView.swift index 65c399c..79f095a 100644 --- a/macos/TaskersMac/Sources/TaskersTerminalView.swift +++ b/macos/TaskersMac/Sources/TaskersTerminalView.swift @@ -4,7 +4,7 @@ import Foundation #if DEBUG private func taskersMacDebugLog(_ message: @autoclosure () -> String) { - fputs("[taskers-macos] \(message())\n", stderr) + NSLog("[taskers-macos] %@", message()) } #else private func taskersMacDebugLog(_ message: @autoclosure () -> String) { diff --git a/macos/TaskersMacTests/TaskersSmokeTests.swift b/macos/TaskersMacTests/TaskersSmokeTests.swift index 4d73b0d..6c7a78f 100644 --- a/macos/TaskersMacTests/TaskersSmokeTests.swift +++ b/macos/TaskersMacTests/TaskersSmokeTests.swift @@ -4,7 +4,7 @@ import XCTest #if DEBUG private func smokeLog(_ message: @autoclosure () -> String) { - fputs("[taskers-smoke] \(message())\n", stderr) + NSLog("[taskers-smoke] %@", message()) } #else private func smokeLog(_ message: @autoclosure () -> String) { From 7cf44aa545eee647405e13c2da6ff3562ebcf4b4 Mon Sep 17 00:00:00 2001 From: OneNoted Date: Tue, 17 Mar 2026 16:12:40 +0100 Subject: [PATCH 35/40] fix: expose macOS debug trace helper --- macos/TaskersMac/Sources/TaskersTerminalView.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/macos/TaskersMac/Sources/TaskersTerminalView.swift b/macos/TaskersMac/Sources/TaskersTerminalView.swift index 79f095a..411e42a 100644 --- a/macos/TaskersMac/Sources/TaskersTerminalView.swift +++ b/macos/TaskersMac/Sources/TaskersTerminalView.swift @@ -3,11 +3,11 @@ import Darwin import Foundation #if DEBUG -private func taskersMacDebugLog(_ message: @autoclosure () -> String) { +func taskersMacDebugLog(_ message: @autoclosure () -> String) { NSLog("[taskers-macos] %@", message()) } #else -private func taskersMacDebugLog(_ message: @autoclosure () -> String) { +func taskersMacDebugLog(_ message: @autoclosure () -> String) { _ = message() } #endif From 45f9b18f054038eee53fcdc0ff847a0e5bb5868c Mon Sep 17 00:00:00 2001 From: OneNoted Date: Tue, 17 Mar 2026 16:22:27 +0100 Subject: [PATCH 36/40] fix: decouple macOS smoke test from embedded host --- .../Sources/TaskersGhosttyHost.swift | 4 +- .../Sources/TaskersMockSurfaceHost.swift | 49 +++++++++++++++++++ .../Sources/TaskersSurfaceHosting.swift | 20 ++++++++ .../Sources/TaskersTerminalView.swift | 4 +- .../Sources/TaskersWorkspaceController.swift | 35 ++++++------- macos/TaskersMac/Sources/main.swift | 5 ++ macos/TaskersMacTests/TaskersSmokeTests.swift | 14 ++++-- 7 files changed, 106 insertions(+), 25 deletions(-) create mode 100644 macos/TaskersMac/Sources/TaskersMockSurfaceHost.swift create mode 100644 macos/TaskersMac/Sources/TaskersSurfaceHosting.swift diff --git a/macos/TaskersMac/Sources/TaskersGhosttyHost.swift b/macos/TaskersMac/Sources/TaskersGhosttyHost.swift index dc61271..67c4698 100644 --- a/macos/TaskersMac/Sources/TaskersGhosttyHost.swift +++ b/macos/TaskersMac/Sources/TaskersGhosttyHost.swift @@ -15,7 +15,7 @@ enum TaskersGhosttyHostError: LocalizedError { } } -final class TaskersGhosttyHost: NSObject { +final class TaskersGhosttyHost: NSObject, TaskersSurfaceHosting { private var config: ghostty_config_t? private var app: ghostty_app_t? private let surfaces = NSHashTable.weakObjects() @@ -80,7 +80,7 @@ final class TaskersGhosttyHost: NSObject { paneID: String, surfaceID: String, descriptor: TaskersSurfaceDescriptor - ) throws -> TaskersTerminalView { + ) throws -> any TaskersHostedSurface { try bootstrap() guard let app else { throw TaskersGhosttyHostError.appCreationFailed diff --git a/macos/TaskersMac/Sources/TaskersMockSurfaceHost.swift b/macos/TaskersMac/Sources/TaskersMockSurfaceHost.swift new file mode 100644 index 0000000..648147b --- /dev/null +++ b/macos/TaskersMac/Sources/TaskersMockSurfaceHost.swift @@ -0,0 +1,49 @@ +import AppKit +import Foundation + +final class TaskersMockSurfaceView: NSView, TaskersHostedSurface { + var hostingView: NSView { self } + + init(title: String?) { + super.init(frame: NSRect(x: 0, y: 0, width: 640, height: 420)) + wantsLayer = true + layer?.backgroundColor = NSColor.windowBackgroundColor.cgColor + + let label = NSTextField(labelWithString: title ?? "Taskers Mock Surface") + label.font = .monospacedSystemFont(ofSize: 14, weight: .medium) + label.textColor = .labelColor + label.translatesAutoresizingMaskIntoConstraints = false + addSubview(label) + + NSLayoutConstraint.activate([ + label.centerXAnchor.constraint(equalTo: centerXAnchor), + label.centerYAnchor.constraint(equalTo: centerYAnchor) + ]) + } + + required init?(coder: NSCoder) { + return nil + } + + func dispose() {} +} + +final class TaskersMockSurfaceHost: TaskersSurfaceHosting { + var onSurfaceClosed: ((String, String, String) -> Void)? + + func makeSurface( + workspaceID: String, + paneID: String, + surfaceID: String, + descriptor: TaskersSurfaceDescriptor + ) throws -> any TaskersHostedSurface { + _ = workspaceID + _ = paneID + _ = surfaceID + return TaskersMockSurfaceView(title: descriptor.title) + } + + func setFocused(_ focused: Bool) { + _ = focused + } +} diff --git a/macos/TaskersMac/Sources/TaskersSurfaceHosting.swift b/macos/TaskersMac/Sources/TaskersSurfaceHosting.swift new file mode 100644 index 0000000..4d0718c --- /dev/null +++ b/macos/TaskersMac/Sources/TaskersSurfaceHosting.swift @@ -0,0 +1,20 @@ +import AppKit +import Foundation + +protocol TaskersHostedSurface: AnyObject { + var hostingView: NSView { get } + func dispose() +} + +protocol TaskersSurfaceHosting: AnyObject { + var onSurfaceClosed: ((String, String, String) -> Void)? { get set } + + func makeSurface( + workspaceID: String, + paneID: String, + surfaceID: String, + descriptor: TaskersSurfaceDescriptor + ) throws -> any TaskersHostedSurface + + func setFocused(_ focused: Bool) +} diff --git a/macos/TaskersMac/Sources/TaskersTerminalView.swift b/macos/TaskersMac/Sources/TaskersTerminalView.swift index 411e42a..a468281 100644 --- a/macos/TaskersMac/Sources/TaskersTerminalView.swift +++ b/macos/TaskersMac/Sources/TaskersTerminalView.swift @@ -143,7 +143,7 @@ final class TaskersGhosttyLaunchStorage { } } -final class TaskersTerminalView: NSView { +final class TaskersTerminalView: NSView, TaskersHostedSurface { let workspaceID: String let paneID: String let surfaceID: String @@ -160,6 +160,8 @@ final class TaskersTerminalView: NSView { true } + var hostingView: NSView { self } + init( host: TaskersGhosttyHost, app: ghostty_app_t, diff --git a/macos/TaskersMac/Sources/TaskersWorkspaceController.swift b/macos/TaskersMac/Sources/TaskersWorkspaceController.swift index fdc8675..a43655c 100644 --- a/macos/TaskersMac/Sources/TaskersWorkspaceController.swift +++ b/macos/TaskersMac/Sources/TaskersWorkspaceController.swift @@ -2,20 +2,21 @@ import AppKit import Foundation final class TaskersSurfaceHostView: NSView { - let terminalView: TaskersTerminalView + let surface: any TaskersHostedSurface - init(terminalView: TaskersTerminalView) { - self.terminalView = terminalView + init(surface: any TaskersHostedSurface) { + self.surface = surface super.init(frame: .zero) translatesAutoresizingMaskIntoConstraints = false - terminalView.translatesAutoresizingMaskIntoConstraints = false - addSubview(terminalView) + let hostedView = surface.hostingView + hostedView.translatesAutoresizingMaskIntoConstraints = false + addSubview(hostedView) NSLayoutConstraint.activate([ - terminalView.leadingAnchor.constraint(equalTo: leadingAnchor), - terminalView.trailingAnchor.constraint(equalTo: trailingAnchor), - terminalView.topAnchor.constraint(equalTo: topAnchor), - terminalView.bottomAnchor.constraint(equalTo: bottomAnchor) + hostedView.leadingAnchor.constraint(equalTo: leadingAnchor), + hostedView.trailingAnchor.constraint(equalTo: trailingAnchor), + hostedView.topAnchor.constraint(equalTo: topAnchor), + hostedView.bottomAnchor.constraint(equalTo: bottomAnchor) ]) } @@ -24,7 +25,7 @@ final class TaskersSurfaceHostView: NSView { } func dispose() { - terminalView.dispose() + surface.dispose() } } @@ -63,7 +64,7 @@ final class WeightedSplitView: NSSplitView { final class TaskersWorkspaceController: NSWindowController { private let core: TaskersCoreBridge - private let ghosttyHost: TaskersGhosttyHost + private let surfaceHost: any TaskersSurfaceHosting private var surfaceRegistry: [String: TaskersSurfaceHostView] = [:] private var pollTimer: Timer? private var lastRevision: UInt64? @@ -77,9 +78,9 @@ final class TaskersWorkspaceController: NSWindowController { Set(surfaceRegistry.keys) } - init(core: TaskersCoreBridge, ghosttyHost: TaskersGhosttyHost) { + init(core: TaskersCoreBridge, ghosttyHost: any TaskersSurfaceHosting) { self.core = core - self.ghosttyHost = ghosttyHost + self.surfaceHost = ghosttyHost let window = NSWindow( contentRect: NSRect(x: 80, y: 80, width: 1380, height: 900), styleMask: [.titled, .closable, .miniaturizable, .resizable], @@ -89,7 +90,7 @@ final class TaskersWorkspaceController: NSWindowController { window.title = "Taskers" window.isReleasedWhenClosed = false super.init(window: window) - ghosttyHost.onSurfaceClosed = { [weak self] workspaceID, paneID, surfaceID in + surfaceHost.onSurfaceClosed = { [weak self] workspaceID, paneID, surfaceID in self?.closeSurface(workspaceID: workspaceID, paneID: paneID, surfaceID: surfaceID) } } @@ -228,13 +229,13 @@ final class TaskersWorkspaceController: NSWindowController { } let descriptor = try core.surfaceDescriptor(workspaceId: workspace.id, paneId: paneID) - let terminalView = try ghosttyHost.makeSurface( + let surface = try surfaceHost.makeSurface( workspaceID: workspace.id, paneID: paneID, surfaceID: activeSurfaceID, descriptor: descriptor ) - let hostView = TaskersSurfaceHostView(terminalView: terminalView) + let hostView = TaskersSurfaceHostView(surface: surface) surfaceRegistry[activeSurfaceID] = hostView return hostView } @@ -285,7 +286,7 @@ final class TaskersWorkspaceController: NSWindowController { didShutdown = true pollTimer?.invalidate() pollTimer = nil - ghosttyHost.onSurfaceClosed = nil + surfaceHost.onSurfaceClosed = nil for surface in surfaceRegistry.values { surface.dispose() surface.removeFromSuperview() diff --git a/macos/TaskersMac/Sources/main.swift b/macos/TaskersMac/Sources/main.swift index d353b66..8ecec82 100644 --- a/macos/TaskersMac/Sources/main.swift +++ b/macos/TaskersMac/Sources/main.swift @@ -27,6 +27,11 @@ final class TaskersMacApplication: NSObject, NSApplicationDelegate { if TaskersEnvironment.isSmokeTestEnabled { DispatchQueue.main.asyncAfter(deadline: .now() + 2) { + guard let controller = self.workspaceController, controller.surfaceCount > 0 else { + fputs("Taskers macOS smoke failed: no surfaces rendered\n", stderr) + fflush(stderr) + exit(1) + } NSApp.terminate(nil) } } diff --git a/macos/TaskersMacTests/TaskersSmokeTests.swift b/macos/TaskersMacTests/TaskersSmokeTests.swift index 6c7a78f..b498469 100644 --- a/macos/TaskersMacTests/TaskersSmokeTests.swift +++ b/macos/TaskersMacTests/TaskersSmokeTests.swift @@ -39,9 +39,9 @@ final class TaskersSmokeTests: XCTestCase { socketPath: tempDirectory.appendingPathComponent("taskers.sock").path, configuredShell: "/bin/sh", demo: false, - backend: "ghostty_embedded" + backend: "mock" )) - let host = TaskersGhosttyHost() + let host = TaskersMockSurfaceHost() let controller = TaskersWorkspaceController(core: core, ghosttyHost: host) smokeLog("show window") @@ -100,14 +100,18 @@ final class TaskersSmokeTests: XCTestCase { let terminfo = resourcesRoot.appendingPathComponent("terminfo", isDirectory: true) let helper = repoRoot.appendingPathComponent("build/macos/bin/taskersctl") - for requiredPath in [ghosttyResources, terminfo, helper] { + for requiredPath in [helper] { guard FileManager.default.fileExists(atPath: requiredPath.path) else { throw XCTSkip("missing preview dependency at \(requiredPath.path)") } } - setenv("GHOSTTY_RESOURCES_DIR", ghosttyResources.path, 1) - setenv("TERMINFO", terminfo.path, 1) + if FileManager.default.fileExists(atPath: ghosttyResources.path) { + setenv("GHOSTTY_RESOURCES_DIR", ghosttyResources.path, 1) + } + if FileManager.default.fileExists(atPath: terminfo.path) { + setenv("TERMINFO", terminfo.path, 1) + } setenv("TASKERS_CTL_PATH", helper.path, 1) setenv("TASKERS_DISABLE_SHELL_INTEGRATION", "1", 1) } From 8e719090ccbf70363394b8e2b325f6540c624773 Mon Sep 17 00:00:00 2001 From: OneNoted Date: Tue, 17 Mar 2026 16:33:59 +0100 Subject: [PATCH 37/40] fix: avoid macOS test layout recursion --- macos/TaskersMacTests/TaskersSmokeTests.swift | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/macos/TaskersMacTests/TaskersSmokeTests.swift b/macos/TaskersMacTests/TaskersSmokeTests.swift index b498469..98fdf4a 100644 --- a/macos/TaskersMacTests/TaskersSmokeTests.swift +++ b/macos/TaskersMacTests/TaskersSmokeTests.swift @@ -117,8 +117,10 @@ final class TaskersSmokeTests: XCTestCase { } private func drainMainRunLoop() { - controllerWindow?.contentView?.layoutSubtreeIfNeeded() - RunLoop.current.run(until: Date().addingTimeInterval(0.1)) + let deadline = Date().addingTimeInterval(0.1) + while Date() < deadline { + _ = RunLoop.current.run(mode: .default, before: deadline) + } } private var controllerWindow: NSWindow? { From 37021200575ff97d95ccefc52c803516159fe26e Mon Sep 17 00:00:00 2001 From: OneNoted Date: Tue, 17 Mar 2026 16:58:08 +0100 Subject: [PATCH 38/40] fix: avoid macOS split view layout recursion --- .../Sources/TaskersWorkspaceController.swift | 49 ++++++++++++++++++- 1 file changed, 48 insertions(+), 1 deletion(-) diff --git a/macos/TaskersMac/Sources/TaskersWorkspaceController.swift b/macos/TaskersMac/Sources/TaskersWorkspaceController.swift index a43655c..3e81ded 100644 --- a/macos/TaskersMac/Sources/TaskersWorkspaceController.swift +++ b/macos/TaskersMac/Sources/TaskersWorkspaceController.swift @@ -31,6 +31,13 @@ final class TaskersSurfaceHostView: NSView { final class WeightedSplitView: NSSplitView { private let weights: [CGFloat] + private var hasPendingWeightApplication = false + private var lastAppliedSignature: WeightApplicationSignature? + + private struct WeightApplicationSignature: Equatable { + let size: CGSize + let arrangedSubviewCount: Int + } init(isVertical: Bool, weights: [CGFloat]) { self.weights = weights @@ -46,19 +53,59 @@ final class WeightedSplitView: NSSplitView { override func layout() { super.layout() + scheduleWeightApplication() + } + + override func didAddSubview(_ subview: NSView) { + super.didAddSubview(subview) + scheduleWeightApplication() + } + + private func scheduleWeightApplication() { guard arrangedSubviews.count > 1 else { return } + let signature = WeightApplicationSignature( + size: bounds.size, + arrangedSubviewCount: arrangedSubviews.count + ) + guard signature != lastAppliedSignature || !hasPendingWeightApplication else { + return + } + + hasPendingWeightApplication = true + DispatchQueue.main.async { [weak self] in + self?.applyWeightsIfNeeded(expectedSignature: signature) + } + } + + private func applyWeightsIfNeeded(expectedSignature: WeightApplicationSignature) { + hasPendingWeightApplication = false + guard arrangedSubviews.count > 1 else { + lastAppliedSignature = expectedSignature + return + } + + let currentSignature = WeightApplicationSignature( + size: bounds.size, + arrangedSubviewCount: arrangedSubviews.count + ) + guard currentSignature == expectedSignature else { + scheduleWeightApplication() + return + } + let totalWeight = max(weights.reduce(0, +), 1) let dividerCount = CGFloat(arrangedSubviews.count - 1) - let available = (isVertical ? bounds.width : bounds.height) - (dividerThickness * dividerCount) + let available = max((isVertical ? bounds.width : bounds.height) - (dividerThickness * dividerCount), 0) var consumed: CGFloat = 0 for index in 0..<(arrangedSubviews.count - 1) { consumed += available * (weights[index] / totalWeight) setPosition(consumed + dividerThickness * CGFloat(index), ofDividerAt: index) } + lastAppliedSignature = currentSignature } } From 38b06b28c573da33cf012db2ff14ff35946d5420 Mon Sep 17 00:00:00 2001 From: OneNoted Date: Tue, 17 Mar 2026 17:07:55 +0100 Subject: [PATCH 39/40] chore: monitor macOS preview --- macos/TaskersMac/Sources/TaskersEnvironment.swift | 9 +++++++++ macos/TaskersMac/Sources/TaskersGhosttyHost.swift | 4 ++++ macos/TaskersMac/Sources/TaskersTerminalView.swift | 6 +++++- macos/TaskersMac/Sources/main.swift | 6 ++++++ 4 files changed, 24 insertions(+), 1 deletion(-) diff --git a/macos/TaskersMac/Sources/TaskersEnvironment.swift b/macos/TaskersMac/Sources/TaskersEnvironment.swift index d81d1bd..95b4112 100644 --- a/macos/TaskersMac/Sources/TaskersEnvironment.swift +++ b/macos/TaskersMac/Sources/TaskersEnvironment.swift @@ -57,6 +57,15 @@ enum TaskersEnvironment { ) } + static func emitSmokeLog(_ message: String) { + guard isSmokeTestEnabled else { + return + } + + fputs("Taskers macOS smoke: \(message)\n", stderr) + fflush(stderr) + } + private static func setPath(_ key: String, url: URL) { guard FileManager.default.fileExists(atPath: url.path) else { return diff --git a/macos/TaskersMac/Sources/TaskersGhosttyHost.swift b/macos/TaskersMac/Sources/TaskersGhosttyHost.swift index 67c4698..c28f036 100644 --- a/macos/TaskersMac/Sources/TaskersGhosttyHost.swift +++ b/macos/TaskersMac/Sources/TaskersGhosttyHost.swift @@ -40,6 +40,7 @@ final class TaskersGhosttyHost: NSObject, TaskersSurfaceHosting { return } + TaskersEnvironment.emitSmokeLog("Ghostty host bootstrap begin") guard let config = ghostty_config_new() else { throw TaskersGhosttyHostError.configurationFailed } @@ -73,6 +74,7 @@ final class TaskersGhosttyHost: NSObject, TaskersSurfaceHosting { self.config = config self.app = app ghostty_app_set_focus(app, NSApp.isActive) + TaskersEnvironment.emitSmokeLog("Ghostty host bootstrap end") } func makeSurface( @@ -86,6 +88,7 @@ final class TaskersGhosttyHost: NSObject, TaskersSurfaceHosting { throw TaskersGhosttyHostError.appCreationFailed } + TaskersEnvironment.emitSmokeLog("creating Ghostty surface \(surfaceID)") let view = try TaskersTerminalView( host: self, app: app, @@ -95,6 +98,7 @@ final class TaskersGhosttyHost: NSObject, TaskersSurfaceHosting { descriptor: descriptor ) registerSurface(view) + TaskersEnvironment.emitSmokeLog("created Ghostty surface \(surfaceID)") return view } diff --git a/macos/TaskersMac/Sources/TaskersTerminalView.swift b/macos/TaskersMac/Sources/TaskersTerminalView.swift index a468281..4a086a2 100644 --- a/macos/TaskersMac/Sources/TaskersTerminalView.swift +++ b/macos/TaskersMac/Sources/TaskersTerminalView.swift @@ -198,7 +198,7 @@ final class TaskersTerminalView: NSView, TaskersHostedSurface { launchStorage: launchStorage, userdata: self.callbackContextHandle! ) - updateSurfaceMetrics() + TaskersEnvironment.emitSmokeLog("surface created \(surfaceID)") } required init?(coder: NSCoder) { @@ -236,6 +236,7 @@ final class TaskersTerminalView: NSView, TaskersHostedSurface { override func viewDidMoveToWindow() { super.viewDidMoveToWindow() + TaskersEnvironment.emitSmokeLog("surface moved to window \(surfaceID) window=\(window != nil)") updateSurfaceMetrics() } @@ -340,6 +341,9 @@ final class TaskersTerminalView: NSView, TaskersHostedSurface { guard let surface else { return } + guard window != nil else { + return + } let backingRect = convertToBacking(bounds) let width = UInt32(max(1, Int(backingRect.width))) diff --git a/macos/TaskersMac/Sources/main.swift b/macos/TaskersMac/Sources/main.swift index 8ecec82..f687d0c 100644 --- a/macos/TaskersMac/Sources/main.swift +++ b/macos/TaskersMac/Sources/main.swift @@ -8,6 +8,7 @@ final class TaskersMacApplication: NSObject, NSApplicationDelegate { func applicationDidFinishLaunching(_ notification: Notification) { _ = notification + TaskersEnvironment.emitSmokeLog("applicationDidFinishLaunching") TaskersEnvironment.scrubInheritedTerminalEnvironment() TaskersEnvironment.configureBundledPaths() if TaskersEnvironment.isRunningUnderXCTest { @@ -15,15 +16,20 @@ final class TaskersMacApplication: NSObject, NSApplicationDelegate { } do { + TaskersEnvironment.emitSmokeLog("creating core bridge") let core = try TaskersCoreBridge(options: TaskersEnvironment.defaultCoreOptions()) + TaskersEnvironment.emitSmokeLog("creating Ghostty host") let ghosttyHost = TaskersGhosttyHost() + TaskersEnvironment.emitSmokeLog("creating workspace controller") let controller = TaskersWorkspaceController(core: core, ghosttyHost: ghosttyHost) self.core = core self.ghosttyHost = ghosttyHost self.workspaceController = controller + TaskersEnvironment.emitSmokeLog("starting workspace controller") try controller.start() + TaskersEnvironment.emitSmokeLog("workspace controller started") if TaskersEnvironment.isSmokeTestEnabled { DispatchQueue.main.asyncAfter(deadline: .now() + 2) { From 1150e753205cedfd540892d1ebeebb659950928c Mon Sep 17 00:00:00 2001 From: OneNoted Date: Tue, 17 Mar 2026 17:18:39 +0100 Subject: [PATCH 40/40] fix: initialize Ghostty before macOS bootstrap --- .../Sources/TaskersEnvironment.swift | 18 ++++++++++++++++++ .../Sources/TaskersGhosttyHost.swift | 6 ++++++ 2 files changed, 24 insertions(+) diff --git a/macos/TaskersMac/Sources/TaskersEnvironment.swift b/macos/TaskersMac/Sources/TaskersEnvironment.swift index 95b4112..34b0f3f 100644 --- a/macos/TaskersMac/Sources/TaskersEnvironment.swift +++ b/macos/TaskersMac/Sources/TaskersEnvironment.swift @@ -1,6 +1,7 @@ import Foundation enum TaskersEnvironment { + private static var didInitializeGhostty = false private static let inheritedTerminalEnvironmentKeys = [ "TERM", "TERMINFO", @@ -66,6 +67,23 @@ enum TaskersEnvironment { fflush(stderr) } + static func ensureGhosttyInitialized() -> Bool { + if didInitializeGhostty { + return true + } + + emitSmokeLog("initializing Ghostty globals") + let status = ghostty_init(UInt(CommandLine.argc), CommandLine.unsafeArgv) + guard status == GHOSTTY_SUCCESS else { + emitSmokeLog("Ghostty global init failed status=\(status)") + return false + } + + didInitializeGhostty = true + emitSmokeLog("Ghostty globals initialized") + return true + } + private static func setPath(_ key: String, url: URL) { guard FileManager.default.fileExists(atPath: url.path) else { return diff --git a/macos/TaskersMac/Sources/TaskersGhosttyHost.swift b/macos/TaskersMac/Sources/TaskersGhosttyHost.swift index c28f036..905eea8 100644 --- a/macos/TaskersMac/Sources/TaskersGhosttyHost.swift +++ b/macos/TaskersMac/Sources/TaskersGhosttyHost.swift @@ -2,11 +2,14 @@ import AppKit import Foundation enum TaskersGhosttyHostError: LocalizedError { + case initializationFailed case configurationFailed case appCreationFailed var errorDescription: String? { switch self { + case .initializationFailed: + return "failed to initialize Ghostty globals" case .configurationFailed: return "failed to initialize Ghostty configuration" case .appCreationFailed: @@ -41,6 +44,9 @@ final class TaskersGhosttyHost: NSObject, TaskersSurfaceHosting { } TaskersEnvironment.emitSmokeLog("Ghostty host bootstrap begin") + guard TaskersEnvironment.ensureGhosttyInitialized() else { + throw TaskersGhosttyHostError.initializationFailed + } guard let config = ghostty_config_new() else { throw TaskersGhosttyHostError.configurationFailed }