Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
71c4e4c
Add isolated extension workers for native shell panes
demrom Oct 17, 2026
751c239
Merge master into feature/extension-shell-workers
demrom Oct 18, 2026
1b8fe97
Merge current master into extension shell workers
demrom Oct 20, 2026
38d15ce
Export worker pane callbacks in Release builds
demrom Oct 20, 2026
6ad68a7
Avoid status RPCs while shell workers are running
demrom Oct 20, 2026
1ac4bdb
Cap concurrent shell workers at the physical core count, raisable in …
demrom Oct 20, 2026
e1b2de4
Merge current master into extension shell workers
demrom Oct 23, 2026
12c0849
Add ashell_pane_spawn_ex: separate stderr and an optional terminal fo…
demrom Oct 20, 2026
b48641d
Broker nested shell spawns through the host worker pool
demrom Oct 23, 2026
cfe5cdc
Expose an explicit host pane broker and report lost worker connections
demrom Oct 23, 2026
b33ae54
Close pane descriptors when stream setup fails
demrom Oct 23, 2026
cf020ff
Document host pane interrupt and usage contracts
demrom Oct 23, 2026
ad7d258
Report individual worker completion assertion failures
demrom Oct 23, 2026
97551c9
Keep worker fixtures independent of Dash login profiles
demrom Oct 23, 2026
41192c3
Compare worker fixture HOME by directory identity
demrom Oct 23, 2026
83eb230
Merge remote-tracking branch 'origin/master' into local/publication82…
demrom Oct 23, 2026
f497d97
Register stock OpenSSH clients with isolated workers
demrom Oct 23, 2026
783cc8a
Limit stock client registration to the worker-enabled mini target
demrom Oct 23, 2026
a2fceba
Document stock client home-path migration
demrom Oct 23, 2026
5dacf19
Match migration instructions to the client home adapter
demrom Oct 23, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,16 @@ TeX files are not installed by default. Type any TeX command and the system will

If you enable VoiceOver in Settings, a-Shell will work with VoiceOver: reading commands as you type them, reading the result, letting you read the screen with your finger...

## Independent shell workers

On iOS 26 and later, the mini target includes dedicated Generic Non-UI extensions, `ShellWorkerA` onward (ten at present; `ShellWorkerCount` in `a-Shell-mini-Info.plist` is the count, and `ShellWorkers/generate-targets.sh [N]` brings the project targets and the Settings list in line with it, a second run changes nothing). The host runs as many workers at once as the "Shell workers" setting says (`worker_limit` in Settings; Automatic is one per physical core, `hw.physicalcpu`), never more than the bundled count; a request beyond that gets `ENOSPC`. Each worker owns one native command and its ios_system state. The original Intents and IntentsUI targets remain separate. Consumers call `ashell_pane_spawn` from a background thread, read the returned full-duplex descriptor, poll completion and release the opaque handle. The API and stream ownership rules are in `ShellWorkers/NativePane.h`.

The host retains each `AppExtensionProcess` and XPC connection until release or interruption. A full pool returns `ENOSPC`; earlier iOS versions return `ENOTSUP`. Cancel and release terminate the dedicated worker through XPC before freeing its reservation; releasing an ExtensionFoundation assertion alone permits process reuse. Commands that ignore EOF therefore cannot retain a released slot. A lost worker reports status 137 while the host and other workers remain available. Directory access uses security-scoped bookmarks and is checked with actual file operations in the worker.

Workers share the host's command libraries. Build with ios_system changes #179–#184 and dash_iOS#1–#2, including the matched libedit dependency described there. The build derives each worker's catalogue and executable markers from `Resources_mini/commandDictionary.plist`; it generates the ExtensionFoundation binding from `PRODUCT_BUNDLE_PREFIX`. Keep worker versions in `ShellWorkers/Worker.xcconfig` aligned with the containing app. The mini bundle suffixes are `.ShellWorkerA` onward; each requires its own App ID and profile for a device build.

`tests/worker-pool.m` is a real app-command consumer for Simulator qualification. Device memory limits, background execution without other host background modes, and App Store acceptance require separate validation. Retaining the process handle provides no promise of unlimited background execution.

## Terminal color themes

