diff --git a/.github/workflows/macos-preview.yml b/.github/workflows/macos-preview.yml new file mode 100644 index 0000000..b77b959 --- /dev/null +++ b/.github/workflows/macos-preview.yml @@ -0,0 +1,108 @@ +name: macOS Preview + +on: + pull_request: + push: + branches: + - main + workflow_dispatch: + +jobs: + macos-preview: + strategy: + fail-fast: false + matrix: + runner: + - macos-14 + - 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 -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 + + - name: Build and test Taskers.app + run: | + mkdir -p build/macos + 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: 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" + 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 signed 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/.github/workflows/release-assets.yml b/.github/workflows/release-assets.yml new file mode 100644 index 0000000..65229e9 --- /dev/null +++ b/.github/workflows/release-assets.yml @@ -0,0 +1,179 @@ +name: Release Assets + +on: + push: + tags: + - "v*" + workflow_dispatch: + +permissions: + contents: write + +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 + bash scripts/smoke_linux_release_launcher.sh + + - name: Upload Linux bundle + uses: actions/upload-artifact@v4 + with: + name: linux-bundle + path: dist/taskers-linux-bundle-v*.tar.xz + + 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 + 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: 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 + + - 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: 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: + name: macos-universal-dmg + path: dist/Taskers-v*-universal2.dmg + + release-manifest: + needs: + - linux-bundle + 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 + + upload-github-release: + if: startsWith(github.ref, 'refs/tags/v') + needs: + - release-manifest + - macos-universal-dmg + 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: Create 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-v*-universal2.dmg 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 e0301cc..949c120 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -94,6 +94,15 @@ 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" @@ -207,6 +216,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 +234,16 @@ dependencies = [ "cfg-if", ] +[[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 +254,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 +494,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 +1358,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" @@ -1448,28 +1507,22 @@ checksum = "df7f62577c25e07834649fc3b39fafdc597c0a3527dc1c60129201ccfcbaa50c" [[package]] name = "taskers" -version = "0.2.1" +version = "0.3.0" dependencies = [ "anyhow", - "clap", - "gtk4", - "libadwaita", "serde", "serde_json", - "svgtypes", - "taskers-control", - "taskers-domain", - "taskers-ghostty", - "taskers-runtime", + "sha2", + "tar", + "taskers-paths", "tempfile", - "time", - "tokio", - "toml 0.8.23", + "ureq", + "xz2", ] [[package]] name = "taskers-cli" -version = "0.2.1" +version = "0.3.0" dependencies = [ "anyhow", "clap", @@ -1482,21 +1535,37 @@ dependencies = [ [[package]] name = "taskers-control" -version = "0.2.1" +version = "0.3.0" dependencies = [ "anyhow", "serde", "serde_json", "taskers-domain", + "taskers-paths", "tempfile", "thiserror 2.0.18", "tokio", "uuid", ] +[[package]] +name = "taskers-core" +version = "0.3.0" +dependencies = [ + "anyhow", + "serde", + "serde_json", + "taskers-control", + "taskers-domain", + "taskers-ghostty", + "taskers-paths", + "taskers-runtime", + "tempfile", +] + [[package]] name = "taskers-domain" -version = "0.2.1" +version = "0.3.0" dependencies = [ "indexmap", "serde", @@ -1508,27 +1577,71 @@ dependencies = [ [[package]] name = "taskers-ghostty" -version = "0.2.1" +version = "0.3.0" dependencies = [ "gtk4", "libloading", "serde", "tar", + "taskers-paths", "tempfile", "thiserror 2.0.18", "ureq", "xz2", ] +[[package]] +name = "taskers-gtk" +version = "0.3.0" +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.3.0" +dependencies = [ + "serde", + "serde_json", + "taskers-control", + "taskers-core", + "taskers-domain", + "taskers-ghostty", + "taskers-paths", + "taskers-runtime", + "tempfile", +] + +[[package]] +name = "taskers-paths" +version = "0.3.0" + [[package]] name = "taskers-runtime" -version = "0.2.1" +version = "0.3.0" dependencies = [ "anyhow", "base64", "libc", "portable-pty", "taskers-domain", + "taskers-paths", ] [[package]] @@ -1752,6 +1865,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" @@ -1828,6 +1947,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" diff --git a/Cargo.toml b/Cargo.toml index 3436f36..c3bd846 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,10 +1,14 @@ [workspace] members = [ "crates/taskers-app", + "crates/taskers-launcher", + "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" @@ -14,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" } @@ -27,10 +31,16 @@ 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" +taskers-core = { version = "0.3.0", path = "crates/taskers-core" } +taskers-paths = { version = "0.3.0", path = "crates/taskers-paths" } diff --git a/README.md b/README.md index 1304ec6..9821e85 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) @@ -8,17 +8,24 @@ Taskers is a Linux-first terminal workspace for agent-heavy work. It gives you N ## Try it +Linux (`x86_64-unknown-linux-gnu`): + ```bash 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 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 ```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 b0e495b..c34142f 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] @@ -20,11 +21,13 @@ gtk.workspace = true serde.workspace = true serde_json.workspace = true svgtypes.workspace = true -taskers-runtime = { version = "0.2.1", path = "../taskers-runtime" } +taskers-core.workspace = true +taskers-paths.workspace = true +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-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..bb4e91e 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,20 +17,19 @@ 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_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, @@ -43,11 +39,12 @@ 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::{ - 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, @@ -80,7 +77,6 @@ struct StartupContext { app_config: AppConfig, crash_reporter: CrashReporter, ghostty_host: Option, - shell_launch: ShellLaunchSpec, startup_toast: Option, } @@ -92,7 +88,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 +389,6 @@ impl UiHandle { overlay: adw::ToastOverlay, crash_reporter: CrashReporter, ghostty_host: Option, - shell_launch: ShellLaunchSpec, ) -> Rc { Rc::new(Self { app_state, @@ -404,7 +398,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 +780,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()); @@ -1878,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(); } @@ -1887,9 +1872,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 +1906,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 +1984,6 @@ fn main() -> gtk::glib::ExitCode { app_config, crash_reporter, ghostty_host, - shell_launch, startup_toast, }; @@ -2073,7 +2055,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..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." } @@ -333,25 +335,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 { @@ -388,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"); @@ -408,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() @@ -424,7 +409,9 @@ mod tests { .collect::>() ); assert_eq!( - loaded.keybindings.accelerators(ShortcutAction::CloseTerminal), + loaded + .keybindings + .accelerators(ShortcutAction::CloseTerminal), ShortcutAction::CloseTerminal .default_accelerators() .iter() @@ -440,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/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-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-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-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-control/Cargo.toml b/crates/taskers-control/Cargo.toml index d8885fb..ad5d454 100644 --- a/crates/taskers-control/Cargo.toml +++ b/crates/taskers-control/Cargo.toml @@ -15,7 +15,8 @@ serde_json.workspace = true thiserror.workspace = true tokio.workspace = true uuid.workspace = true -taskers-domain = { version = "0.2.1", path = "../taskers-domain" } +taskers-paths.workspace = true +taskers-domain = { version = "0.3.0", path = "../taskers-domain" } [dev-dependencies] tempfile.workspace = true 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-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..a89788b --- /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.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.3.0", 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..9461878 --- /dev/null +++ b/crates/taskers-core/src/app_state.rs @@ -0,0 +1,225 @@ +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, + backend: BackendChoice, + 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(), + !matches!( + backend, + BackendChoice::Ghostty | BackendChoice::GhosttyEmbedded + ), + shell_launch.clone(), + ); + runtime.sync_model(&model)?; + + let state = Self { + controller, + runtime, + backend, + 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 backend(&self) -> BackendChoice { + self.backend + } + + pub fn shell_launch(&self) -> &ShellLaunchSpec { + &self.shell_launch + } + + pub fn snapshot_model(&self) -> AppModel { + self.controller.snapshot().model + } + + pub fn revision(&self) -> u64 { + self.controller.revision() + } + + 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_control::{ControlCommand, ControlQuery}; + 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")); + } + + #[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-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-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/Cargo.toml b/crates/taskers-ghostty/Cargo.toml index 2490595..508b723 100644 --- a/crates/taskers-ghostty/Cargo.toml +++ b/crates/taskers-ghostty/Cargo.toml @@ -10,14 +10,17 @@ version.workspace = true build = "build.rs" [dependencies] -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" +[target.'cfg(target_os = "linux")'.dependencies] +gtk.workspace = true + [features] default = [] libghostty = [] 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-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, 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-launcher/Cargo.toml b/crates/taskers-launcher/Cargo.toml new file mode 100644 index 0000000..55ca370 --- /dev/null +++ b/crates/taskers-launcher/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "taskers" +description = "Linux 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 + +[dev-dependencies] +tempfile.workspace = true diff --git a/crates/taskers-launcher/assets/taskers-notify.sh b/crates/taskers-launcher/assets/taskers-notify.sh new file mode 100644 index 0000000..b3a92b2 --- /dev/null +++ b/crates/taskers-launcher/assets/taskers-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 Taskers --body "$message" >/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..06e3962 --- /dev/null +++ b/crates/taskers-launcher/src/lib.rs @@ -0,0 +1,698 @@ +#[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, + 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::default_release_install_root; +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::>(); + let installation = ManagedInstallation::ensure_installed(env!("CARGO_PKG_VERSION"))?; + installation.install_linux_user_assets()?; + installation.launch(&args) +} + +#[derive(Debug, Deserialize, Serialize)] +struct ReleaseManifest { + version: String, + artifacts: BTreeMap, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +struct ReleaseArtifact { + kind: ArtifactKind, + url: String, + sha256: String, + #[serde(default)] + size_bytes: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +enum ArtifactKind { + #[serde(rename = "linux_bundle_v1")] + LinuxBundleV1, +} + +#[derive(Debug)] +struct ManagedInstallation { + target_triple: String, + version: String, + bundle_root: PathBuf, +} + +impl ManagedInstallation { + fn ensure_installed(version: &str) -> Result { + 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); + let installation = Self { + target_triple: target_triple.to_string(), + version: version.to_string(), + bundle_root, + }; + + if installation.is_complete() { + 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(artifact)?; + Ok(installation) + } + + fn launch(&self, args: &[OsString]) -> Result { + let executable = self.executable_path(); + let mut command = Command::new(&executable); + command.args(args); + 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 + ); + } + Ok(manifest) + } + + fn install_artifact(&self, artifact: &ReleaseArtifact) -> Result<()> { + validate_artifact_kind(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()))?; + unpack_linux_bundle(&download_path, &unpack_root)?; + + if !validate_bundle_layout(&unpack_root) { + bail!( + "artifact {} did not unpack the expected layout into {}", + artifact.url, + unpack_root.display() + ); + } + + 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(&unpack_root, &self.bundle_root).with_context(|| { + format!( + "failed to move {} to {}", + unpack_root.display(), + self.bundle_root.display() + ) + })?; + remove_path(&staging_root)?; + + Ok(()) + } + + fn install_linux_user_assets(&self) -> Result<()> { + 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 + .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-notify"); + write_executable( + ¬ify_path, + include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/assets/taskers-notify.sh" + )), + )?; + + refresh_desktop_indexes(&applications_dir); + Ok(()) + } + + fn is_complete(&self) -> bool { + validate_bundle_layout(&self.bundle_root) + } + + fn executable_path(&self) -> PathBuf { + self.bundle_root.join("bin").join("taskers") + } + + fn taskersctl_path(&self) -> PathBuf { + self.bundle_root.join("bin").join("taskersctl") + } + + fn ghostty_resources_path(&self) -> PathBuf { + self.bundle_root.join("ghostty") + } + + fn terminfo_path(&self) -> PathBuf { + self.bundle_root.join("terminfo") + } +} + +fn validate_artifact_kind(kind: ArtifactKind) -> Result<()> { + match kind { + ArtifactKind::LinuxBundleV1 => Ok(()), + } +} + +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() -> Result<&'static str> { + match env::consts::ARCH { + "x86_64" => Ok("x86_64-unknown-linux-gnu"), + _ => bail!( + "unsupported Linux architecture for the published taskers launcher: {}", + env::consts::ARCH + ), + } +} + +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") +} + +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(PathBuf::from) +} + +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 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 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()]); +} + +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, launcher_path_looks_installed, + path_taskers_executable, sha256_path, + }; + #[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; + + #[test] + fn default_manifest_url_uses_exact_version() { + 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, version, "x86_64-unknown-linux-gnu"), + PathBuf::from(format!("/tmp/taskers/{version}/x86_64-unknown-linux-gnu")) + ); + } + + #[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"), + env!("CARGO_PKG_VERSION"), + ) + .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().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, + }, + )]), + }; + 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()); + } + + #[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/crates/taskers-launcher/src/main.rs b/crates/taskers-launcher/src/main.rs new file mode 100644 index 0000000..1916f79 --- /dev/null +++ b/crates/taskers-launcher/src/main.rs @@ -0,0 +1,54 @@ +fn main() { + let exit_code = match taskers::run() { + Ok(status) => exit_code_from_status(status), + Err(error) => { + eprintln!("taskers launcher failed: {error:#}"); + 1 + } + }; + + 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/crates/taskers-macos-ffi/Cargo.toml b/crates/taskers-macos-ffi/Cargo.toml new file mode 100644 index 0000000..4236987 --- /dev/null +++ b/crates/taskers-macos-ffi/Cargo.toml @@ -0,0 +1,26 @@ +[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] +serde.workspace = true +serde_json.workspace = true +taskers-core.workspace = true +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.3.0", path = "../taskers-runtime" } + +[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..de15f50 --- /dev/null +++ b/crates/taskers-macos-ffi/include/taskers_macos_ffi.h @@ -0,0 +1,32 @@ +#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_with_options_json( + const char *options_json +); +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..aeea9f5 --- /dev/null +++ b/crates/taskers-macos-ffi/src/lib.rs @@ -0,0 +1,640 @@ +use std::{ + cell::RefCell, + ffi::{CStr, CString, c_char}, + path::PathBuf, + ptr, + str::FromStr, +}; + +use serde::{Deserialize, Serialize}; +use taskers_control::{ControlCommand, default_socket_path}; +use taskers_core::{AppState, default_session_path, load_or_bootstrap}; +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}; + +pub struct TaskersMacosCore { + app_state: AppState, + _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, +} + +#[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) }; +} + +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_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(options.configured_shell.as_deref()) { + 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 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}"))?; + + if let Some(error) = shell_integration_error { + set_last_error(error); + } else { + clear_last_error(); + } + + Ok(Self { + app_state, + _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(&MacosSnapshot::from(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}"))?; + 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 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(), + Err(_) => ptr::null_mut(), + } +} + +#[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, +) -> 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.app_state.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, json}; + use tempfile::tempdir; + + use super::{CoreOptions, 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"); + 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"}"#) + .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.app_state.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") + ) + ); + } + + #[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/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..17393c7 --- /dev/null +++ b/crates/taskers-paths/src/lib.rs @@ -0,0 +1,467 @@ +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 +} + +pub fn default_release_install_root() -> PathBuf { + TaskersPaths::detect().data_dir.join("releases") +} + +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")); + } + + #[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") + ); + } +} diff --git a/crates/taskers-runtime/Cargo.toml b/crates/taskers-runtime/Cargo.toml index 8f20859..3b6c415 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-domain = { version = "0.2.1", path = "../taskers-domain" } +taskers-paths.workspace = true +taskers-domain = { version = "0.3.0", path = "../taskers-domain" } 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 53a4914..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) @@ -276,21 +304,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<()> { @@ -468,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() { @@ -498,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/docs/release.md b/docs/release.md index 15d7848..dcf2f3c 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,41 +32,85 @@ 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 +bash scripts/smoke_linux_release_launcher.sh ``` The output asset name must match: ```text -taskers-ghostty-runtime-v-.tar.xz +taskers-linux-bundle-v-.tar.xz ``` -- Dry-run crate publishing in dependency order: +- 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/build_macos_dmg.sh +bash scripts/notarize_macos_dmg.sh dist/Taskers-v-universal2.dmg +``` + +- 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: + +```bash +python3 scripts/build_release_manifest.py +``` + +- 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 -- Create a GitHub release draft tagged `v`. -- Upload the matching Ghostty runtime bundle from `dist/`. -- Publish the crates to crates.io in the same order as the dry-run: +- 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-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 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 @@ -74,13 +118,18 @@ 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 a clean install path: +- Verify the Linux launcher install: ```bash cargo install taskers --locked taskers --demo ``` -- Confirm the published crate can bootstrap the matching runtime asset 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/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..f88eb1b --- /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 TaskersSnapshot.parse(data: Data(json.utf8)) + } + + @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..34b0f3f --- /dev/null +++ b/macos/TaskersMac/Sources/TaskersEnvironment.swift @@ -0,0 +1,94 @@ +import Foundation + +enum TaskersEnvironment { + private static var didInitializeGhostty = false + 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 var isRunningUnderXCTest: Bool { + let environment = ProcessInfo.processInfo.environment + return environment["XCTestConfigurationFilePath"] != nil + || environment["XCTestBundlePath"] != nil + } + + static func scrubInheritedTerminalEnvironment() { + for key in inheritedTerminalEnvironmentKeys { + unsetenv(key) + } + } + + 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: false, + backend: "ghostty_embedded" + ) + } + + static func emitSmokeLog(_ message: String) { + guard isSmokeTestEnabled else { + return + } + + fputs("Taskers macOS smoke: \(message)\n", stderr) + 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 + } + + 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..905eea8 --- /dev/null +++ b/macos/TaskersMac/Sources/TaskersGhosttyHost.swift @@ -0,0 +1,184 @@ +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: + return "failed to initialize the embedded Ghostty runtime" + } + } +} + +final class TaskersGhosttyHost: NSObject, TaskersSurfaceHosting { + 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 + } + + TaskersEnvironment.emitSmokeLog("Ghostty host bootstrap begin") + guard TaskersEnvironment.ensureGhosttyInitialized() else { + throw TaskersGhosttyHostError.initializationFailed + } + 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 + TaskersGhosttySurfaceContext.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) + TaskersEnvironment.emitSmokeLog("Ghostty host bootstrap end") + } + + func makeSurface( + workspaceID: String, + paneID: String, + surfaceID: String, + descriptor: TaskersSurfaceDescriptor + ) throws -> any TaskersHostedSurface { + try bootstrap() + guard let app else { + throw TaskersGhosttyHostError.appCreationFailed + } + + TaskersEnvironment.emitSmokeLog("creating Ghostty surface \(surfaceID)") + let view = try TaskersTerminalView( + host: self, + app: app, + workspaceID: workspaceID, + paneID: paneID, + surfaceID: surfaceID, + descriptor: descriptor + ) + registerSurface(view) + TaskersEnvironment.emitSmokeLog("created Ghostty surface \(surfaceID)") + 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() + } + } + + func surfaceDidClose(workspaceID: String, paneID: String, surfaceID: String) { + 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 { + guard target.tag == GHOSTTY_TARGET_SURFACE else { + return false + } + + guard let surface = target.target.surface else { + return false + } + + guard let context = TaskersGhosttySurfaceContext.from(surface: surface) else { + return false + } + + switch action.tag { + case GHOSTTY_ACTION_SHOW_CHILD_EXITED: + context.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..eee521f --- /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 GhosttyKit; 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/TaskersSnapshot.swift b/macos/TaskersMac/Sources/TaskersSnapshot.swift new file mode 100644 index 0000000..36e2fd3 --- /dev/null +++ b/macos/TaskersMac/Sources/TaskersSnapshot.swift @@ -0,0 +1,199 @@ +import Foundation + +protocol TaskersIdentifiedRecord { + var id: String { get } +} + +struct OrderedMap { + let elements: [(String, Value)] + private let storage: [String: Value] + + init(values: [Value]) { + self.elements = values.map { ($0.id, $0) } + self.storage = Dictionary(uniqueKeysWithValues: elements) + } + + subscript(key: String) -> Value? { + storage[key] + } +} + +struct TaskersSnapshot: Decodable { + let activeWindow: String + let windows: OrderedMap + let workspaces: OrderedMap + + enum CodingKeys: String, CodingKey { + case activeWindow = "active_window" + case windows + 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 + } + + 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, TaskersIdentifiedRecord { + 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, TaskersIdentifiedRecord { + 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" + } + + 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, TaskersIdentifiedRecord { + 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, TaskersIdentifiedRecord { + 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, TaskersIdentifiedRecord { + let id: String + let surfaces: OrderedMap + let activeSurface: String + + enum CodingKeys: String, CodingKey { + case id + 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, TaskersIdentifiedRecord { + let id: String + let metadata: TaskersSurfaceMetadata +} + +struct TaskersSurfaceMetadata: Decodable { + let title: String? + let cwd: String? +} + +enum TaskersSplitAxis: String, Decodable { + case horizontal + case vertical +} + +indirect 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 Kind: String, Decodable { + case leaf + case split + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + switch try container.decode(Kind.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/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 new file mode 100644 index 0000000..4a086a2 --- /dev/null +++ b/macos/TaskersMac/Sources/TaskersTerminalView.swift @@ -0,0 +1,577 @@ +import AppKit +import Darwin +import Foundation + +#if DEBUG +func taskersMacDebugLog(_ message: @autoclosure () -> String) { + NSLog("[taskers-macos] %@", message()) +} +#else +func taskersMacDebugLog(_ message: @autoclosure () -> String) { + _ = message() +} +#endif + +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 + taskersMacDebugLog("surface \(surfaceID) child exited") + closeSurfaceIfNeeded() + } + + func handleSurfaceClosed() { + taskersMacDebugLog("surface \(surfaceID) close callback") + closeSurfaceIfNeeded() + } + + private func closeSurfaceIfNeeded() { + if !Thread.isMainThread { + DispatchQueue.main.async { [self] in + closeSurfaceIfNeeded() + } + return + } + + 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 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($0) } + } + + var commandPointer: UnsafePointer? { + commandStorage.map { UnsafePointer($0) } + } + + func withEnvironment( + _ body: (UnsafeMutablePointer?, Int) throws -> T + ) rethrows -> T { + try envVars.withUnsafeMutableBufferPointer { buffer in + try body(buffer.baseAddress, buffer.count) + } + } +} + +final class TaskersTerminalView: NSView, TaskersHostedSurface { + let workspaceID: String + let paneID: String + let surfaceID: String + + 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 + + override var acceptsFirstResponder: Bool { + true + } + + var hostingView: NSView { self } + + 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.callbackContext = TaskersGhosttySurfaceContext( + host: host, + workspaceID: workspaceID, + paneID: paneID, + surfaceID: surfaceID + ) + 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 + layer?.backgroundColor = NSColor.black.cgColor + + self.surface = try Self.createSurface( + view: self, + app: app, + launchStorage: launchStorage, + userdata: self.callbackContextHandle! + ) + TaskersEnvironment.emitSmokeLog("surface created \(surfaceID)") + } + + required init?(coder: NSCoder) { + return nil + } + + deinit { + dispose() + } + + 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() + TaskersEnvironment.emitSmokeLog("surface moved to window \(surfaceID) window=\(window != nil)") + updateSurfaceMetrics() + } + + override func viewDidChangeBackingProperties() { + super.viewDidChangeBackingProperties() + updateSurfaceMetrics() + } + + override func keyDown(with event: NSEvent) { + 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) + } + + 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 beginTeardown() { + callbackContext.beginTeardown() + } + + func dispose() { + guard !isDisposed else { + return + } + + isDisposed = true + callbackContext.beginTeardown() + host?.unregisterSurface(self) + taskersMacDebugLog("dispose start surface=\(surfaceID)") + + let surface = self.surface + self.surface = nil + let launchStorage = self.launchStorage + + guard let callbackContextHandle else { + return + } + self.callbackContextHandle = nil + + 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) + } + + 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 + } + guard window != nil 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 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 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, + launchStorage: TaskersGhosttyLaunchStorage, + userdata: UnsafeMutableRawPointer + ) 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 = userdata + config.scale_factor = scale + config.context = GHOSTTY_SURFACE_CONTEXT_SPLIT + 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 + } + } + + 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 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 + } + } + + fileprivate static func modifiers(from flags: NSEvent.ModifierFlags) -> ghostty_input_mods_e { + var mods = GHOSTTY_MODS_NONE.rawValue + if flags.contains(.shift) { + mods |= GHOSTTY_MODS_SHIFT.rawValue + } + if flags.contains(.control) { + mods |= GHOSTTY_MODS_CTRL.rawValue + } + if flags.contains(.option) { + mods |= GHOSTTY_MODS_ALT.rawValue + } + if flags.contains(.command) { + mods |= GHOSTTY_MODS_SUPER.rawValue + } + return ghostty_input_mods_e(rawValue: mods) + } + + private static func modifierFlags(from mods: ghostty_input_mods_e) -> NSEvent.ModifierFlags { + let raw = mods.rawValue + var flags: NSEvent.ModifierFlags = [] + if raw & GHOSTTY_MODS_SHIFT.rawValue != 0 { + flags.insert(.shift) + } + if raw & GHOSTTY_MODS_CTRL.rawValue != 0 { + flags.insert(.control) + } + if raw & GHOSTTY_MODS_ALT.rawValue != 0 { + flags.insert(.option) + } + if raw & GHOSTTY_MODS_SUPER.rawValue != 0 { + flags.insert(.command) + } + return flags + } + +} + +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 + } +} diff --git a/macos/TaskersMac/Sources/TaskersWorkspaceController.swift b/macos/TaskersMac/Sources/TaskersWorkspaceController.swift new file mode 100644 index 0000000..3e81ded --- /dev/null +++ b/macos/TaskersMac/Sources/TaskersWorkspaceController.swift @@ -0,0 +1,344 @@ +import AppKit +import Foundation + +final class TaskersSurfaceHostView: NSView { + let surface: any TaskersHostedSurface + + init(surface: any TaskersHostedSurface) { + self.surface = surface + super.init(frame: .zero) + translatesAutoresizingMaskIntoConstraints = false + + let hostedView = surface.hostingView + hostedView.translatesAutoresizingMaskIntoConstraints = false + addSubview(hostedView) + NSLayoutConstraint.activate([ + hostedView.leadingAnchor.constraint(equalTo: leadingAnchor), + hostedView.trailingAnchor.constraint(equalTo: trailingAnchor), + hostedView.topAnchor.constraint(equalTo: topAnchor), + hostedView.bottomAnchor.constraint(equalTo: bottomAnchor) + ]) + } + + required init?(coder: NSCoder) { + return nil + } + + func dispose() { + surface.dispose() + } +} + +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 + super.init(frame: .zero) + self.isVertical = isVertical + dividerStyle = .thin + translatesAutoresizingMaskIntoConstraints = false + } + + required init?(coder: NSCoder) { + return nil + } + + 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 = 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 + } +} + +final class TaskersWorkspaceController: NSWindowController { + private let core: TaskersCoreBridge + private let surfaceHost: any TaskersSurfaceHosting + private var surfaceRegistry: [String: TaskersSurfaceHostView] = [:] + private var pollTimer: Timer? + private var lastRevision: UInt64? + private var didShutdown = false + + var surfaceCount: Int { + surfaceRegistry.count + } + + var lastRenderedSurfaceIDs: Set { + Set(surfaceRegistry.keys) + } + + init(core: TaskersCoreBridge, ghosttyHost: any TaskersSurfaceHosting) { + self.core = core + self.surfaceHost = 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) + surfaceHost.onSurfaceClosed = { [weak self] workspaceID, paneID, surfaceID in + self?.closeSurface(workspaceID: workspaceID, paneID: paneID, surfaceID: surfaceID) + } + } + + required init?(coder: NSCoder) { + return nil + } + + deinit { + shutdown() + } + + override func close() { + shutdown() + super.close() + } + + 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] { + return existing + } + + let descriptor = try core.surfaceDescriptor(workspaceId: workspace.id, paneId: paneID) + let surface = try surfaceHost.makeSurface( + workspaceID: workspace.id, + paneID: paneID, + surfaceID: activeSurfaceID, + descriptor: descriptor + ) + let hostView = TaskersSurfaceHostView(surface: surface) + surfaceRegistry[activeSurfaceID] = hostView + return hostView + } + + 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 + } + surface.dispose() + surface.removeFromSuperview() + } + } + + 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, + "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 + } + + private func shutdown() { + guard !didShutdown else { + return + } + + didShutdown = true + pollTimer?.invalidate() + pollTimer = nil + surfaceHost.onSurfaceClosed = nil + for surface in surfaceRegistry.values { + surface.dispose() + surface.removeFromSuperview() + } + surfaceRegistry.removeAll() + window?.contentView = nil + } +} diff --git a/macos/TaskersMac/Sources/main.swift b/macos/TaskersMac/Sources/main.swift new file mode 100644 index 0000000..f687d0c --- /dev/null +++ b/macos/TaskersMac/Sources/main.swift @@ -0,0 +1,71 @@ +import AppKit +import Foundation + +final class TaskersMacApplication: NSObject, NSApplicationDelegate { + private var core: TaskersCoreBridge? + private var ghosttyHost: TaskersGhosttyHost? + private var workspaceController: TaskersWorkspaceController? + + func applicationDidFinishLaunching(_ notification: Notification) { + _ = notification + TaskersEnvironment.emitSmokeLog("applicationDidFinishLaunching") + TaskersEnvironment.scrubInheritedTerminalEnvironment() + TaskersEnvironment.configureBundledPaths() + if TaskersEnvironment.isRunningUnderXCTest { + return + } + + 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) { + 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) + } + } + } 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 !TaskersEnvironment.isRunningUnderXCTest + } +} + +let app = NSApplication.shared +let delegate = TaskersMacApplication() +app.delegate = delegate +app.setActivationPolicy(.regular) +app.run() 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..98fdf4a --- /dev/null +++ b/macos/TaskersMacTests/TaskersSmokeTests.swift @@ -0,0 +1,129 @@ +import AppKit +import XCTest +@testable import TaskersMac + +#if DEBUG +private func smokeLog(_ message: @autoclosure () -> String) { + NSLog("[taskers-smoke] %@", message()) +} +#else +private func smokeLog(_ message: @autoclosure () -> String) { + _ = message() +} +#endif + +@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: "mock" + )) + let host = TaskersMockSurfaceHost() + 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, + "pane_id": initialWorkspace.activePane, + "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 { + 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 [helper] { + guard FileManager.default.fileExists(atPath: requiredPath.path) else { + throw XCTSkip("missing preview dependency at \(requiredPath.path)") + } + } + + 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) + } + + private func drainMainRunLoop() { + let deadline = Date().addingTimeInterval(0.1) + while Date() < deadline { + _ = RunLoop.current.run(mode: .default, before: deadline) + } + } + + 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..ab815cb --- /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": [ + { + "id": "window-a", + "workspace_order": ["workspace-a"], + "active_workspace": "workspace-a" + } + ], + "workspaces": [ + { + "id": "workspace-a", + "label": "Main", + "columns": [ + { + "id": "column-a", + "width": 520, + "window_order": ["window-1"], + "active_window": "window-1" + }, + { + "id": "column-b", + "width": 360, + "window_order": ["window-2"], + "active_window": "window-2" + } + ], + "windows": [ + { + "id": "window-1", + "height": 400, + "layout": { "kind": "leaf", "pane_id": "pane-1" }, + "active_pane": "pane-1" + }, + { + "id": "window-2", + "height": 400, + "layout": { "kind": "leaf", "pane_id": "pane-2" }, + "active_pane": "pane-2" + } + ], + "active_window": "window-1", + "panes": [ + { + "id": "pane-1", + "surfaces": [ + { + "id": "surface-1", + "metadata": { "title": "One", "cwd": null } + } + ], + "active_surface": "surface-1" + }, + { + "id": "pane-2", + "surfaces": [ + { + "id": "surface-2", + "metadata": { "title": "Two", "cwd": null } + } + ], + "active_surface": "surface-2" + } + ], + "active_pane": "pane-1" + } + ] + } + """ + + 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"])) + } +} diff --git a/macos/project.yml b/macos/project.yml new file mode 100644 index 0000000..55d9f0d --- /dev/null +++ b/macos/project.yml @@ -0,0 +1,68 @@ +name: Taskers +options: + bundleIdPrefix: dev.taskers + projectFormat: xcode15_3 + 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 + - sdk: Carbon.framework + - sdk: libc++.tbd + - sdk: liblzma.tbd + 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: + BUNDLE_LOADER: $(TEST_HOST) + PRODUCT_BUNDLE_IDENTIFIER: dev.taskers.tests + TEST_HOST: $(BUILT_PRODUCTS_DIR)/Taskers.app/Contents/MacOS/Taskers +schemes: + TaskersMac: + build: + targets: + TaskersMac: all + TaskersMacTests: [test] + test: + targets: + - TaskersMacTests diff --git a/scripts/build_linux_bundle.sh b/scripts/build_linux_bundle.sh new file mode 100755 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 100755 index 0000000..fa44e19 --- /dev/null +++ b/scripts/build_macos_dmg.sh @@ -0,0 +1,30 @@ +#!/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" +staging_dir="$out_dir/.taskers-dmg-staging" + +if [[ ! -d "$app_path" ]]; then + echo "expected Taskers.app at $app_path" >&2 + exit 1 +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 "$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 new file mode 100755 index 0000000..84f5b5f --- /dev/null +++ b/scripts/build_release_manifest.py @@ -0,0 +1,59 @@ +#!/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) -> dict: + entry = { + "kind": kind, + "url": f"{base_url}/{path.name}", + "sha256": sha256(path), + "size_bytes": path.stat().st_size, + } + 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") + +manifest = { + "version": version, + "artifacts": artifacts, +} + +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/generate_macos_project.sh b/scripts/generate_macos_project.sh new file mode 100755 index 0000000..d3e5c2a --- /dev/null +++ b/scripts/generate_macos_project.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +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 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/macos-build-preview-deps.sh b/scripts/macos-build-preview-deps.sh new file mode 100755 index 0000000..886bac7 --- /dev/null +++ b/scripts/macos-build-preview-deps.sh @@ -0,0 +1,34 @@ +#!/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 +# 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" +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" +cp -R "${GHOSTTY_DIR}/zig-out/share/terminfo" "${BUILD_DIR}/resources/terminfo" 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/sign_macos_app.sh b/scripts/sign_macos_app.sh new file mode 100755 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_linux_release_launcher.sh b/scripts/smoke_linux_release_launcher.sh new file mode 100644 index 0000000..b9463fc --- /dev/null +++ b/scripts/smoke_linux_release_launcher.sh @@ -0,0 +1,120 @@ +#!/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" +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" +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_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" \ + --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.' 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" 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" diff --git a/vendor/ghostty/pkg/macos/video/pixel_format.zig b/vendor/ghostty/pkg/macos/video/pixel_format.zig index 78091da..3672358 100644 --- a/vendor/ghostty/pkg/macos/video/pixel_format.zig +++ b/vendor/ghostty/pkg/macos/video/pixel_format.zig @@ -51,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" = c.kCVPixelFormatType_30RGB_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