Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2,975 changes: 2,821 additions & 154 deletions Localizable.xcstrings

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,8 +123,8 @@ brew install --cask rootshell
- **Multipath TCP** - MPTCP over Apple Network.framework maintains subflows on WiFi and cellular simultaneously for near-instant handover (requires Linux 5.6+ on server)
- **Native SCP & SFTP** - Built-in `scp` and interactive `sftp` client with tab completion, glob patterns, and real-time progress
- **Background SSH Tunnels** - Maintain port forwards without a terminal UI with auto-start on launch and byte transfer statistics
- **Auto-start tmux** - Automatically attach to or create tmux sessions on connect
- **tmux Session Discovery** - After connecting, lists active tmux sessions with window count and live terminal preview
- **Auto-start Multiplexer** - Automatically attach to or create a tmux, herdr or zmx session on connect
- **Multiplexer Session Discovery** - After connecting, lists active tmux, zellij, herdr and zmx sessions with per-multiplexer detail and a live terminal preview
- **Tailscale Integration** - Device discovery and SSH to your tailnet with no-auth support
- **Host Shorthand (HSS)** - Pattern-based hostname expansion with YAML configuration
- **Connection Health** - Real-time RTT and packet loss tracking with time series chart and negotiated cryptographic algorithm details
Expand Down
15 changes: 15 additions & 0 deletions rootshell-helper/Sources/EnvironmentBuilder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,16 @@ class EnvironmentBuilder {
LocaleHelper.posixLocale
}

/// The launchd-provided per-user temp directory, or nil on failure.
private static func darwinUserTempDir() -> String? {
let size = confstr(_CS_DARWIN_USER_TEMP_DIR, nil, 0)
guard size > 0 else { return nil }
var buffer = [Int8](repeating: 0, count: size)
let result = confstr(_CS_DARWIN_USER_TEMP_DIR, &buffer, size)
guard result > 0, result <= size else { return nil }
return String(cString: buffer)
}