Use `config --theme` to list bundled presets and JSON files in `~/Documents/Themes`. Select a theme with `config --theme dracula`. Add `-g` to update every open window and save the theme in Settings, for example `config --theme catppuccin-latte -g`. Use `-p` to save the current window settings, including font and color overrides. Choosing a theme in Settings or with `config --theme name -g` or `config --theme name -p` clears saved background, foreground and cursor overrides. The presets include all four Catppuccin flavors and Catppuccin OLED, adapted from Mocha with a pure black background and distinct neutral ANSI colors ordered from black through bright white. Catppuccin Latte and Rose Pine Dawn are light presets. Existing font and color options still work. `config --theme default` restores the saved theme and color overrides without changing the font. `config --theme factory` restores system foreground, background and cursor colors and the default ANSI palette; `config -r` also resets the other terminal settings.
Expand Down Expand Up @@ -136,3 +146,22 @@ For font files, create `~/Documents/Fonts` and drop `.ttf` or `.otf` files there
`config -n "face name" -s 17 -g` updates all open windows and saves the font and size. `-p` saves the current window's settings without applying them to other windows. In iOS Settings, open a-Shell or a-Shell mini and use "Font", "Custom font name", and "Font size". Choose "Custom" for another installed face or a file in `Documents/Fonts`. Changes apply when returning to the app and survive relaunch. Reopen the app's Settings page to refresh values changed from the terminal.

Font files are refreshed when running `config` or returning to the app. Removing a file unregisters it; a window using an unavailable font displays Menlo until the file returns or another face is selected. An invalid name passed to `config -n` reports an error and keeps the current selection.

## Stock OpenSSH clients

The SSH client update requires the matching OpenSSH 10.6p1 recipe and worker-aware runtime from `holzschu/ios_system`, on top of ios_system#201. Build `SSHStock`, `SCPStock`, `SFTPStock` and `SSHKeygenStock` with that recipe for each SDK you package, using matching ios_system and OpenSSL framework inputs. Keep each SDK's output in a separate directory. No SSH server or security-key provider is required for these four clients.

Package the resulting frameworks with Apple's `xcodebuild -create-xcframework`. For example, with the two recipe output directories in `SSH_DEVICE_OUTPUT` and `SSH_SIMULATOR_OUTPUT`:

```sh
for name in SSHStock SCPStock SFTPStock SSHKeygenStock; do
xcodebuild -create-xcframework \
-framework "$SSH_DEVICE_OUTPUT/sdk/$name.framework" \
-framework "$SSH_SIMULATOR_OUTPUT/sdk/$name.framework" \
-output "xcfs/$name.xcframework"
done
```

For a Simulator-only build, omit the device framework argument. Use fresh output paths when changing inputs. The mini Embed Frameworks phase signs these XCFrameworks. The mini command catalog selects their public entry points; extension catalogs are generated from the mini catalog. Application startup appends the four commands to the existing worker selection only when the target declares a positive `ShellWorkerCount`, preserving any configured commands. Targets without shell workers retain the existing client selection. The matching runtime runs each admitted root once and requires a fresh worker for nested client invocations; missing worker APIs or exhausted admission return status 127.

