diff --git a/.gitignore b/.gitignore index 392adb5..4665b88 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,9 @@ Macterm/Resources/* build/ +# Swift/zig build caches (e.g. a local .build from tooling) — never committed. +.build/ + # Generated by xcodegen from project.yml Macterm.xcodeproj/ diff --git a/AGENTS.md b/AGENTS.md index d4cf334..4789cd5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,7 +6,7 @@ A macOS terminal emulator built with SwiftUI and libghostty. Single-window app w ```bash mise install # Install tools (gh, swiftformat, swiftlint, xcodegen, xcbeautify) -mise run setup # Download pre-built GhosttyKit.xcframework +mise run setup # Download pre-built GhosttyKit.xcframework + zmx binary mise run run # Build and launch (debug) mise run logs # Stream live logs from the debug app (--release for release app) mise run format # Auto-fix formatting with swiftformat @@ -19,7 +19,7 @@ mise run build # Release build + DMG Requires macOS 14+, Swift 6.0+. Liquid glass and a few chrome refinements are macOS 26 (Tahoe) features that degrade gracefully on older systems (gated behind `WindowAppearance.glassSupported` / `#available`). GhosttyKit is a pre-built xcframework from `thdxg/ghostty` (a fork that adds CI builds); no zig toolchain needed. -`GhosttyKit.xcframework` and the `Macterm/Resources/{ghostty,terminfo,…}` contents are gitignored artifacts downloaded by `mise run setup` — **every fresh checkout, including a git worktree, must run `mise run setup`** before it can build. Don't symlink them from another checkout: `setup.sh` only re-downloads when the artifact is _absent_ (presence check, not version check), so a symlinked copy silently goes stale. To refresh a stale artifact, delete it and re-run setup. +`GhosttyKit.xcframework`, the `Macterm/Resources/{ghostty,terminfo,…}` contents, and the bundled `Macterm/Resources/zmx/zmx` binary are gitignored artifacts downloaded by `mise run setup` — **every fresh checkout, including a git worktree, must run `mise run setup`** before it can build. Don't symlink them from another checkout: `setup.sh` only re-downloads when the artifact is _absent_ (presence check, not version check), so a symlinked copy silently goes stale. To refresh a stale artifact, delete it and re-run setup. (`GhosttyKit` comes from the `thdxg/ghostty` release; `zmx` from the `thdxg/zmx` release — both prebuilt by CI, never compiled locally, since zig 0.15.2 can't link against the macOS 26+/Xcode 26.4 SDK locally.) ## Releasing & Updates @@ -95,6 +95,17 @@ Two non-obvious terminfo facts (the regression behind #39/#40 — broken `TERM=x - **Never set TERMINFO ourselves.** At shell spawn libghostty _unconditionally overwrites_ `TERMINFO` with `dirname(GHOSTTY_RESOURCES_DIR)/terminfo`, so the bundle layout must make that derivation land on the dir we ship — terminfo MUST be a sibling of `ghostty/`, never inside it. `BundledResourcesTests` asserts this invariant. - **The terminfo tree uses the macOS hashed layout** — `terminfo/78/xterm-ghostty` (`x` = 0x78), not `terminfo/x/...`. It's a `tic -x` compiled tree shipped verbatim. +### Session Persistence (zmx) + +Terminal shells survive app quit and reattach on relaunch, backed by [zmx](https://github.com/neurosnap/zmx) — a Zig session multiplexer that passes raw PTY bytes through (no tmux-style screen re-parse). Every surface's shell runs under `zmx attach macterm-`, injected via ghostty's **`command-wrapper`** config (a patch carried by the `thdxg/ghostty` fork — see below): the wrapper argv is prepended to the *fully resolved* shell command (after `login(1)` + shell integration), so OSC 7 cwd / OSC 133 framing — and thus live tab titles and cwd-on-split — stay intact. + +- **Bundled binary.** Built by `thdxg/zmx` CI (single squashed patch commit on upstream, like the ghostty fork) and downloaded by `setup.sh` to `Macterm/Resources/zmx/zmx` (gitignored), embedded into the bundle at `Contents/Resources/zmx/zmx` by `embed-zmx.sh` (a `project.yml` post-compile phase). Never compiled locally — zig 0.15.2 can't link against the macOS 26+/Xcode 26.4 SDK. +- **`ZmxClient`** (`Macterm/System/ZmxClient.swift`) — cache-free wrapper around the binary. `ZmxSessionID` (`macterm-`), `ZmxSocketBudget` (bypass wrapping when `sun_path` would overflow — never gate kills on it), `ZmxSessionListParser` (the `zmx ls` tab-delimited parser), `ZmxAttach.resolveLaunch` (interactive → argv wrapper; declared `run:` → `/bin/sh -c` string), and `ZmxReaper.orphans` (pure orphan-selection). A 5s subprocess timeout + zombie-reap bounds every call so a stuck daemon can't hang the close/quit path. +- **Stable session id.** `Pane.sessionID` (a UUID **distinct from** `Pane.id`, which is regenerated on restore) is persisted in `PaneSnapshot`. On restore the rebuilt pane reuses it, so its surface reattaches the live daemon instead of spawning fresh. `PaneSnapshot` also persists `workingDirectory`. +- **Detach vs. kill.** `pane.destroySurface()` only *detaches* (the daemon lives on). `pane.killPersistentSession()` is the explicit kill, called **only on permanent close** — `TerminalTab.removePane`, `AppState.removeProject`/`closeTab`, and `executeLayoutPlan`'s `panesToDestroy`. Transient teardown (`unloadProject`, window hide, tab-switch churn) never kills. On quit with `terminateSessionsOnQuit` on, `applicationWillTerminate` kills all sessions **synchronously** (`ZmxClient.killSessionsBlocking`) — a detached `Task` would never run before the process exits. +- **Launch reaper.** `AppState.restoreSelection` calls `ZmxClient.reapOrphans(knownSurfaceIDs:)` — kills `macterm-*` sessions with `clients == 0` that no restored pane claims (crash/force-quit orphans). Prefix-scoped (`macterm-` only, never a co-resident `supa-*` Supacode session); a `nil` probe or `clients > 0` spares everything. +- **Quit.** Quitting detaches by default (shells reattach next launch). `Preferences.terminateSessionsOnQuit` (default off, Settings → Session Persistence) kills all sessions on quit instead; while off, the "processes will be killed" quit prompt is suppressed since nothing is lost. + ### Tests (`MactermTests/`) One `XxxTests.swift` per production type, mirroring the source path. `@testable import Macterm` + `@MainActor` on test classes. @@ -136,7 +147,7 @@ One `XxxTests.swift` per production type, mirroring the source path. `@testable - Workspaces → `~/Library/Application Support/Macterm/workspaces_v3.json`; projects → `projects.json`; wrapper configs (`macterm-defaults.conf`, `macterm-overrides.conf`) in the same directory. The directory name comes from `appDisplayName` (`CFBundleDisplayName`), so debug builds use `Macterm Debug/` — fully separate data per build, mirroring the bundle-ID split. - `Pane` IDs are not preserved across restarts — restore creates fresh UUIDs. - Declarative layouts are an _authorable_ file at `.macterm/layout.yaml` in the project root, applied/saved on demand via `applyLayout`/`saveLayout`. An unparseable file surfaces `LayoutFileError` and is never applied. JSON schema at `assets/layout.schema.json` — keep it in sync when layout types change. - - A committed layout file is the source of truth: on relaunch, `restoreSelection` skips the workspace snapshot for any project that has one; a project's first open auto-applies it (with no live panes the reconcile is pure-spawn, never destructive). + - On relaunch, `restoreSelection` restores **every** project's workspace snapshot (including projects with a committed layout file), so a layout project's live tabs/splits and each pane's persisted `sessionID` come back and its zmx sessions reattach. **Reopen never prompts and never re-applies the layout** — a restored snapshot always wins. The committed `.macterm/layout.yaml` only seeds a workspace when there's **no** snapshot to restore (a genuine first open, or first launch after the layout was committed): `autoApplyLayoutOnFirstOpen` applies it silently, guarded on `workspaces[id] == nil` so a restored snapshot makes it a no-op. (Applying a layout to an already-open project remains an explicit user action via `applyLayout`, which still stages `pendingLayoutApply` for confirmation when destructive.) - `save` records a pane's `run:` as its **live** foreground command (`ghostty_surface_foreground_pid` → `ProcessInspector` argv via `KERN_PROCARGS2`) — an idle prompt saves no `run`. `shell:` is recorded only when the pane sits in a _non-default_ shell. `run` and `shell` are mutually exclusive on a leaf. - `apply` (`LayoutReconciler`) matches live panes to declared ones by that same live `(run, cwd)`; a pane that quit its declared command is respawned. A plain-shell leaf matches an idle pane positionally, but a declared `shell:` only reuses an idle pane running that shell (basename compare). The live-command/live-shell lookups are injected closures (default `ProcessInspector`), so the logic is unit-testable. - The file's top-level `name:` is the project it was saved for — a mismatch on apply stages a confirmation warning. Tab `name:` is the tab's title, matched to live tabs during reconcile. @@ -166,7 +177,6 @@ Macterm-side settings flow through `Preferences` (UserDefaults); ghostty-shaped ## Known Limitations -- **No process persistence** — closing the app kills all shells (recommend tmux/zellij). - **Single window only** — multi-window would require a tmux-like daemon; out of scope. - **Not code-signed with a Developer ID** — first launch needs `xattr -cr /Applications/Macterm.app` (or Homebrew install); Sparkle updates verify EdDSA after that. - **Pane IDs not stable across restarts** — fresh views are created on restore. diff --git a/Macterm/App/AppState.swift b/Macterm/App/AppState.swift index 91ba437..6e7b0cd 100644 --- a/Macterm/App/AppState.swift +++ b/Macterm/App/AppState.swift @@ -88,6 +88,16 @@ final class AppState { @ObservationIgnored private var processNameTimer: Timer? + /// Guards against overlapping `zmx ls` refreshes of the foreground-resolver + /// cache: a slow probe shouldn't pile up behind the 250ms poll. + @ObservationIgnored + private var zmxForegroundRefreshInFlight = false + + /// zmx session-persistence client. Defaults to the live binding; injectable + /// so tests can drive the launch-time orphan reaper without a real daemon. + @ObservationIgnored + var zmx: ZmxClient = .live + init(workspaceStore: WorkspaceStore = WorkspaceStore()) { self.workspaceStore = workspaceStore autoTileObserver = NotificationCenter.default.addObserver( @@ -112,6 +122,14 @@ final class AppState { /// workspaces. Each pane only republishes (and triggers a tab re-render) /// when its name actually changes, so this is cheap when nothing's moving. func refreshAllForegroundProcesses() { + // Refresh the zmx session→daemon-leader-pid cache that ProcessInspector + // uses to see past the `zmx attach` wrapper to the real foreground + // process. One `zmx ls` per tick (not per pane), off-main; the cache is + // read synchronously and is at most one tick stale (daemon pids are + // stable per session). A guard drops the refresh when one is already in + // flight so a slow `ls` can't pile up. Only when panes exist. + refreshZmxForegroundCacheIfNeeded() + // Shell/raw-mode detection (KERN_PROCARGS2 + open/tcgetattr per pane) // and the quiet-settle only matter when the status indicator is shown; // skip them in icon mode so the default poll stays as cheap as before @@ -136,6 +154,23 @@ final class AppState { if didAcknowledgeCompletion { saveWorkspaces() } } + /// Kick an off-main `zmx ls` to refresh `ZmxForegroundResolver`'s + /// session→daemon-leader-pid cache, unless one is already running or no + /// panes exist (nothing to resolve, and no reason to spawn a subprocess). + private func refreshZmxForegroundCacheIfNeeded() { + guard !zmxForegroundRefreshInFlight else { return } + let hasPanes = workspaces.values.contains { ws in + ws.tabs.contains { !$0.splitRoot.allPanes().isEmpty } + } + guard hasPanes else { return } + zmxForegroundRefreshInFlight = true + Task { [zmx] in + let map = await zmx.sessionLeaderPIDs() + ZmxForegroundResolver.updateCache(map) + await MainActor.run { self.zmxForegroundRefreshInFlight = false } + } + } + private func recordProjectVisit(_ projectID: UUID) { projectRecency.push(projectID) UserDefaults.standard.set(projectRecency.items.map(\.uuidString), forKey: recencyKey) @@ -165,14 +200,15 @@ final class AppState { hasRestoredSelection = true let snapshots = workspaceStore.load() let valid = Set(projects.map(\.id)) - // A committed layout file is the source of truth: skip restoring the - // session snapshot for any project that has one, leaving its workspace - // nil so it rebuilds from `.macterm/layout.yaml` on open (below / on - // first select). Projects with no layout file restore their snapshot. - let pathByID = Dictionary(projects.map { ($0.id, $0.path) }, uniquingKeysWith: { a, _ in a }) - for ws in WorkspaceSerializer.restore(from: snapshots, validIDs: valid) - where !LayoutFile.exists(atProjectRoot: pathByID[ws.projectID] ?? "") - { + // Restore every project's session snapshot — including layout projects. + // The snapshot carries the live tabs/splits and each pane's `sessionID`, + // so a layout project's panes reattach their zmx sessions and its live + // layout is remembered, instead of being silently overwritten by the + // declared `.macterm/layout.yaml` on every launch. The committed layout + // is offered (a confirmable apply), not force-applied over a restored + // session — see `autoApplyLayoutOnFirstOpen` (first-open seed only) and + // `offerLayoutIfChanged`. + for ws in WorkspaceSerializer.restore(from: snapshots, validIDs: valid) { workspaces[ws.projectID] = ws } if let id = Preferences.shared.activeProjectID, @@ -180,14 +216,27 @@ final class AppState { { activeProjectID = id recordProjectVisit(id) - // Build the active project from its layout file if it has one (its - // snapshot was skipped above); otherwise the restored snapshot stands - // and `ensureWorkspace` only creates a default when neither exists. + // Reopen is always silent — never prompt. A restored snapshot wins + // (reattach + remembered live layout); only when there's no snapshot + // does the committed layout seed the workspace, applied silently. + // `autoApplyLayoutOnFirstOpen` guards on `workspaces[id] == nil`, so + // a restored snapshot makes it a no-op automatically. autoApplyLayoutOnFirstOpen(project) ensureWorkspace(projectID: id, path: project.path) acknowledgeActiveTab(projectID: id) warmFocusedProject() } + + // Reap zmx sessions left behind by a crash / force-quit: any macterm-* + // session with no attached client that no restored pane claims. Runs + // after workspaces are built so the known set is complete. Detached but + // claimed sessions (a pane that will reattach lazily on select) are + // spared because their surfaceID is in the known set. + let known = Set(workspaces.values + .flatMap(\.tabs) + .flatMap { $0.splitRoot.allPanes() } + .map(\.sessionID)) + Task { [zmx] in await zmx.reapOrphans(knownSurfaceIDs: known) } } func saveWorkspaces() { @@ -314,6 +363,8 @@ final class AppState { logger.debug("removeProject: \(projectID, privacy: .public)") if let ws = workspaces[projectID] { for pane in ws.tabs.flatMap({ $0.splitRoot.allPanes() }) { + // Project removed for good → kill each pane's persisted session. + pane.killPersistentSession() pane.destroySurface() } } @@ -345,6 +396,8 @@ final class AppState { else { return } logger.debug("closeTab: \(tabID, privacy: .public) project=\(projectID, privacy: .public)") for pane in tab.splitRoot.allPanes() { + // Tab closed for good → kill each pane's persisted session. + pane.killPersistentSession() pane.destroySurface() } ws.closeTab(tabID) @@ -633,7 +686,11 @@ final class AppState { } // Destroy surfaces only AFTER the new trees no longer reference them. + // A pane the reconcile dropped is gone for good (not reused by any + // declared node), so kill its persisted session — otherwise it would + // orphan a clients==0 zmx daemon until the next launch's reaper. for pane in plan.panesToDestroy { + pane.killPersistentSession() pane.destroySurface() } diff --git a/Macterm/App/MactermApp.swift b/Macterm/App/MactermApp.swift index 9a9d3e7..ae75d4d 100644 --- a/Macterm/App/MactermApp.swift +++ b/Macterm/App/MactermApp.swift @@ -254,6 +254,19 @@ final class AppDelegate: NSObject, NSApplicationDelegate { func applicationWillTerminate(_: Notification) { onTerminate?() + // zmx sessions outlive the app by design (reattach on relaunch). Only + // when the user opted into terminate-on-quit do we kill them all here — + // and it must be a BLOCKING kill: a detached Task would never be + // scheduled before the process exits, so the per-pane fire-and-forget + // path (killPersistentSession) silently no-ops at quit. Collect the + // session ids and tear them down synchronously, bounded so a wedged + // daemon can't hang quit. + guard Preferences.shared.terminateSessionsOnQuit else { return } + let sessionIDs = (appState?.workspaces.values ?? [:].values) + .flatMap(\.tabs) + .flatMap { $0.splitRoot.allPanes() } + .map { ZmxSessionID.make(surfaceID: $0.sessionID) } + ZmxClient.live.killSessionsBlocking(sessionIDs) } func applicationShouldTerminate(_: NSApplication) -> NSApplication.TerminateReply { @@ -273,6 +286,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate { /// Walk every workspace + the quick terminal and emit one row per pane /// whose ghostty surface still has a foreground process running. private func collectRunningProcessRows() -> [RunningProcessRow] { + // With session persistence active (zmx bundled + under budget) and + // terminate-on-quit off, every shell survives quit and reattaches on + // relaunch — so nothing is actually lost and the "processes will be + // killed" prompt is wrong. Skip it. (When the user opts into + // terminate-on-quit, sessions DO die, so fall through and prompt.) + if ZmxClient.live.executableURL() != nil, !Preferences.shared.terminateSessionsOnQuit { + return [] + } var rows: [RunningProcessRow] = [] let projectsByID = Dictionary( uniqueKeysWithValues: (projectStore?.projects ?? []).map { ($0.id, $0) } diff --git a/Macterm/App/Preferences.swift b/Macterm/App/Preferences.swift index 7b96512..47c1fe4 100644 --- a/Macterm/App/Preferences.swift +++ b/Macterm/App/Preferences.swift @@ -82,6 +82,15 @@ final class Preferences { didSet { defaults.set(showNewProjectButton, forKey: Keys.showNewProjectButton) } } + /// When true, quitting Macterm kills every pane's zmx session so nothing + /// keeps running in the background. Default off — session persistence (shells + /// survive quit and reattach on relaunch) is the point, so quit detaches + /// rather than terminates. Macterm-side only; never touches the ghostty + /// config pipeline. + var terminateSessionsOnQuit: Bool { + didSet { defaults.set(terminateSessionsOnQuit, forKey: Keys.terminateSessionsOnQuit) } + } + // MARK: - Toolbar var tabSwitcherVisibility: TabSwitcherVisibility { @@ -256,6 +265,7 @@ final class Preferences { tabIconSymbol = defaults.string(forKey: Keys.tabIconSymbol) ?? "terminal" showTabStatusIndicator = defaults.object(forKey: Keys.showTabStatusIndicator) as? Bool ?? false showNewProjectButton = defaults.object(forKey: Keys.showNewProjectButton) as? Bool ?? true + terminateSessionsOnQuit = defaults.object(forKey: Keys.terminateSessionsOnQuit) as? Bool ?? false tabSwitcherVisibility = (defaults.string(forKey: Keys.tabSwitcherVisibility)) .flatMap(TabSwitcherVisibility.init(rawValue:)) ?? .whenMultiple Self.runOneTimeMigrations(defaults: defaults) @@ -298,6 +308,7 @@ final class Preferences { static let projectIconSymbol = "macterm.sidebar.projectIcon" static let tabIconSymbol = "macterm.sidebar.tabIcon" static let showTabStatusIndicator = "macterm.sidebar.showTabStatusIndicator" + static let terminateSessionsOnQuit = "macterm.session.terminateOnQuit" static let showNewProjectButton = "macterm.sidebar.showNewProjectButton" static let tabSwitcherVisibility = "macterm.toolbar.tabSwitcherVisibility" static let migrationV2GhosttyConfigOwned = "macterm.migration.v2_ghostty_config_owned" diff --git a/Macterm/Model/SplitNode.swift b/Macterm/Model/SplitNode.swift index 7435294..821bc57 100644 --- a/Macterm/Model/SplitNode.swift +++ b/Macterm/Model/SplitNode.swift @@ -235,6 +235,13 @@ private struct TerminalExecutionTracker { @MainActor @Observable final class Pane: Identifiable { let id = UUID() + /// Stable session id for zmx-backed persistence, distinct from `id`. The + /// pane's shell runs under `zmx attach macterm-`; this id is + /// persisted in the workspace snapshot (unlike `id`, which is regenerated on + /// every restore) so a restored pane re-attaches to its still-running + /// daemon. Defaults to a fresh UUID for a new pane; the restore path passes + /// the saved one. + let sessionID: UUID let projectPath: String let projectID: UUID /// Process the pane launches on first surface creation, injected into the @@ -418,7 +425,13 @@ final class Pane: Identifiable { func ensureNSView() -> GhosttyTerminalNSView { if let existing = _nsView { return existing } - let view = GhosttyTerminalNSView(workingDirectory: projectPath, command: command, shell: shell, env: env) + let view = GhosttyTerminalNSView( + workingDirectory: projectPath, + sessionID: sessionID, + command: command, + shell: shell, + env: env + ) _nsView = view return view } @@ -479,6 +492,19 @@ final class Pane: Identifiable { } } + /// Permanently kill this pane's persisted zmx session. Call ONLY when the + /// pane is gone for good (user closed it, tab/project removed, dropped by a + /// layout apply) — NOT on transient teardown (project unload, window hide, + /// tab-switch surface churn), where the session must survive so a later + /// reattach restores the shell. `destroySurface` alone detaches (the daemon + /// lives on); this is the explicit "kill" the detach-not-kill design omits. + /// Fire-and-forget: the close path isn't blocked on it, and ZmxClient's + /// subprocess timeout bounds a stuck daemon. + func killPersistentSession() { + let id = ZmxSessionID.make(surfaceID: sessionID) + Task { await ZmxClient.live.killSession(id) } + } + var processTitle: String { // The live foreground process name (`hx`, `btop`) when a program is // running, else the shell name when idle. Always process-table derived @@ -517,12 +543,14 @@ final class Pane: Identifiable { init( projectPath: String, projectID: UUID, + sessionID: UUID = UUID(), command: String? = nil, shell: String? = nil, env: [String: String]? = nil ) { self.projectPath = projectPath self.projectID = projectID + self.sessionID = sessionID self.command = command self.shell = shell self.env = env diff --git a/Macterm/Model/Workspace.swift b/Macterm/Model/Workspace.swift index 0784725..a1dbe75 100644 --- a/Macterm/Model/Workspace.swift +++ b/Macterm/Model/Workspace.swift @@ -169,6 +169,9 @@ final class TerminalTab: Identifiable { @discardableResult func removePane(_ paneID: UUID) -> PaneRemovalResult { guard let pane = splitRoot.findPane(id: paneID) else { return .notFound } + // Pane removal is always permanent (it leaves the tree), so kill its + // persisted zmx session — unlike transient teardown, which only detaches. + pane.killPersistentSession() pane.destroySurface() let panes = splitRoot.allPanes() if panes.count <= 1 { diff --git a/Macterm/Persistence/WorkspacePersistence.swift b/Macterm/Persistence/WorkspacePersistence.swift index fd07cc6..79df060 100644 --- a/Macterm/Persistence/WorkspacePersistence.swift +++ b/Macterm/Persistence/WorkspacePersistence.swift @@ -70,9 +70,37 @@ struct PaneSnapshot: Codable { /// outlive the shell process, and `.idle` is the default. Optional so older /// snapshots (without the field) decode as nil / idle. var needsAttention: Bool? + /// Stable zmx session id (`Pane.sessionID`). On restore the rebuilt pane + /// re-uses this id so its shell re-attaches to the still-running + /// `macterm-` daemon instead of spawning fresh. Optional so older + /// snapshots (pre-persistence) decode as nil → a fresh id (new session). + var sessionID: UUID? + /// The pane's live working directory at snapshot time, so a restored pane + /// lands back where the user was. Optional/back-compat: nil falls back to + /// `projectPath`. (zmx reattach restores the *running* cwd regardless; this + /// matters for a session that didn't survive and respawns fresh.) + var workingDirectory: String? // No `title`: the tab name is derived live from the pane's foreground // process, so there's nothing per-pane to persist. (An older snapshot's // `title` key is harmlessly ignored on decode.) + + /// Explicit memberwise init with defaults for the optional fields, so call + /// sites that predate sessionID/workingDirectory (and tests building old-shape + /// snapshots) keep compiling. SwiftLint forbids the `= nil` on the stored + /// property declarations, so the defaults live here instead. + init( + id: UUID, + projectPath: String, + needsAttention: Bool? = nil, + sessionID: UUID? = nil, + workingDirectory: String? = nil + ) { + self.id = id + self.projectPath = projectPath + self.needsAttention = needsAttention + self.sessionID = sessionID + self.workingDirectory = workingDirectory + } } struct SplitBranchSnapshot: Codable { @@ -204,14 +232,19 @@ enum WorkspaceSerializer { case let .pane(p): // Prefer the shell's live cwd over the pane's original project // path so reopening the app lands each pane back in the directory - // the user had navigated to. Falls back to projectPath when the - // surface hasn't reported a pwd yet. - let path = p.nsView?.currentPwd ?? p.projectPath + // the user had navigated to. OSC 7 (`currentPwd`) first; fall back to + // the foreground process's cwd from the process table (reliable even + // for shells that don't emit OSC 7); finally projectPath. + let path = p.nsView?.currentPwd + ?? ProcessInspector.foregroundWorkingDirectory(forPane: p) + ?? p.projectPath let needsAttention = p.executionState == .done return .pane(PaneSnapshot( id: p.id, projectPath: path, - needsAttention: needsAttention + needsAttention: needsAttention, + sessionID: p.sessionID, + workingDirectory: path )) case let .split(b): return .split(SplitBranchSnapshot( @@ -226,7 +259,15 @@ enum WorkspaceSerializer { private static func restoreNode(_ snap: SplitNodeSnapshot, projectID: UUID) -> SplitNode { switch snap { case let .pane(p): - let pane = Pane(projectPath: p.projectPath, projectID: projectID) + // Reuse the persisted session id so the restored pane re-attaches to + // its still-running zmx daemon; nil (old snapshot) → a fresh session. + // Land in the saved working directory (workingDirectory is the + // explicit field; projectPath is the back-compat fallback). + let pane = Pane( + projectPath: p.workingDirectory ?? p.projectPath, + projectID: projectID, + sessionID: p.sessionID ?? UUID() + ) if p.needsAttention == true { pane.restoreNeedsAttention() } diff --git a/Macterm/Settings/SettingsView.swift b/Macterm/Settings/SettingsView.swift index 357315f..42a95c3 100644 --- a/Macterm/Settings/SettingsView.swift +++ b/Macterm/Settings/SettingsView.swift @@ -30,6 +30,8 @@ private struct GeneralSettings: View { @AppStorage(Preferences.Keys.eagerlyStartProjectTabs) private var eagerlyStartProjectTabs = true + @AppStorage(Preferences.Keys.terminateSessionsOnQuit) + private var terminateSessionsOnQuit = false @State private var ghosttyConfigPath: String = Preferences.shared.userGhosttyConfigPath @@ -83,6 +85,19 @@ private struct GeneralSettings: View { .font(.system(size: 11)) .foregroundStyle(.secondary) } + + Section("Session Persistence") { + Toggle("Quit terminals when Macterm quits", isOn: $terminateSessionsOnQuit) + .onChange(of: terminateSessionsOnQuit) { _, v in + Preferences.shared.terminateSessionsOnQuit = v + } + Text( + "Off (default): shells keep running in the background after you quit and reattach on next launch. " + + "On: quitting stops every terminal's processes." + ) + .font(.system(size: 11)) + .foregroundStyle(.secondary) + } } .formStyle(.grouped) } diff --git a/Macterm/System/ProcessInspector.swift b/Macterm/System/ProcessInspector.swift index a95754e..6b23b7d 100644 --- a/Macterm/System/ProcessInspector.swift +++ b/Macterm/System/ProcessInspector.swift @@ -10,13 +10,31 @@ import Foundation /// Everything is best-effort: any failure yields nil rather than throwing. Only /// same-uid processes are readable, which the pane's foreground process is. enum ProcessInspector { + /// The pid to inspect for a pane's foreground process. When the pane's shell + /// is wrapped in zmx, libghostty's `foregroundPID` points at the local + /// `zmx attach` client (whose `comm` is just `zmx`), while the real shell / + /// program runs under the zmx daemon — a detached process tree reachable + /// only by session id. `ZmxForegroundResolver` maps the session id to the + /// daemon's pty foreground process; use that when available, else fall back + /// to libghostty's pid (no zmx, over-budget bypass, or before the first + /// `zmx ls` populates the cache). + @MainActor + private static func foregroundPID(forPane pane: Pane) -> pid_t? { + if let resolved = ZmxForegroundResolver.foregroundPID( + sessionID: ZmxSessionID.make(surfaceID: pane.sessionID) + ) { + return resolved + } + return pane.nsView?.foregroundPID + } + /// The command running in the pane's foreground, or nil if the pane is idle /// at a shell prompt, has no live surface, or the process can't be read. /// The string is the resolved argv joined with spaces (e.g. /// `node …/npm-cli.js run dev`), not necessarily what the user typed. @MainActor static func runningCommand(forPane pane: Pane) -> String? { - guard let pid = pane.nsView?.foregroundPID else { return nil } + guard let pid = foregroundPID(forPane: pane) else { return nil } guard let args = argv(pid: pid), !args.isEmpty else { return nil } // Idle at a prompt: the foreground process is the shell itself. Nothing // worth recording as a `run` command. @@ -51,7 +69,7 @@ enum ProcessInspector { /// pid at all (surface not yet created). @MainActor static func runningProcessName(forPane pane: Pane) -> String? { - guard let pid = pane.nsView?.foregroundPID else { return nil } + guard let pid = foregroundPID(forPane: pane) else { return nil } guard var comm = comm(pid: pid), !comm.isEmpty else { return nil } // A login shell's comm may carry a leading `-` (e.g. `-zsh`). if comm.hasPrefix("-") { comm.removeFirst() } @@ -65,7 +83,7 @@ enum ProcessInspector { /// shell holds it is prompt churn (see `Pane.receiveReportedTitle`). @MainActor static func foregroundProgramPID(forPane pane: Pane) -> pid_t? { - guard let pid = pane.nsView?.foregroundPID else { return nil } + guard let pid = foregroundPID(forPane: pane) else { return nil } guard let args = argv(pid: pid), let first = args.first, !isShell(first) else { return nil } return pid } @@ -76,7 +94,7 @@ enum ProcessInspector { /// Macterm-maintained list. @MainActor static func foregroundProcessIsShell(forPane pane: Pane) -> Bool { - guard let pid = pane.nsView?.foregroundPID else { return false } + guard let pid = foregroundPID(forPane: pane) else { return false } return isShellProcess(pid: pid) } @@ -124,7 +142,7 @@ enum ProcessInspector { /// program's cwd (typically launched from, and matching, the shell's). @MainActor static func foregroundWorkingDirectory(forPane pane: Pane) -> String? { - guard let pid = pane.nsView?.foregroundPID else { return nil } + guard let pid = foregroundPID(forPane: pane) else { return nil } return workingDirectory(pid: pid) } @@ -169,7 +187,7 @@ enum ProcessInspector { /// (which may be a bare `-zsh` login form). @MainActor static func runningShell(forPane pane: Pane) -> String? { - guard let pid = pane.nsView?.foregroundPID else { return nil } + guard let pid = foregroundPID(forPane: pane) else { return nil } guard let args = argv(pid: pid), let first = args.first, isShell(first) else { return nil } // Skip the user's default login shell — only a shell they switched to is // worth recording. diff --git a/Macterm/System/ZmxClient.swift b/Macterm/System/ZmxClient.swift new file mode 100644 index 0000000..05fa8fb --- /dev/null +++ b/Macterm/System/ZmxClient.swift @@ -0,0 +1,393 @@ +import Foundation +import os + +private let logger = Logger(subsystem: appBundleID, category: "ZmxClient") + +/// Per-surface session-persistence wrapper around the bundled `zmx` multiplexer +/// (https://github.com/neurosnap/zmx). Each terminal surface launches its shell +/// under `zmx attach ` (injected as ghostty's `command-wrapper`), so the +/// shell survives app quit; on the next launch the same session id re-attaches +/// to the still-running daemon and the buffer + process come back. +/// +/// Cache-free by design: zmx itself is authoritative for attach-vs-create, so we +/// never gate launch on a stale local snapshot of daemon state. Adapted from +/// Supacode's `ZmxClient`, trimmed to Macterm's single-window model (no +/// per-worktree concept, no remote SSH surfaces). +struct ZmxClient { + /// Bundled zmx executable URL when the socket-path budget probe passed, + /// otherwise nil. Use for the wrap-vs-bypass decision on NEW surfaces. + var executableURL: @Sendable () -> URL? + /// True whenever the zmx binary is bundled, independent of the probe. + /// Kill paths use this (not `executableURL`) so we can still tear down + /// sessions from an earlier under-budget launch even when this launch is + /// over budget — probe bypass means "don't wrap", not "don't kill". + var isBundled: @Sendable () -> Bool + /// Tear down a session. No-op on a missing session. Bounded by the + /// subprocess timeout so a stuck daemon can't hold the close path forever. + var killSession: @Sendable (_ sessionID: String) async -> Void + /// Each live Macterm session with its attached-client count, or nil when the + /// probe failed/timed out. nil means UNKNOWN (never reap); `[]` is a + /// successful empty listing. An entry's `clients == nil` marks an unknown + /// count (err/status line) the reaper must also spare. + var listSessionsWithClients: @Sendable () async -> [ZmxSessionListParser.Entry]? + /// `macterm-` → daemon session-leader pid, parsed from `zmx ls`. Drives + /// `ZmxForegroundResolver`'s cache so `ProcessInspector` can find the real + /// foreground process under the daemon (not the `zmx attach` client that + /// libghostty reports). Empty when the probe fails or no sessions exist. + var sessionLeaderPIDs: @Sendable () async -> [String: pid_t] +} + +extension ZmxClient { + /// 5-second cap on any `zmx` subprocess so a stuck daemon never blocks the + /// app's close / quit paths. Every call we issue (ls / kill) completes in + /// <100ms in practice; if it doesn't, log + continue beats hanging. + static let subprocessTimeout: Duration = .seconds(5) + + static let live: ZmxClient = { + // Probe the socket-path budget once per process and cache the outcome. + let probed = OSAllocatedUnfairLock(initialState: nil) + // The bundled binary at Contents/Resources/zmx/zmx (embed-zmx.sh). + let cachedBundledURL: URL? = Bundle.main.url( + forResource: "zmx", + withExtension: nil, + subdirectory: "zmx" + ) + + @Sendable + func resolveExecutable() -> URL? { + guard let url = cachedBundledURL else { return nil } + let outcome: ProbeOutcome = probed.withLock { current in + if let current { return current } + let computed: ProbeOutcome + if let reason = ZmxSocketBudget.probe() { + logger.warning("Bypassing zmx wrapping: \(reason, privacy: .public)") + computed = .bypass + } else { + computed = .allow + } + current = computed + return computed + } + return outcome == .allow ? url : nil + } + + @Sendable + func bundledExecutable() -> URL? { + cachedBundledURL + } + + return ZmxClient( + executableURL: resolveExecutable, + isBundled: { bundledExecutable() != nil }, + killSession: { sessionID in + _ = await runZmx(["kill", sessionID], executable: bundledExecutable()) + }, + listSessionsWithClients: { + // nil from runZmx is the UNKNOWN signal (spawn error / timeout / + // non-zero exit); preserve it so the reaper never kills on a + // failed probe. + guard let stdout = await runZmx( + ["ls"], executable: bundledExecutable(), captureStdout: true + ) + else { return nil } + return ZmxSessionListParser.parse(stdout) + }, + sessionLeaderPIDs: { + guard let stdout = await runZmx( + ["ls"], executable: bundledExecutable(), captureStdout: true + ) + else { return [:] } + return ZmxForegroundResolver.parseLeaderPIDs(stdout) + } + ) + }() + + /// No-op client for tests / when zmx is unavailable. + static let noop = ZmxClient( + executableURL: { nil }, + isBundled: { false }, + killSession: { _ in }, + listSessionsWithClients: { [] }, + sessionLeaderPIDs: { [:] } + ) + + private enum ProbeOutcome: Equatable { case allow, bypass } + + /// Kill every `macterm-*` session the live daemon hosts that no live/persisted + /// pane claims and that has no attached client — i.e. crash / force-quit + /// orphans. Attach-aware and prefix-scoped: a session with a live client + /// (`clients > 0`), an unknown client count (`clients == nil`, an err/status + /// line), or a non-`macterm-` name (e.g. a co-resident Supacode `supa-*` + /// session) is spared, and a failed probe (`listSessionsWithClients` → nil) + /// reaps nothing. `knownSurfaceIDs` are the surface ids every live + restored + /// pane owns this launch. + func reapOrphans(knownSurfaceIDs: Set) async { + guard let live = await listSessionsWithClients() else { + logger.info("Skipping orphan reap: zmx session probe unavailable") + return + } + let known = Set(knownSurfaceIDs.map(ZmxSessionID.make(surfaceID:))) + let orphans = ZmxReaper.orphans(in: live, known: known) + guard !orphans.isEmpty else { return } + logger.info("Reaping \(orphans.count, privacy: .public) orphan zmx session(s)") + await withTaskGroup(of: Void.self) { group in + for name in orphans { + group.addTask { await self.killSession(name) } + } + } + } + + /// Synchronously kill `sessionIDs`, blocking the caller until every kill + /// finishes or `timeout` elapses. For `applicationWillTerminate`, where the + /// run loop is tearing down and a detached `Task` would never be scheduled + /// before the process exits — so a fire-and-forget kill silently no-ops. The + /// kills run concurrently off the main thread; each is already bounded by the + /// 5s subprocess timeout, and `timeout` caps the whole batch so a wedged + /// daemon can't hang quit indefinitely. + nonisolated func killSessionsBlocking( + _ sessionIDs: [String], + timeout: Duration = .seconds(6) + ) { + guard !sessionIDs.isEmpty else { return } + let group = DispatchGroup() + group.enter() + let kill = killSession + Task { + await withTaskGroup(of: Void.self) { taskGroup in + for id in sessionIDs { + taskGroup.addTask { await kill(id) } + } + } + group.leave() + } + let seconds = Double(timeout.components.seconds) + + Double(timeout.components.attoseconds) / 1e18 + _ = group.wait(timeout: .now() + seconds) + } + + /// Runs a zmx subcommand; returns captured stdout on success, or nil on any + /// failure (unbundled, spawn error, timeout, non-zero exit). Uses the + /// non-budget-gated `bundledExecutable` so kill paths work even when this + /// launch is over budget. + private static func runZmx( + _ arguments: [String], + executable: URL?, + captureStdout: Bool = false + ) async -> String? { + guard let executable else { return nil } + let commandDesc = "zmx " + arguments.joined(separator: " ") + let process = Process() + process.executableURL = executable + process.arguments = arguments + // Pin ZMX_DIR so the subprocess resolves the same socket dir as the + // wrapped shell (defense-in-depth against env divergence). + var env = ProcessInfo.processInfo.environment + env["ZMX_DIR"] = ZmxSocketBudget.socketDir(env: env) + process.environment = env + + // macOS pipe buffer is ~64KB; a child that emits more without us draining + // would deadlock on write while we await termination. Drain captured + // stdout continuously, or send it to /dev/null when unneeded. + let stdoutBuffer = OSAllocatedUnfairLock(initialState: Data()) + if captureStdout { + let stdoutPipe = Pipe() + process.standardOutput = stdoutPipe + stdoutPipe.fileHandleForReading.readabilityHandler = { handle in + let chunk = handle.availableData + if chunk.isEmpty { + handle.readabilityHandler = nil + return + } + stdoutBuffer.withLock { $0.append(chunk) } + } + } else { + process.standardOutput = FileHandle.nullDevice + } + let stderrPipe = Pipe() + process.standardError = stderrPipe + let stderrBuffer = OSAllocatedUnfairLock(initialState: Data()) + stderrPipe.fileHandleForReading.readabilityHandler = { handle in + let chunk = handle.availableData + if chunk.isEmpty { + handle.readabilityHandler = nil + return + } + stderrBuffer.withLock { $0.append(chunk) } + } + + // `terminationHandler` is the cancellation-safe exit signal; wired + // BEFORE run() so the signal is never missed. + let exitStream = AsyncStream { continuation in + process.terminationHandler = { proc in + continuation.yield(proc.terminationStatus) + continuation.finish() + } + } + do { + try process.run() + } catch { + logger.warning("\(commandDesc, privacy: .public) failed: \(error.localizedDescription, privacy: .public)") + return nil + } + + let exitStatus: Int32? = await withTaskGroup(of: Int32?.self) { group in + group.addTask { + for await status in exitStream { + return status + } + return nil + } + group.addTask { + try? await Task.sleep(for: subprocessTimeout) + return nil + } + defer { group.cancelAll() } + // `next()` yields Int32?? (group element is itself Int32?); flatten + // to the inner status. First child to finish wins (timeout or exit). + return await group.next().flatMap(\.self) + } + + guard let exitStatus else { + if process.isRunning { process.terminate() } + // Wait for the kernel to reap before returning so we don't leak a + // zombie. Bounded so a wedged SIGTERM target can't extend the path. + _ = await withTaskGroup(of: Void.self) { group in + group.addTask { for await _ in exitStream {} } + group.addTask { try? await Task.sleep(for: .seconds(1)) } + defer { group.cancelAll() } + await group.next() + } + logger.warning("\(commandDesc, privacy: .public) timed out after \(subprocessTimeout, privacy: .public)") + return nil + } + if exitStatus != 0 { + let stderr = stderrBuffer.withLock { String(data: $0, encoding: .utf8) ?? "" } + logger.warning("\(commandDesc, privacy: .public) exit=\(exitStatus) stderr=\(stderr, privacy: .public)") + return nil + } + guard captureStdout else { return nil } + return stdoutBuffer.withLock { String(data: $0, encoding: .utf8) ?? "" } + } +} + +/// Pure session-ID helpers. zmx's macOS socket-path budget is tight (`sun_path` +/// is 104); `macterm-` is 44 bytes, leaving headroom for a longer custom +/// `ZMX_DIR`. +enum ZmxSessionID { + static let prefix = "macterm-" + + static func make(surfaceID: UUID) -> String { + prefix + surfaceID.uuidString.lowercased() + } +} + +/// Pure parser for zmx's full (`ls`, non-`--short`) tab-delimited listing. +/// Each line is `[→ | ]name=\tk=v\t...`; a healthy session carries +/// `clients=`, an unreachable one carries `err=`/`status=` (no count). +enum ZmxSessionListParser { + struct Entry: Equatable { + var name: String + /// nil when the count is unknown (err/status line); the reaper spares these. + var clients: Int? + } + + static func parse(_ stdout: String) -> [Entry] { + stdout + .split(whereSeparator: \.isNewline) + .compactMap { line -> Entry? in + var trimmed = Substring(line) + if trimmed.hasPrefix("→ ") { trimmed = trimmed.dropFirst(2) } + while trimmed.first?.isWhitespace == true { + trimmed = trimmed.dropFirst() + } + let fields = trimmed.split(separator: "\t") + var values: [Substring: Substring] = [:] + for field in fields { + guard let separator = field.firstIndex(of: "=") else { continue } + values[field[field.startIndex ..< separator]] = field[field.index(after: separator)...] + } + guard let name = values["name"], name.hasPrefix(ZmxSessionID.prefix) else { return nil } + // Absent `clients=` (err/status line) → nil = unknown, not zero. + let clients = values["clients"].flatMap { Int($0) } + return Entry(name: String(name), clients: clients) + } + } +} + +/// Pure orphan-selection logic, split out so the reap policy is unit-testable +/// without spawning subprocesses. +enum ZmxReaper { + /// Names safe to reap: a `macterm-` session with `clients == 0` that the + /// `known` set doesn't claim. Spares unknown counts (`clients == nil`), + /// attached sessions (`clients > 0`), foreign-prefix sessions, and anything + /// still owned by a live/restored pane. + static func orphans(in entries: [ZmxSessionListParser.Entry], known: Set) -> [String] { + entries.compactMap { entry in + guard entry.name.hasPrefix(ZmxSessionID.prefix), + entry.clients == 0, + !known.contains(entry.name) + else { return nil } + return entry.name + } + } +} + +/// Socket-path budget against macOS' `sockaddr_un.sun_path` limit. If +/// `/` would overflow, the bundled zmx is unusable and we +/// bypass wrapping (no persistence) rather than hand ghostty a command that dies +/// silently in `zmx attach`. +enum ZmxSocketBudget { + static let sunPathLimit = 104 + static let safetyMargin = 2 + /// `"macterm-" + 36-char UUID` is always 44 bytes; hardcoded so `probe` + /// doesn't allocate a UUID per call just to count it. + static let sessionNameByteCount = ZmxSessionID.prefix.utf8.count + 36 + + /// Resolved zmx socket dir: `ZMX_DIR`, then `XDG_RUNTIME_DIR`/zmx, then + /// `TMPDIR`/zmx-, then `/tmp/zmx-`. Mirrors zmx's own resolver + /// (incl. trailing-slash trim) so kill and the wrapped shell can't diverge. + /// `env` is injectable for deterministic tests. + static func socketDir(env: [String: String] = ProcessInfo.processInfo.environment) -> String { + if let custom = env["ZMX_DIR"], !custom.isEmpty { return custom } + let uid = getuid() + if let xdg = env["XDG_RUNTIME_DIR"], !xdg.isEmpty { return "\(trimTrailingSlash(xdg))/zmx" } + if let tmp = env["TMPDIR"], !tmp.isEmpty { return "\(trimTrailingSlash(tmp))/zmx-\(uid)" } + return "/tmp/zmx-\(uid)" + } + + private static func trimTrailingSlash(_ value: String) -> String { + var trimmed = Substring(value) + while trimmed.hasSuffix("/") { + trimmed = trimmed.dropLast() + } + return String(trimmed) + } + + /// Non-nil reason when `/macterm-` would not fit; nil = safe. + static func probe(env: [String: String] = ProcessInfo.processInfo.environment) -> String? { + let dir = socketDir(env: env) + let totalLen = dir.utf8.count + 1 + sessionNameByteCount + let budget = sunPathLimit - safetyMargin + if totalLen > budget { + return "socket path \(totalLen)B exceeds budget \(budget)B (dir=\(dir))" + } + return nil + } +} + +/// Resolves how a surface launches under zmx. +enum ZmxAttach { + /// The `command-wrapper` argv that wraps a surface's shell in zmx: + /// `[zmx, attach, macterm-]`, prepended to ghostty's fully-resolved + /// command so the real shell runs (and is shell-integrated) as a child of + /// the wrapper. Empty when `executablePath` is nil (zmx unbundled or over the + /// socket-path budget) → the surface launches a plain, unpersisted shell. + /// + /// A declared `run:` is NOT folded in here — the surface types it into the + /// wrapped shell via `initial_input`, preserving the same semantics as a + /// non-persisted run. + static func wrapperArgv(executablePath: String?, sessionID: String) -> [String] { + guard let executablePath else { return [] } + return [executablePath, "attach", sessionID] + } +} diff --git a/Macterm/System/ZmxForegroundResolver.swift b/Macterm/System/ZmxForegroundResolver.swift new file mode 100644 index 0000000..76c2eba --- /dev/null +++ b/Macterm/System/ZmxForegroundResolver.swift @@ -0,0 +1,94 @@ +import Darwin +import Foundation +import os + +private let logger = Logger(subsystem: appBundleID, category: "ZmxForeground") + +/// Translates a zmx-wrapped surface's session id into the pid of the *real* +/// foreground process — the shell or program the user is actually looking at. +/// +/// Why this is needed: a wrapped surface's shell runs under the zmx **daemon**, +/// a process tree completely detached from the `zmx attach` **client** that +/// libghostty reports as the surface's foreground pid. So `ProcessInspector`, +/// reading libghostty's pid, sees `zmx` for every pane — breaking tab names and +/// layout `run:` capture. The daemon side is reachable only by session id. +/// +/// Resolution: `zmx ls` maps `sessionID → daemon session-leader pid` (the +/// `login`/shell process the daemon spawned). The leader's controlling tty's +/// foreground process group (`tcgetpgrp`) is the true foreground — NOT the +/// deepest child (a language server spawned by an editor is a deeper leaf than +/// the editor the user sees). This mirrors how libghostty / tmux resolve the +/// foreground on the client side; we just do it against the daemon's pty. +/// +/// `zmx ls` is a subprocess, far too costly to run per-pane on the ~250ms +/// foreground poll, so the `sessionID → leaderPID` map is **cached** and +/// refreshed once per poll tick (one `zmx ls` total, not per pane). Daemon +/// leader pids are stable for a session's lifetime, so cache staleness between +/// ticks is harmless. +enum ZmxForegroundResolver { + /// Cached `macterm-` → daemon session-leader pid. Guarded by an unfair + /// lock; written by `refresh` (off the poll), read by `ProcessInspector` on + /// the main actor. + private static let cache = OSAllocatedUnfairLock<[String: pid_t]>(initialState: [:]) + + /// Replace the cached session→leader-pid map. Call once per foreground-poll + /// tick with a freshly parsed `zmx ls` listing. + static func updateCache(_ map: [String: pid_t]) { + cache.withLock { $0 = map } + } + + /// The pid of the real foreground process for `sessionID`, or nil when the + /// session isn't in the cache (not yet seen, or not zmx-wrapped). Resolves + /// the daemon leader's pty foreground process group. + static func foregroundPID(sessionID: String) -> pid_t? { + guard let leaderPID = cache.withLock({ $0[sessionID] }) else { return nil } + guard let tty = ttyPath(pid: leaderPID) else { return nil } + let fd = open(tty, O_RDONLY | O_NOCTTY | O_NONBLOCK) + guard fd >= 0 else { return nil } + defer { close(fd) } + let pgrp = tcgetpgrp(fd) + guard pgrp > 0 else { return nil } + return pgrp + } + + /// The controlling-tty device path of `pid` (e.g. `/dev/ttys083`), or nil. + /// `proc_pidinfo(PROC_PIDTBSDINFO)` gives the tty's device number; map it to + /// a path via `devname`. + private static func ttyPath(pid: pid_t) -> String? { + var info = proc_bsdinfo() + let size = Int32(MemoryLayout.size) + let ret = proc_pidinfo(pid, PROC_PIDTBSDINFO, 0, &info, size) + guard ret == size else { return nil } + let dev = info.e_tdev + guard dev != 0, dev != UInt32.max else { return nil } + guard let name = devname(dev_t(bitPattern: dev), S_IFCHR) else { return nil } + return "/dev/" + String(cString: name) + } +} + +extension ZmxForegroundResolver { + /// Parse a `zmx ls` listing into a `sessionName → leaderPID` map. Reuses the + /// existing `ZmxSessionListParser` for names and extracts the `pid=` field. + /// Only `macterm-` sessions with a readable pid are included. + static func parseLeaderPIDs(_ stdout: String) -> [String: pid_t] { + var map: [String: pid_t] = [:] + for line in stdout.split(whereSeparator: \.isNewline) { + var trimmed = Substring(line) + if trimmed.hasPrefix("→ ") { trimmed = trimmed.dropFirst(2) } + while trimmed.first?.isWhitespace == true { + trimmed = trimmed.dropFirst() + } + var name: String? + var pid: pid_t? + for field in trimmed.split(separator: "\t") { + guard let sep = field.firstIndex(of: "=") else { continue } + let key = field[field.startIndex ..< sep] + let value = field[field.index(after: sep)...] + if key == "name" { name = String(value) } + if key == "pid" { pid = Int32(value) } + } + if let name, name.hasPrefix(ZmxSessionID.prefix), let pid { map[name] = pid } + } + return map + } +} diff --git a/Macterm/Views/Terminal/GhosttyTerminalNSView.swift b/Macterm/Views/Terminal/GhosttyTerminalNSView.swift index cca7f40..49cb3d4 100644 --- a/Macterm/Views/Terminal/GhosttyTerminalNSView.swift +++ b/Macterm/Views/Terminal/GhosttyTerminalNSView.swift @@ -21,6 +21,11 @@ final class GhosttyTerminalNSView: NSView { private let shell: String? /// Extra environment variables for the spawned shell. private let env: [String: String]? + /// Stable per-pane session id used to wrap the surface's shell in zmx + /// (`zmx attach macterm-`), so the shell survives app quit and the same + /// id re-attaches to the live daemon on the next launch. Persisted with the + /// pane (unlike `id`, which is fresh per restore). + private let sessionID: UUID /// Heap buffers backing the `const char*` fields of the surface config — /// notably `initial_input`, which libghostty writes to the pty @@ -104,8 +109,15 @@ final class GhosttyTerminalNSView: NSView { private var keyTextAccumulator: [String] = [] private var currentKeyEvent: NSEvent? - init(workingDirectory: String, command: String? = nil, shell: String? = nil, env: [String: String]? = nil) { + init( + workingDirectory: String, + sessionID: UUID, + command: String? = nil, + shell: String? = nil, + env: [String: String]? = nil + ) { self.workingDirectory = workingDirectory + self.sessionID = sessionID self.command = command self.shell = shell self.env = env @@ -171,9 +183,25 @@ final class GhosttyTerminalNSView: NSView { config.command = cString(resolvedShell) } - // Declared `run` is typed into the shell verbatim, as if the user had - // entered it at the prompt. No shell-syntax handling: cwd is set above, - // not via an injected `cd`. + // Wrap the resolved shell in zmx for session persistence. The + // `command_wrapper` argv is prepended to ghostty's fully-resolved + // command (after login(1) + shell integration), so OSC 7 cwd / OSC 133 + // framing stay intact — the shell just runs as a child of + // `zmx attach macterm-`, which upserts the session and + // re-attaches the live daemon on relaunch. nil executable (zmx unbundled + // or over the socket-path budget) → no wrapper, a plain unpersisted + // shell. The argv array of `const char*` must outlive + // `ghostty_surface_new`, so its element buffers come from `cString` + // (freed in destroySurface) and the pointer array is held in a local + // that spans the call. + let wrapperArgv: [UnsafePointer?] = ZmxAttach.wrapperArgv( + executablePath: ZmxClient.live.executableURL()?.path, + sessionID: ZmxSessionID.make(surfaceID: sessionID) + ).map { cString($0) } + + // Declared `run` is typed into the (wrapped) shell verbatim, as if the + // user had entered it at the prompt. No shell-syntax handling: cwd is + // set above, not via an injected `cd`. if let command, !command.isEmpty { config.initial_input = cString(command + "\n") } @@ -187,13 +215,31 @@ final class GhosttyTerminalNSView: NSView { } } - if envVars.isEmpty { - surface = ghostty_surface_new(app, &config) - } else { - envVars.withUnsafeMutableBufferPointer { buf in - config.env_vars = buf.baseAddress - config.env_var_count = buf.count + /// Sets env on the config and spawns. Split out so the optional + /// command-wrapper buffer scope (below) wraps both env-present and + /// env-absent paths without four-way nesting. + func makeSurface() { + if envVars.isEmpty { surface = ghostty_surface_new(app, &config) + } else { + envVars.withUnsafeMutableBufferPointer { buf in + config.env_vars = buf.baseAddress + config.env_var_count = buf.count + surface = ghostty_surface_new(app, &config) + } + } + } + + // The command_wrapper argv (a `const char* const*`) needs the pointer + // array itself to have a stable base for the duration of the spawn; + // bind it just around `makeSurface`. Empty wrapper → no zmx (bypass). + if wrapperArgv.isEmpty { + makeSurface() + } else { + wrapperArgv.withUnsafeBufferPointer { buf in + config.command_wrapper = buf.baseAddress + config.command_wrapper_count = buf.count + makeSurface() } } guard let surface else { return } diff --git a/MactermTests/App/AppStateTests.swift b/MactermTests/App/AppStateTests.swift index 7a284a9..b917b4c 100644 --- a/MactermTests/App/AppStateTests.swift +++ b/MactermTests/App/AppStateTests.swift @@ -527,9 +527,14 @@ struct AppStateTests { } @Test - func layout_file_wins_over_restored_snapshot() throws { + func reopen_restores_snapshot_silently_and_ignores_layout() throws { + // Reopen is always silent: a restored session snapshot wins (so a layout + // project's panes reattach their live shells and its live layout is + // remembered), and the committed layout is NOT applied and NOT prompted + // for — even when it differs. The layout only seeds a genuine first open + // (no snapshot), covered by the next test. let dir = FileManager.default.temporaryDirectory - .appendingPathComponent("macterm-layoutwins-\(UUID().uuidString)") + .appendingPathComponent("macterm-reopen-\(UUID().uuidString)") try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) defer { try? FileManager.default.removeItem(at: dir) } let project = Project(name: "winner", path: dir.path, sortOrder: 0) @@ -540,7 +545,41 @@ struct AppStateTests { let snapshotWS = Workspace(projectID: project.id, projectPath: dir.path) store.save(WorkspaceSerializer.snapshot([project.id: snapshotWS])) - // And a layout file declaring a two-pane split. + // And a layout file declaring a different (two-pane) split. + writeLayout(""" + tabs: + - name: "Dev" + split: + direction: horizontal + first: { run: "npm run dev" } + second: {} + """, at: dir.path) + + let priorActive = Preferences.shared.activeProjectID + Preferences.shared.activeProjectID = project.id + defer { Preferences.shared.activeProjectID = priorActive } + + let state = makeAppState(store: store) + state.restoreSelection(projects: [project]) + + // Restored snapshot wins: one pane, layout NOT applied, NO prompt. + let ws = try #require(state.workspaces[project.id]) + #expect(ws.tabs[0].splitRoot.allPanes().count == 1) + #expect(ws.tabs[0].customTitle != "Dev") + #expect(state.pendingLayoutApply == nil) + } + + @Test + func layout_auto_applies_on_genuine_first_open_without_snapshot() throws { + // No snapshot at all → the layout still seeds the workspace on first open + // (pure-spawn, no prompt). This is the only path that auto-applies now. + let dir = FileManager.default.temporaryDirectory + .appendingPathComponent("macterm-firstopen-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: dir) } + let project = Project(name: "fresh", path: dir.path, sortOrder: 0) + let store = WorkspaceStore(fileURL: dir.appendingPathComponent("workspaces.json")) + writeLayout(""" tabs: - name: "Dev" @@ -550,20 +589,17 @@ struct AppStateTests { second: {} """, at: dir.path) - // Make it the active project so restore reopens it. let priorActive = Preferences.shared.activeProjectID Preferences.shared.activeProjectID = project.id defer { Preferences.shared.activeProjectID = priorActive } - // Restore: the layout file must win — the project's snapshot is skipped - // and its workspace is rebuilt from the layout (two panes, not one). let state = makeAppState(store: store) state.restoreSelection(projects: [project]) let ws = try #require(state.workspaces[project.id]) - #expect(ws.tabs.count == 1) #expect(ws.tabs[0].customTitle == "Dev") #expect(ws.tabs[0].splitRoot.allPanes().count == 2) + #expect(state.pendingLayoutApply == nil) // no prompt on first open } @Test diff --git a/MactermTests/Persistence/WorkspaceSerializerTests.swift b/MactermTests/Persistence/WorkspaceSerializerTests.swift index 69c3ece..7112330 100644 --- a/MactermTests/Persistence/WorkspaceSerializerTests.swift +++ b/MactermTests/Persistence/WorkspaceSerializerTests.swift @@ -223,6 +223,81 @@ struct WorkspaceSerializerTests { #expect(restored.first?.tabs.first?.executionState == .idle) } + @Test + func round_trip_preserves_session_id_for_reattach() { + // The session id is the link to the live zmx daemon — it MUST survive a + // snapshot/restore so the restored pane re-attaches instead of spawning + // a fresh shell. (The pane `id` is intentionally regenerated; sessionID + // is the stable one.) + let ws = Workspace(projectID: UUID(), projectPath: "/tmp") + guard case let .pane(original) = ws.tabs[0].splitRoot else { + Issue.record("expected leaf") + return + } + let originalSessionID = original.sessionID + let roundTripped = roundTrip([ws.projectID: ws]) + guard case let .pane(restored) = roundTripped[0].tabs[0].splitRoot else { + Issue.record("expected leaf") + return + } + #expect(restored.sessionID == originalSessionID) + #expect(restored.id != original.id) // pane id regenerates; sessionID doesn't + } + + @Test + func old_snapshot_without_session_id_gets_fresh_one() { + // A pre-persistence PaneSnapshot (no sessionID) must restore to a pane + // with a fresh session id rather than crashing or reusing a nil — it + // simply starts a new session. + let projectID = UUID() + let snap = WorkspaceSnapshot( + projectID: projectID, + activeTabID: nil, + tabs: [TabSnapshot( + id: UUID(), + customTitle: nil, + focusedPaneID: nil, + splitRoot: .pane(PaneSnapshot(id: UUID(), projectPath: "/tmp/old")) + )] + ) + let restored = WorkspaceSerializer.restore(from: [snap], validIDs: [projectID]) + guard case let .pane(p) = restored[0].tabs[0].splitRoot else { + Issue.record("expected leaf") + return + } + // A real UUID was minted (not nil/zero), and the saved path carried over. + #expect(p.projectPath == "/tmp/old") + #expect(p.sessionID != UUID(uuidString: "00000000-0000-0000-0000-000000000000")) + } + + @Test + func working_directory_is_used_over_project_path_on_restore() { + // When workingDirectory is present it wins (the live cwd the user had); + // projectPath is only the back-compat fallback. + let projectID = UUID() + let snap = WorkspaceSnapshot( + projectID: projectID, + activeTabID: nil, + tabs: [TabSnapshot( + id: UUID(), + customTitle: nil, + focusedPaneID: nil, + splitRoot: .pane(PaneSnapshot( + id: UUID(), + projectPath: "/tmp/project-root", + sessionID: UUID(), + workingDirectory: "/tmp/project-root/src/deep" + )) + )] + ) + let restored = WorkspaceSerializer.restore(from: [snap], validIDs: [projectID]) + guard case let .pane(p) = restored[0].tabs[0].splitRoot else { + Issue.record("expected leaf") + return + } + #expect(p.projectPath == "/tmp/project-root/src/deep") + } + @Test func load_from_missing_file_returns_empty() { let tmp = FileManager.default.temporaryDirectory diff --git a/MactermTests/System/ZmxClientTests.swift b/MactermTests/System/ZmxClientTests.swift new file mode 100644 index 0000000..76328f2 --- /dev/null +++ b/MactermTests/System/ZmxClientTests.swift @@ -0,0 +1,232 @@ +import Foundation +@testable import Macterm +import Testing + +/// Pure-logic coverage for the zmx persistence helpers: the `zmx ls` parser, the +/// socket-path budget probe, session-id formatting, and the launch resolver + +/// shell quoting. The subprocess runner and live daemon interaction need a real +/// zmx binary and are exercised by the manual end-to-end run instead. +struct ZmxSessionListParserTests { + @Test + func parsesHealthySessionsWithClientCounts() { + let stdout = """ + → name=macterm-abc\tclients=1\tcreated=123 + name=macterm-def\tclients=0\tcreated=456 + """ + let entries = ZmxSessionListParser.parse(stdout) + #expect(entries == [ + .init(name: "macterm-abc", clients: 1), + .init(name: "macterm-def", clients: 0), + ]) + } + + @Test + func absentClientCountIsUnknownNotZero() { + // An err/status line carries no `clients=`; that must decode to nil + // (unknown) so the reaper spares it, not 0 (which it would reap). + let entries = ZmxSessionListParser.parse(" name=macterm-xyz\terr=unreachable") + #expect(entries == [.init(name: "macterm-xyz", clients: nil)]) + } + + @Test + func ignoresForeignSessionsWithoutPrefix() { + // Only `macterm-` sessions are ours; a co-resident tmux/other-app zmx + // session must be skipped entirely. + let entries = ZmxSessionListParser.parse("name=other-session\tclients=2") + #expect(entries.isEmpty) + } + + @Test + func emptyListingYieldsNoEntries() { + #expect(ZmxSessionListParser.parse("").isEmpty) + #expect(ZmxSessionListParser.parse("\n \n").isEmpty) + } +} + +struct ZmxSocketBudgetTests { + @Test + func defaultTmpDirIsUnderBudget() { + // `/tmp/zmx-` (~13 chars) + `/macterm-` (45) is well under + // the 102B budget; probe must pass (nil). + #expect(ZmxSocketBudget.probe(env: [:]) == nil) + } + + @Test + func explicitZmxDirWins() { + #expect(ZmxSocketBudget.socketDir(env: ["ZMX_DIR": "/custom/dir"]) == "/custom/dir") + } + + @Test + func trailingSlashIsTrimmedForDerivedDirs() { + // The derived (non-ZMX_DIR) paths must match zmx's own resolver, which + // trims a trailing slash before appending — otherwise kill and the + // wrapped shell land on different socket dirs. + #expect(ZmxSocketBudget.socketDir(env: ["XDG_RUNTIME_DIR": "/run/user/501/"]) == "/run/user/501/zmx") + #expect(ZmxSocketBudget.socketDir(env: ["TMPDIR": "/var/tmp/"]) == "/var/tmp/zmx-\(getuid())") + } + + @Test + func overlongDirExceedsBudget() { + let longDir = "/" + String(repeating: "x", count: 100) + let reason = ZmxSocketBudget.probe(env: ["ZMX_DIR": longDir]) + #expect(reason != nil) + } +} + +struct ZmxSessionIDTests { + @Test + func formatsLowercasedWithPrefix() throws { + let id = try #require(UUID(uuidString: "AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE")) + #expect(ZmxSessionID.make(surfaceID: id) == "macterm-aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee") + } +} + +struct ZmxReaperTests { + private func entry(_ name: String, clients: Int?) -> ZmxSessionListParser.Entry { + .init(name: name, clients: clients) + } + + @Test + func reaps_unclaimed_detached_macterm_sessions() { + let entries = [ + entry("macterm-a", clients: 0), // orphan → reap + entry("macterm-b", clients: 0), // claimed → spare + ] + let orphans = ZmxReaper.orphans(in: entries, known: ["macterm-b"]) + #expect(orphans == ["macterm-a"]) + } + + @Test + func spares_attached_sessions_even_if_unclaimed() { + // clients > 0 means a live client (another instance / manual attach) — + // never reap regardless of the known set. + let orphans = ZmxReaper.orphans(in: [entry("macterm-x", clients: 1)], known: []) + #expect(orphans.isEmpty) + } + + @Test + func spares_unknown_client_count() { + // nil clients (err/status line) = unknown → spare. + let orphans = ZmxReaper.orphans(in: [entry("macterm-x", clients: nil)], known: []) + #expect(orphans.isEmpty) + } + + @Test + func never_touches_foreign_prefix_sessions() { + // A co-resident Supacode session must never be reaped by Macterm. + let entries = [ + entry("supa-abc", clients: 0), + entry("macterm-y", clients: 0), + ] + let orphans = ZmxReaper.orphans(in: entries, known: []) + #expect(orphans == ["macterm-y"]) + } + + @Test + func empty_listing_reaps_nothing() { + #expect(ZmxReaper.orphans(in: [], known: ["macterm-a"]).isEmpty) + } +} + +struct ZmxForegroundResolverParseTests { + @Test + func parsesSessionNameToLeaderPID() { + let stdout = """ + name=macterm-abc\tpid=46878\tclients=1\tcreated=123 + name=macterm-def\tpid=47353\tclients=0\tcreated=456 + """ + let map = ZmxForegroundResolver.parseLeaderPIDs(stdout) + #expect(map == ["macterm-abc": 46878, "macterm-def": 47353]) + } + + @Test + func skipsForeignPrefixAndPidlessLines() { + let stdout = """ + name=supa-xyz\tpid=999\tclients=0 + name=macterm-nopid\tclients=0 + name=macterm-ok\tpid=42\tclients=1 + """ + let map = ZmxForegroundResolver.parseLeaderPIDs(stdout) + #expect(map == ["macterm-ok": 42]) + } +} + +struct ZmxAttachTests { + @Test + func wrapperArgvWrapsTheShellWhenExecutablePresent() { + let argv = ZmxAttach.wrapperArgv(executablePath: "/path/to/zmx", sessionID: "macterm-1") + #expect(argv == ["/path/to/zmx", "attach", "macterm-1"]) + } + + @Test + func noExecutableYieldsEmptyArgv() { + // nil executable (zmx unbundled or over budget) → no wrapper, plain shell. + #expect(ZmxAttach.wrapperArgv(executablePath: nil, sessionID: "macterm-1").isEmpty) + } +} + +/// Drives the async `reapOrphans` over an injected `ZmxClient` (no real +/// subprocess), so the known-set mapping, the nil-probe short-circuit, and the +/// kill fan-out are covered end to end — not just the pure `ZmxReaper.orphans`. +struct ZmxReapOrphansDriverTests { + /// A client whose `ls` returns `entries` and that records every killed id. + private func recordingClient( + entries: [ZmxSessionListParser.Entry]?, + killed: LockedBox<[String]> + ) -> ZmxClient { + ZmxClient( + executableURL: { URL(fileURLWithPath: "/fake/zmx") }, + isBundled: { true }, + killSession: { id in killed.mutate { $0.append(id) } }, + listSessionsWithClients: { entries }, + sessionLeaderPIDs: { [:] } + ) + } + + @Test + func reapsOnlyUnclaimedDetachedSessions() async { + let killed = LockedBox<[String]>([]) + let knownID = UUID() + let client = recordingClient( + entries: [ + .init(name: ZmxSessionID.make(surfaceID: knownID), clients: 0), // claimed → spare + .init(name: "macterm-orphan", clients: 0), // unclaimed → reap + .init(name: "macterm-attached", clients: 1), // attached → spare + .init(name: "supa-foreign", clients: 0), // foreign prefix → spare + ], + killed: killed + ) + await client.reapOrphans(knownSurfaceIDs: [knownID]) + #expect(killed.value == ["macterm-orphan"]) + } + + @Test + func failedProbeReapsNothing() async { + let killed = LockedBox<[String]>([]) + // nil listing = probe failed/unavailable → never reap. + let client = recordingClient(entries: nil, killed: killed) + await client.reapOrphans(knownSurfaceIDs: []) + #expect(killed.value.isEmpty) + } +} + +/// Minimal thread-safe box so the injected `@Sendable` killSession closure can +/// record across the reaper's concurrent task group. +private final class LockedBox: @unchecked Sendable { + private let lock = NSLock() + private var stored: T + init(_ value: T) { + stored = value + } + + var value: T { lock.lock() + defer { lock.unlock() } + return stored + } + + func mutate(_ body: (inout T) -> Void) { + lock.lock() + defer { lock.unlock() } + body(&stored) + } +} diff --git a/README.md b/README.md index 7de5d72..2c05ad7 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ ## Features - **Vertical Project Sidebar**: Native macOS sidebar for organizing projects and tabs vertically. -- **Persistent Multiplexing**: Projects, tabs, and panes are saved and restored automatically on relaunch. +- **Session Persistence**: Quit anytime — your shells keep running in the background and reattach on relaunch, processes and scrollback intact. Powered by [zmx](https://github.com/neurosnap/zmx), so your whole workspace (projects, tabs, splits, and the programs inside them) comes back exactly as you left it. No tmux required. - **Declarative Layouts**: Define a `.macterm/layout.yaml` describing each project's tabs, splits, and the process every pane runs; apply or save it from the command palette. - **Ghostty Config Compatibility**: Macterm reads your existing Ghostty config. Theme, font, notification, keybinds — all of it just works. - **Command Palette**: Versatile command palette to interact with multiplexing and manage projects diff --git a/project.yml b/project.yml index ac6391b..77e7a0b 100644 --- a/project.yml +++ b/project.yml @@ -50,6 +50,7 @@ targets: - "Macterm.entitlements" - "Resources/ghostty" - "Resources/terminfo" + - "Resources/zmx" - "AppIcon.icon" # Ghostty resources (themes + shell-integration), downloaded by setup.sh. # Folder reference (not the default "group") because the contents are @@ -132,6 +133,14 @@ targets: - sdk: MetalKit.framework - sdk: QuartzCore.framework - sdk: UserNotifications.framework + postCompileScripts: + # Copy the prebuilt zmx binary (downloaded by setup.sh from the thdxg/zmx + # release, like GhosttyKit) into the app bundle at + # Contents/Resources/zmx/zmx — ZmxClient resolves it via Bundle.main. + # Runs after compile so the bundle's Resources dir exists. + - script: "${SRCROOT}/scripts/embed-zmx.sh" + name: "Embed zmx" + basedOnDependencyAnalysis: false MactermTests: type: bundle.unit-test diff --git a/scripts/embed-zmx.sh b/scripts/embed-zmx.sh new file mode 100755 index 0000000..220f725 --- /dev/null +++ b/scripts/embed-zmx.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +# Embed the built zmx binary into the app bundle at Contents/Resources/zmx/zmx. +# +# Runs as a project.yml post-compile build phase. ZmxClient resolves the binary +# via Bundle.main.url(forResource:"zmx", withExtension:nil, subdirectory:"zmx"), +# matching this `zmx/zmx` layout. Kept in its own `zmx/` subdir (not loose in +# Resources/) so the lookup is unambiguous and mirrors Supacode's bundle layout. +set -euo pipefail + +# Prefer Xcode's SRCROOT (build-phase env); fall back to the repo root so the +# script is runnable standalone. +srcroot="${SRCROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}" +# The prebuilt binary downloaded by setup.sh from the thdxg/zmx release. +zmx_source="${srcroot}/Macterm/Resources/zmx/zmx" + +if [[ -z "${TARGET_BUILD_DIR:-}" || -z "${UNLOCALIZED_RESOURCES_FOLDER_PATH:-}" ]]; then + echo "error: embed-zmx.sh must run inside an Xcode build phase (missing TARGET_BUILD_DIR)" >&2 + exit 1 +fi + +if [[ ! -x "${zmx_source}" ]]; then + echo "error: ${zmx_source} not found. Run 'mise run setup' to download it." >&2 + exit 1 +fi + +destination_dir="${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/zmx" +mkdir -p "${destination_dir}" +# -c clones (copy-on-write) when possible; -p preserves the executable bit. +cp -cp "${zmx_source}" "${destination_dir}/zmx" 2>/dev/null || cp -p "${zmx_source}" "${destination_dir}/zmx" +chmod +x "${destination_dir}/zmx" +echo "✓ embedded zmx → ${destination_dir}/zmx" diff --git a/scripts/setup.sh b/scripts/setup.sh index 957d2fe..e3d9210 100755 --- a/scripts/setup.sh +++ b/scripts/setup.sh @@ -2,6 +2,7 @@ set -euo pipefail FORK_REPO="thdxg/ghostty" +ZMX_REPO="thdxg/zmx" XCFRAMEWORK_DIR="GhosttyKit.xcframework" # Marker for the downloaded upstream resources. The tarball mirrors a real # Ghostty.app Resources layout: ghostty/{themes,shell-integration} plus a @@ -9,14 +10,20 @@ XCFRAMEWORK_DIR="GhosttyKit.xcframework" # presence signals the download ran. Keyed on terminfo/ so checkouts predating # the terminfo bundling (or the flat-layout interim) re-download it. RESOURCES_MARKER="Macterm/Resources/terminfo" +# Prebuilt zmx session multiplexer (session persistence). Built by thdxg/zmx CI +# and downloaded here, mirroring GhosttyKit — never compiled locally (zig). +# Embedded into the bundle at Contents/Resources/zmx/zmx by embed-zmx.sh. +ZMX_BIN="Macterm/Resources/zmx/zmx" need_xcframework=true need_resources=true +need_zmx=true [[ -d "$XCFRAMEWORK_DIR" ]] && need_xcframework=false [[ -d "$RESOURCES_MARKER" ]] && need_resources=false +[[ -x "$ZMX_BIN" ]] && need_zmx=false -if ! $need_xcframework && ! $need_resources; then - echo "GhosttyKit and resources already present" +if ! $need_xcframework && ! $need_resources && ! $need_zmx; then + echo "GhosttyKit, resources, and zmx already present" exit 0 fi @@ -48,3 +55,22 @@ if $need_resources; then tar xzf ghostty-resources.tar.gz -C Macterm/Resources rm ghostty-resources.tar.gz fi + +if $need_zmx; then + # Prebuilt universal (arm64+x86_64) zmx binary from the thdxg/zmx release. + # Universal so it execs on both Apple Silicon and Intel, matching Macterm's + # universal app build. Shipped as a tarball (preserves the executable bit + # through the GitHub asset round-trip) holding a single `zmx` binary; + # extracted to Macterm/Resources/zmx/zmx (gitignored). + ZMX_TAG=$(gh release list --repo "$ZMX_REPO" --limit 1 --json tagName -q ".[0].tagName") + if [[ -z "$ZMX_TAG" ]]; then + echo "Error: No zmx releases found in $ZMX_REPO" >&2 + exit 1 + fi + gh release download "$ZMX_TAG" --pattern "zmx-universal-macos.tar.gz" --repo "$ZMX_REPO" + rm -rf Macterm/Resources/zmx + mkdir -p Macterm/Resources/zmx + tar xzf zmx-universal-macos.tar.gz -C Macterm/Resources/zmx + chmod +x Macterm/Resources/zmx/zmx + rm zmx-universal-macos.tar.gz +fi diff --git a/website/public/index.html b/website/public/index.html index 7b16668..3c40efe 100644 --- a/website/public/index.html +++ b/website/public/index.html @@ -4,13 +4,13 @@ Macterm — the terminal that remembers - + - + @@ -52,7 +52,7 @@

The terminal
that remembers

-

Macterm is a macOS terminal for keeping your projects organized and right where you left them.

+

A macOS terminal that keeps your projects organized and your shells running. Quit anytime; everything reattaches right where you left it.

@@ -107,9 +107,10 @@

Vertical project sidebar

vertically where there's actually room to read them.

  • -

    Persistent multiplexing

    -

    Projects, tabs, and panes are saved and restored on relaunch. Close - the app and your whole workspace comes back.

    +

    Session persistence

    +

    Quit anytime — your shells keep running in the background and + reattach on relaunch, processes and scrollback intact. Projects, tabs, + and splits come back exactly as you left them. No tmux required.

  • Command palette