/// Convenience initializer that auto-detects bundle paths
convenience init(bundle: Bundle = .main, version: String = "1.0.0") {
var config = Config()
Expand Down Expand Up @@ -117,6 +127,11 @@ class EnvironmentBuilder {
// Minimal default PATH - login shell will extend this
env["PATH"] = "/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin"

// Multiplexer socket paths rely on launchd's per-user temp directory.
if let tmpDir = Self.darwinUserTempDir() {
env["TMPDIR"] = tmpDir
}

// TERM: an explicit value from the app's settings wins outright — the
// user picked it, and honoring it is the whole point of the setting.
if let termType = config.termType, !termType.isEmpty {
Expand Down
25 changes: 25 additions & 0 deletions rootshell-helper/Sources/ProcessExecutor.m
Original file line number Diff line number Diff line change
Expand Up @@ -324,6 +324,25 @@ + (NSString *)defaultShell {
return @"/bin/sh";
}

/// The launchd-provided per-user temp directory, or nil on failure.
+ (nullable NSString *)darwinUserTempDir {
size_t size = confstr(_CS_DARWIN_USER_TEMP_DIR, NULL, 0);
if (size == 0) {
return nil;
}
char *buffer = malloc(size);
if (!buffer) {
return nil;
}
size_t result = confstr(_CS_DARWIN_USER_TEMP_DIR, buffer, size);
NSString *value = nil;
if (result > 0 && result <= size) {
value = [NSString stringWithUTF8String:buffer];
}
free(buffer);
return value;
}

/// Build environment array with minimal required variables plus custom ones
+ (char **)buildEnvironmentWithCustom:(nullable NSDictionary<NSString *, NSString *> *)customEnv {
NSMutableDictionary *env = [NSMutableDictionary dictionary];
Expand All @@ -346,6 +365,12 @@ + (char **)buildEnvironmentWithCustom:(nullable NSDictionary<NSString *, NSStrin
}
}

// Set before customEnv so an explicit value still wins.
NSString *tmpDir = [self darwinUserTempDir];
if (tmpDir) {
env[@"TMPDIR"] = tmpDir;
}

// Merge custom environment (overrides defaults)
if (customEnv) {
[env addEntriesFromDictionary:customEnv];
Expand Down
9 changes: 8 additions & 1 deletion rootshell/Core/CloudKit/CloudKitSyncable.swift
Original file line number Diff line number Diff line change
Expand Up @@ -159,8 +159,14 @@ extension SSHConnectionHistoryEntry: CloudKitSyncable {
// device which members the writer knew. A nil member means the user
// cleared it only when the stamped version is at least the version that
// introduced that member.
// zmxAutoEnable rides the envelope rather than getting its own record
// field like `herdrAutoEnable` above. That inconsistency is deliberate:
// a new top-level field costs a production CloudKit schema deploy, which
// an outside contributor cannot perform, and the envelope exists
// precisely so later fields do not.
let envelope = HistoryExtensionPayload(terminalType: terminalType,
multiplexerSessionName: multiplexerSessionName)
multiplexerSessionName: multiplexerSessionName,
zmxAutoEnable: zmxAutoEnable)
if let envelopeData = try? JSONEncoder().encode(envelope) {
record["extensionData"] = envelopeData
} else {
Expand Down Expand Up @@ -280,6 +286,7 @@ extension SSHConnectionHistoryEntry: CloudKitSyncable {
tmuxAutoEnable: tmuxAutoEnable,
tmuxAutoMode: tmuxAutoMode,
herdrAutoEnable: herdrAutoEnable,
zmxAutoEnable: extensionPayload?.zmxAutoEnable,
launchCommand: launchCommand,
launchCommandMode: launchCommandMode,
terminalType: extensionPayload?.terminalType,
Expand Down
3 changes: 3 additions & 0 deletions rootshell/Core/Persistence/SerializableConnectionConfig.swift
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,7 @@ nonisolated struct SerializableConnectionConfig: Codable, Equatable, Sendable {
/// herdr auto-attach. Optional for backward compat — older serialized
/// sessions decode as nil and restore as disabled.
let herdrAutoEnable: Bool?
let zmxAutoEnable: Bool?
let launchCommand: String?
let launchCommandMode: SSHConfig.LaunchCommandMode?
/// Per-connection TERM override. Optional for backward compat — older
Expand Down Expand Up @@ -194,6 +195,7 @@ nonisolated struct SerializableConnectionConfig: Codable, Equatable, Sendable {
self.tmuxAutoEnable = config.tmuxAutoEnable
self.tmuxAutoMode = config.tmuxAutoMode
self.herdrAutoEnable = config.herdrAutoEnable
self.zmxAutoEnable = config.zmxAutoEnable
self.launchCommand = config.launchCommand
self.launchCommandMode = config.launchCommandMode
self.terminalType = config.terminalType
Expand Down Expand Up @@ -275,6 +277,7 @@ nonisolated struct SerializableConnectionConfig: Codable, Equatable, Sendable {
config.tmuxAutoEnable = tmuxAutoEnable ?? false
config.tmuxAutoMode = tmuxAutoMode ?? .regular
config.herdrAutoEnable = herdrAutoEnable ?? false
config.zmxAutoEnable = zmxAutoEnable ?? false
config.launchCommand = launchCommand
config.launchCommandMode = launchCommandMode ?? .afterConnect
config.terminalType = terminalType
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ final class TerminalConnectionHistoryRecorder {
tmuxAutoEnable: sshConfig.tmuxAutoEnable,
tmuxAutoMode: sshConfig.tmuxAutoMode,
herdrAutoEnable: sshConfig.herdrAutoEnable,
zmxAutoEnable: sshConfig.zmxAutoEnable,
launchCommand: sshConfig.launchCommand,
launchCommandMode: sshConfig.launchCommandMode,
terminalType: sshConfig.terminalType,
Expand Down
27 changes: 27 additions & 0 deletions rootshell/Core/Terminal/WorkingDirectoryURI.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
//
// WorkingDirectoryURI.swift
// rootshell
//
// Decoding for the working directory a terminal reports over OSC 7.
//

import Foundation

/// Turns an OSC 7 working-directory value into a plain filesystem path.
///
/// OSC 7 carries `file://<host><percent-encoded-path>`. Ghostty hands the
/// sequence's payload through `GHOSTTY_ACTION_PWD` as it arrived, so a directory
/// with a space in it would otherwise remain percent-encoded.
///
/// A value that is not a `file://` URI is returned untouched. That case is not
/// an error: a bare path may legitimately contain spaces or a literal `%`, and
/// running one through a percent-decoder corrupts it.
nonisolated enum WorkingDirectoryURI {
static func path(_ value: String) -> String {
guard value.hasPrefix("file://") else { return value }
guard let components = URLComponents(string: value), !components.path.isEmpty else {
return value
}
return components.path
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -207,7 +207,7 @@ Use this after editing the file from a local shell or through a symlink in your
let examplesHeader = String(localized: "Examples:", comment: "Command help: examples section header")

let helpText = """
usage: ssh [-p port] [-l user] [-i identity] [-J jumphost] [-A|-AA] [--tmux|--herdr] [--path] [-o option] destination [command]
usage: ssh [-p port] [-l user] [-i identity] [-J jumphost] [-A|-AA] [--tmux|--herdr|--zmx] [--path] [-o option] destination [command]

\(optionsHeader)
-p port \(String(localized: "Connect to this port (default: 22)", comment: "SSH help: -p option"))
Expand All @@ -218,6 +218,7 @@ usage: ssh [-p port] [-l user] [-i identity] [-J jumphost] [-A|-AA] [--tmux|--he
-AA \(String(localized: "Enable SSH agent forwarding with auto-approval", comment: "SSH help: -AA option"))
--tmux \(String(localized: "Auto-start tmux on the remote host", comment: "SSH help: --tmux option"))
--herdr \(String(localized: "Auto-start herdr on the remote host", comment: "SSH help: --herdr option"))
--zmx \(String(localized: "Auto-start zmx on the remote host", comment: "SSH help: --zmx option"))
--path \(String(localized: "Prepend Rootshell's PATH wrapper for remote exec commands", comment: "SSH help: --path option"))
-o option \(String(localized: "Set SSH option (Port, User, ProxyJump, IdentityFile)", comment: "SSH help: -o option"))

Expand Down Expand Up @@ -250,6 +251,7 @@ usage: ssh [-p port] [-l user] [-i identity] [-J jumphost] [-A|-AA] [--tmux|--he
ssh -AA user@host
ssh --tmux user@host
ssh --herdr user@host
ssh --zmx user@host
ssh --path user@host wish serve
ssh user@host ls -la /tmp
ssh user@host "echo hello"
Expand Down Expand Up @@ -399,8 +401,8 @@ usage: sftp [-P port] [-i identity] [-J jump_host] [-o option] [user@]host
let examplesHeader = String(localized: "Examples:", comment: "Command help: examples section header")

let helpText = """
usage: roam [-p port] [-l user] [-i identity] [-J jumphost] [-A|-AA] [--tmux|--herdr] [--predict mode] [--predict-overwrite] destination
mosh [-p port] [-l user] [-i identity] [-J jumphost] [-A|-AA] [--tmux|--herdr] [--predict mode] [--predict-overwrite] destination
usage: roam [-p port] [-l user] [-i identity] [-J jumphost] [-A|-AA] [--tmux|--herdr|--zmx] [--predict mode] [--predict-overwrite] destination
mosh [-p port] [-l user] [-i identity] [-J jumphost] [-A|-AA] [--tmux|--herdr|--zmx] [--predict mode] [--predict-overwrite] destination

\(optionsHeader)
-p port \(String(localized: "Connect to this port (default: 22)", comment: "Mosh help: -p option"))
Expand All @@ -411,6 +413,7 @@ usage: roam [-p port] [-l user] [-i identity] [-J jumphost] [-A|-AA] [--tmux|--h
-AA \(String(localized: "Enable SSH agent forwarding with auto-approval", comment: "Mosh help: -AA option"))
--tmux \(String(localized: "Auto-start tmux on the remote host", comment: "Mosh help: --tmux option"))
--herdr \(String(localized: "Auto-start herdr on the remote host", comment: "Mosh help: --herdr option"))
--zmx \(String(localized: "Auto-start zmx on the remote host", comment: "Mosh help: --zmx option"))
--predict mode \(String(localized: "Prediction mode: always, adaptive, never (default: always)", comment: "Mosh help: --predict option"))
--predict-overwrite \(String(localized: "Replace existing cells when predicting input", comment: "Mosh help: --predict-overwrite option"))
--no-predict-overwrite \(String(localized: "Use inserting predictions for this session", comment: "Mosh help: --no-predict-overwrite option"))
Expand Down Expand Up @@ -442,6 +445,7 @@ usage: roam [-p port] [-l user] [-i identity] [-J jumphost] [-A|-AA] [--tmux|--h
roam -J bastion user@internal
mosh --tmux user@host
mosh --herdr user@host
mosh --zmx user@host

"""
Task { @MainActor [weak self] in
Expand Down Expand Up @@ -544,8 +548,8 @@ usage: ssh-copy-id [-fnp] [-i identity] [-t target] [-o option] [user@]hostname
let examplesHeader = String(localized: "Examples:", comment: "Command help: examples section header")

let helpText = """
usage: tssh [-p port] [-l user] [-i identity] [-J jumphost] [-A|-AA] [--tmux|--herdr] [--quic|--kcp] destination
trzsz [-p port] [-l user] [-i identity] [-J jumphost] [-A|-AA] [--tmux|--herdr] [--quic|--kcp] destination
usage: tssh [-p port] [-l user] [-i identity] [-J jumphost] [-A|-AA] [--tmux|--herdr|--zmx] [--quic|--kcp] destination
trzsz [-p port] [-l user] [-i identity] [-J jumphost] [-A|-AA] [--tmux|--herdr|--zmx] [--quic|--kcp] destination

\(optionsHeader)
-p port \(String(localized: "Connect to this port (default: 22)", comment: "Trzsz help: -p option"))
Expand All @@ -556,6 +560,7 @@ usage: tssh [-p port] [-l user] [-i identity] [-J jumphost] [-A|-AA] [--tmux|--h
-AA \(String(localized: "Enable SSH agent forwarding with auto-approval", comment: "Trzsz help: -AA option"))
--tmux \(String(localized: "Auto-start tmux on the remote host", comment: "Trzsz help: --tmux option"))
--herdr \(String(localized: "Auto-start herdr on the remote host", comment: "Trzsz help: --herdr option"))
--zmx \(String(localized: "Auto-start zmx on the remote host", comment: "Trzsz help: --zmx option"))
--quic \(String(localized: "Force QUIC transport mode", comment: "Trzsz help: --quic option"))
--kcp \(String(localized: "Force KCP transport mode", comment: "Trzsz help: --kcp option"))
--server path \(String(localized: "Path to tssh server on remote", comment: "Trzsz help: --server option"))
Expand Down Expand Up @@ -586,6 +591,7 @@ usage: tssh [-p port] [-l user] [-i identity] [-J jumphost] [-A|-AA] [--tmux|--h
trzsz -J bastion user@internal
tssh --tmux user@host
tssh --herdr user@host
tssh --zmx user@host

"""
Task { @MainActor [weak self] in
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ extension LocalShellSession {
tmuxAutoEnable: config.tmuxAutoEnable,
tmuxAutoMode: config.tmuxAutoMode,
herdrAutoEnable: config.herdrAutoEnable,
zmxAutoEnable: config.zmxAutoEnable,
remoteCommand: config.remoteCommand,
remoteCommandPolicy: config.remoteCommandPolicy,
multiplexerSessionName: config.multiplexerSessionName
Expand Down Expand Up @@ -279,6 +280,7 @@ extension LocalShellSession {
tmuxAutoEnable: config.tmuxAutoEnable,
tmuxAutoMode: config.tmuxAutoMode,
herdrAutoEnable: config.herdrAutoEnable,
zmxAutoEnable: config.zmxAutoEnable,
launchCommand: config.launchCommand,
launchCommandMode: config.launchCommandMode,
terminalType: config.terminalType,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ nonisolated struct HerdrExposeAdapter: MultiplexerExposeAdapter {
return MuxScript.wrap(body, nonce: nonce)
}

func parseTick(output: String, nonce: String) -> MuxTickResult? {
func parseTick(output: String, session _: String?, nonce: String) -> MuxTickResult? {
let sections = MuxScript.sections(of: output, nonce: nonce)
guard sections.found, !sections.unsupported else { return nil }
guard let root = MuxScript.json(sections.topology) as? [String: Any] else { return nil }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,6 @@
// MultiplexerExposeAdapter.swift
// rootshell
//
// One adapter per raw multiplexer turns "what do I need this tick" into a
// single shell script and its output back into a MuxTickResult. Scripts run
// through RemoteExecProbe on the pane's own connection, so they must be
// self-contained, nonce-framed, and exit 0 (Citadel discards the output of
// a failing command).
//

import Foundation

Expand All @@ -19,6 +13,60 @@ nonisolated struct MuxTickRequest: Sendable {
var knownRevisions: [String: String] = [:]
}

/// Checks whether screen state is compatible with a multiplexer attachment.
nonisolated enum MuxScreenGate {
/// Passthrough multiplexers are admitted on either screen.
static func admits(ownsAlternateScreen: Bool, alternateScreenActive: Bool) -> Bool {
!ownsAlternateScreen || alternateScreenActive
}
}

/// Checks whether a detachable multiplexer has a shell underneath it.
nonisolated enum MuxDetachGate {
static func hasFallbackShell(
hasRemoteCommand: Bool,
hasInitialCommandLaunch: Bool,
tmuxAutoEnable: Bool,
herdrAutoEnable: Bool,
zmxAutoEnable: Bool
) -> Bool {
!(hasRemoteCommand || hasInitialCommandLaunch || tmuxAutoEnable || herdrAutoEnable || zmxAutoEnable)
}
}

/// Classifies a detach-and-reattach attempt from its client-count census.
nonisolated enum MuxZmxDetachTransfer {
enum Result: Equatable {
case confirmed
case unchanged
case detachedOnly
case ambiguous
}

static func classify(
sourceBefore: Int,
targetBefore: Int,
sourceAfter: Int,
targetAfter: Int
) -> Result {
if sourceAfter == sourceBefore - 1,
targetAfter == targetBefore + 1 {
return .confirmed
}
if sourceAfter == sourceBefore,
targetAfter == targetBefore {
return .unchanged
}
// A single-client source proves this pane reached its fallback shell.
if sourceBefore == 1,
sourceAfter == 0,
targetAfter == targetBefore {
return .detachedOnly
}
return .ambiguous
}
}

nonisolated protocol MultiplexerExposeAdapter: Sendable {
var type: MultiplexerType { get }

Expand All @@ -28,10 +76,25 @@ nonisolated protocol MultiplexerExposeAdapter: Sendable {

func tickScript(session: String?, request: MuxTickRequest, nonce: String) -> String

/// nil when the multiplexer is unusable here (too old, no session).
func parseTick(output: String, nonce: String) -> MuxTickResult?
/// nil when the multiplexer is unavailable or has no usable session.
func parseTick(output: String, session: String?, nonce: String) -> MuxTickResult?

/// Whether switching from `session` to `tabID` is safe.
func canFocus(session: String?, tabID: String) -> Bool

func focusScript(session: String?, tabID: String) -> String

/// Whether the focus command's output confirms the switch.
func parseFocusResult(output: String, session: String?, tabID: String) -> Bool

/// Floor for the feed's tick pacing.
var minInterval: TimeInterval { get }
}

nonisolated extension MultiplexerExposeAdapter {
var minInterval: TimeInterval { 0 }
func parseFocusResult(output: String, session: String?, tabID: String) -> Bool { true }
func canFocus(session: String?, tabID: String) -> Bool { true }
}

/// Marker framing and shell quoting shared by the adapters.
Expand Down
Loading