Run the producer's `phone-client.sh` through the app against a disposable SSH account with throwaway keys. Check version, key generation, strict login, scp/sftp byte round trips, multiple-source SCP errors and a following successful login. Stock clients use the existing app `SSH_HOME` root (Documents by default) for `.ssh/config`, keys and known_hosts, falling back to HOME when SSH_HOME is absent. Keep existing files for migration checks. Compilation and catalog validation alone do not prove these runtime behaviors.
Empty file added Resources/bin/dash
Empty file.
40 changes: 40 additions & 0 deletions Resources_mini/Settings.bundle/Root.plist
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,46 @@
<key>DefaultValue</key>
<false/>
</dict>
<!-- BEGIN generated by ShellWorkers/generate-targets.sh -->
<dict>
<key>Type</key>
<string>PSMultiValueSpecifier</string>
<key>Title</key>
<string>Shell workers (Automatic: one per core)</string>
<key>Key</key>
<string>worker_limit</string>
<key>DefaultValue</key>
<integer>0</integer>
<key>Titles</key>
<array>
<string>Automatic</string>
<string>1</string>
<string>2</string>
<string>3</string>
<string>4</string>
<string>5</string>
<string>6</string>
<string>7</string>
<string>8</string>
<string>9</string>
<string>10</string>
</array>
<key>Values</key>
<array>
<integer>0</integer>
<integer>1</integer>
<integer>2</integer>
<integer>3</integer>
<integer>4</integer>
<integer>5</integer>
<integer>6</integer>
<integer>7</integer>
<integer>8</integer>
<integer>9</integer>
<integer>10</integer>
</array>
</dict>
<!-- END generated -->
<dict>
<key>Type</key>
<string>PSGroupSpecifier</string>
Expand Down
Binary file modified Resources_mini/Settings.bundle/en.lproj/Root.strings
Binary file not shown.
Binary file modified Resources_mini/Settings.bundle/es.lproj/Root.strings
Binary file not shown.
Binary file modified Resources_mini/Settings.bundle/fr.lproj/Root.strings
Binary file not shown.
Binary file modified Resources_mini/Settings.bundle/it.lproj/Root.strings
Binary file not shown.
Binary file modified Resources_mini/Settings.bundle/ko.lproj/Root.strings
Binary file not shown.
Empty file added Resources_mini/bin/dash
Empty file.
18 changes: 9 additions & 9 deletions Resources_mini/commandDictionary.plist
Original file line number Diff line number Diff line change
Expand Up @@ -466,8 +466,8 @@
</array>
<key>scp</key>
<array>
<string>ssh_cmd.framework/ssh_cmd</string>
<string>scp_main</string>
<string>SCPStock.framework/SCPStock</string>
<string>ashell_scp_main</string>
<string>12346ABCTdfpqrtvF:J:P:S:c:i:l:o:</string>
<string>file</string>
</array>
Expand All @@ -494,8 +494,8 @@
</array>
<key>sftp</key>
<array>
<string>ssh_cmd.framework/ssh_cmd</string>
<string>sftp_main</string>
<string>SFTPStock.framework/SFTPStock</string>
<string>ashell_sftp_main</string>
<string>1246AafhNpqrvCc:D:i:l:o:s:S:b:B:F:J:P:R:</string>
<string>file</string>
</array>
Expand All @@ -508,16 +508,16 @@
</array>
<key>ssh</key>
<array>
<string>ssh_cmd.framework/ssh_cmd</string>
<string>ssh_main</string>
<string>SSHStock.framework/SSHStock</string>
<string>ashell_ssh_main</string>
<string>1246ab:c:e:fgi:kl:m:no:p:qstvxAB:CD:E:F:GI:J:KL:MNO:PQ:R:S:TVw:W:XYy</string>
<string>no</string>
</array>
<key>ssh-keygen</key>
<array>
<string>ssh_cmd.framework/ssh_cmd</string>
<string>sshkeygen_main</string>
<string>ABHLQXceghiklopquvxyC:D:E:F:G:I:J:K:M:N:O:P:R:S:T:V:W:Z:a:b:f:g:j:m:n:r:s:t:z:</string>
<string>SSHKeygenStock.framework/SSHKeygenStock</string>
<string>ashell_ssh_keygen_main</string>
<string>ABHLQXceghiklopquvxyC:D:E:F:G:I:J:K:M:N:O:P:R:S:T:V:W:Y:Z:a:b:f:g:j:m:n:r:s:t:w:z:</string>
<string>file</string>
</array>
<key>ssh-agent</key>
Expand Down
178 changes: 178 additions & 0 deletions ShellWorkers/Host.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
import ExtensionFoundation
import Foundation

@available(iOS 26.0, *)
extension AppExtensionPoint {
@Definition
static var shellWorker: AppExtensionPoint { Name("ShellWorker") }
}

// Concurrent workers: the user's "worker_limit" setting (0 or unset = automatic, the physical
// core count), never more than the bundled ShellWorkerCount extensions. 0 means no workers.
enum ShellWorkerCapacity {
static var bundled: Int {
let configured = Bundle.main.object(forInfoDictionaryKey: "ShellWorkerCount")
return (configured as? NSNumber)?.intValue ?? Int(configured as? String ?? "") ?? 0
}

static var physicalCores: Int {
var count: Int32 = 0
var size = MemoryLayout<Int32>.size
return sysctlbyname("hw.physicalcpu", &count, &size, nil, 0) == 0 ? Int(count) : 1
}

static var current: Int {
let requested = UserDefaults.standard.integer(forKey: "worker_limit")
return min(requested > 0 ? requested : physicalCores, bundled)
}
}

@_cdecl("ashell_extension_capacity")
func shellExtensionCapacity() -> Int32 { Int32(ShellWorkerCapacity.current) }

// Process lifetime and discovery belong to MainActor. Blocking C ABI calls wait
// only on the calling runner thread, never on the UI actor.
@available(iOS 26.0, *)
@MainActor
final class ShellWorkerPool {
static let shared = ShellWorkerPool()
private enum Slot {
case launching(UUID)
case connected(UUID, AppExtensionProcess, NSXPCConnection, DispatchSourceProcess?)
case retiring(UUID, DispatchSourceProcess?)

var generation: UUID {
switch self {
case .launching(let generation), .connected(let generation, _, _, _), .retiring(let generation, _): generation
}
}
}
private var slots: [String: Slot] = [:]

func connect() async throws -> NSXPCConnection {
guard Bundle.main.object(forInfoDictionaryKey: "ShellWorkerCount") != nil else {
throw NSError(domain: NSPOSIXErrorDomain, code: Int(ENOTSUP))
}
let monitor = try await AppExtensionPoint.Monitor(appExtensionPoint: .shellWorker)
for _ in 0..<100 {
let capacity = ShellWorkerCapacity.current
if capacity > 0 && slots.count >= capacity {
if slots.values.contains(where: { if case .retiring = $0 { return true }; return false }) {
try await Task.sleep(for: .milliseconds(100))
continue
}
throw NSError(domain: NSPOSIXErrorDomain, code: Int(ENOSPC), userInfo: [NSLocalizedDescriptionKey: "All shell workers are in use. Close a pane before opening another."])
}
if let identity = monitor.identities.sorted(by: { $0.bundleIdentifier < $1.bundleIdentifier }).first(where: { slots[$0.bundleIdentifier] == nil }) {
let key = identity.bundleIdentifier
let generation = UUID()
slots[key] = .launching(generation)
do {
let process = try await AppExtensionProcess(configuration: .init(appExtensionIdentity: identity, onInterruption: {
Task { @MainActor in
guard self.slots[key]?.generation == generation else { return }
// A connected worker is retired using its spawn-reply PID.
if case .launching = self.slots[key] {
self.slots.removeValue(forKey: key)
}
}
}))
guard slots[key]?.generation == generation else {
process.invalidate()
throw CancellationError()
}
let connection = try process.makeXPCConnection()
connection.exportedInterface = NSXPCInterface(with: AShellSpawnBrokerWire.self)
connection.exportedObject = ashell_spawn_broker()
slots[key] = .connected(generation, process, connection, nil)
return connection
} catch {
if slots[key]?.generation == generation { slots.removeValue(forKey: key) }
throw error
}
}
try await Task.sleep(for: .milliseconds(100))
}
throw NSError(domain: NSPOSIXErrorDomain, code: Int(ETIMEDOUT), userInfo: [NSLocalizedDescriptionKey: "shell worker discovery timed out"])
}

func observe(_ connection: NSXPCConnection, pid: pid_t, registered: @escaping () -> Void) {
for (key, slot) in slots {
guard case .connected(let generation, let process, let stored, nil) = slot, stored === connection else { continue }
let exit = DispatchSource.makeProcessSource(identifier: pid, eventMask: .exit, queue: .main)
slots[key] = .connected(generation, process, connection, exit)
exit.setEventHandler { [weak self] in
Task { @MainActor in
guard let self, let current = self.slots[key], current.generation == generation else { return }
let source: DispatchSourceProcess?
switch current {
case .connected(_, _, _, let value), .retiring(_, let value): source = value
case .launching: return
}
source?.cancel()
connection.invalidate()
process.invalidate()
self.slots.removeValue(forKey: key)
}
}
// Arm observation while the admitted process is still alive.
exit.setRegistrationHandler(handler: registered)
exit.resume()
return
}
registered()
}

func disconnect(_ connection: NSXPCConnection) {
for (key, slot) in slots {
guard case .connected(let generation, let process, let stored, let exit) = slot, stored === connection else { continue }
if exit == nil {
connection.invalidate()
process.invalidate()
slots.removeValue(forKey: key)
return
}
slots[key] = .retiring(generation, exit)
// Keep the process assertion until exit, with a deadline if observation fails.
Task { @MainActor in
try? await Task.sleep(for: .seconds(2))
guard case .retiring(let current, let source) = self.slots[key], current == generation else { return }
source?.cancel()
connection.invalidate()
process.invalidate()
self.slots.removeValue(forKey: key)
}
return
}
}
}

@_cdecl("ashell_extension_observe")
func shellExtensionObserve(_ connection: NSXPCConnection, _ pid: pid_t) {
if #available(iOS 26.0, *), pid > 0 {
let registered = DispatchSemaphore(value: 0)
Task { @MainActor in
ShellWorkerPool.shared.observe(connection, pid: pid) { registered.signal() }
}
_ = registered.wait(timeout: .now() + 1)
}
}

@_cdecl("ashell_extension_disconnect")
func shellExtensionDisconnect(_ connection: NSXPCConnection) {
if #available(iOS 26.0, *) {
Task { @MainActor in ShellWorkerPool.shared.disconnect(connection) }
}
}

@_cdecl("ashell_extension_connect")
func shellExtensionConnect(_ callback: @escaping @convention(block) (NSXPCConnection?, NSError?) -> Void) {
guard #available(iOS 26.0, *) else {
callback(nil, NSError(domain: NSPOSIXErrorDomain, code: Int(ENOTSUP)))
return
}
Task { @MainActor in
do { callback(try await ShellWorkerPool.shared.connect(), nil) }
catch { callback(nil, error as NSError) }
}
}
45 changes: 45 additions & 0 deletions ShellWorkers/HostPane.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
#ifndef ASHELL_HOST_PANE_H
#define ASHELL_HOST_PANE_H

#include "NativePane.h"

#ifdef __cplusplus
extern "C" {
#endif
#pragma GCC visibility push(default)

// Explicit app-process runner for commands that exceed the extension memory limit.
// Uses the same streams, options and handle lifetime as NativePane.h. One live host
// pane is admitted (EBUSY for another); it shares the app's process cwd and memory.
// No extension allocation or automatic routing is performed. Do not forward worker
// identity environment overrides. Cancellation returns ENOTSUP while running.
// Interrupt requires a Dash foreground command with ASHELL_IOS_SOCKET_INPUT=1
// in envp; otherwise it can return ESRCH. Its PID hook is process-wide: unrelated
// Dash sessions must not enable that variable concurrently. Release retains live cleanup.
// poll_usage returns the app process RUSAGE_SELF at exit, not per-command usage.
int ashell_host_pane_spawn(const char *, const char *, unsigned, unsigned, const char *const *, int *, void **);
int ashell_host_pane_spawn_ex(const char *, const char *, unsigned, unsigned, const char *const *, ashell_pane_options *, int *, void **);
int ashell_host_pane_poll(void *, int *);
int ashell_host_pane_poll_usage(void *, int *, struct rusage *, int *);
int ashell_host_pane_resize(void *, unsigned, unsigned);
int ashell_host_pane_close_stdin(void *);
int ashell_host_pane_interrupt(void *);
int ashell_host_pane_cancel(void *);
void ashell_host_pane_release(void *);

// Explicit worker-to-host broker route. Handles remain in the calling worker;
// poll reports -EPIPE if the host connection is lost. No admission policy here.
int ashell_worker_host_spawn_ex(const char *, const char *, unsigned, unsigned, const char *const *, ashell_pane_options *, int *, void **);
int ashell_worker_host_poll(void *, int *);
int ashell_worker_host_poll_usage(void *, int *, struct rusage *, int *);
int ashell_worker_host_resize(void *, unsigned, unsigned);
int ashell_worker_host_close_stdin(void *);
int ashell_worker_host_interrupt(void *);
int ashell_worker_host_cancel(void *);
void ashell_worker_host_release(void *);

#pragma GCC visibility pop
#ifdef __cplusplus
}
#endif
#endif
12 changes: 12 additions & 0 deletions ShellWorkers/HostPane.m
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
// Compile the same native runner in the app under a distinct public ABI.
#define ASHELL_HOST_PANE 1
#define ashell_pane_spawn ashell_host_pane_spawn
#define ashell_pane_spawn_ex ashell_host_pane_spawn_ex
#define ashell_pane_poll ashell_host_pane_poll
#define ashell_pane_poll_usage ashell_host_pane_poll_usage
#define ashell_pane_resize ashell_host_pane_resize
#define ashell_pane_close_stdin ashell_host_pane_close_stdin
#define ashell_pane_interrupt ashell_host_pane_interrupt
#define ashell_pane_cancel ashell_host_pane_cancel
#define ashell_pane_release ashell_host_pane_release
#include "NativePane.m"
Loading