Skip to content

Commit 127807f

Browse files
committed
Fix Worktrunk bundling and on-demand install (macOS)
1 parent 20ad06a commit 127807f

5 files changed

Lines changed: 344 additions & 9 deletions

File tree

macos/Sources/Features/Worktrunk/WorktrunkClient.swift

Lines changed: 46 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ enum WorktrunkClientError: LocalizedError {
88
var errorDescription: String? {
99
switch self {
1010
case .executableNotFound:
11-
return "Worktrunk binary not found. Install worktrunk (provides `wt`) or set GHOSTTY_WORKTRUNK_BIN."
11+
return "Worktrunk binary not found. Install worktrunk (provides `wt`), install it from Ghostree, or set GHOSTTY_WORKTRUNK_BIN."
1212
case .nonZeroExit(let code, let stderr):
1313
if stderr.isEmpty { return "Worktrunk failed (exit \(code))." }
1414
return "Worktrunk failed (exit \(code)): \(stderr)"
@@ -98,7 +98,7 @@ struct WorktrunkClient {
9898
return Invocation(executableURL: url, arguments: args, environment: env)
9999
}
100100

101-
for path in ["/opt/homebrew/bin/wt", "/usr/local/bin/wt", "/usr/bin/wt"] {
101+
for path in wtExecutableCandidatePaths() {
102102
if FileManager.default.isExecutableFile(atPath: path) {
103103
return Invocation(executableURL: URL(fileURLWithPath: path), arguments: args, environment: env)
104104
}
@@ -112,4 +112,48 @@ struct WorktrunkClient {
112112

113113
return Invocation(executableURL: envURL, arguments: ["wt"] + args, environment: env)
114114
}
115+
116+
private static func wtExecutableCandidatePaths() -> [String] {
117+
var paths: [String] = []
118+
119+
// On-demand install location (preferred for users without Worktrunk).
120+
paths.append(AgentStatusPaths.binDir.appendingPathComponent("wt").path)
121+
122+
// Bundled inside the app bundle (preferred for packaged releases).
123+
if let bundled = bundledExecutablePath(name: "wt") {
124+
paths.append(bundled)
125+
}
126+
127+
// Common system install locations.
128+
paths.append(contentsOf: ["/opt/homebrew/bin/wt", "/usr/local/bin/wt", "/usr/bin/wt"])
129+
130+
return paths
131+
}
132+
133+
private static func bundledExecutablePath(name: String) -> String? {
134+
let base = Bundle.main.bundleURL
135+
136+
// Preferred bundle location.
137+
let worktrunk = base
138+
.appendingPathComponent("Contents", isDirectory: true)
139+
.appendingPathComponent("Resources", isDirectory: true)
140+
.appendingPathComponent("worktrunk", isDirectory: true)
141+
.appendingPathComponent(name, isDirectory: false)
142+
if FileManager.default.isExecutableFile(atPath: worktrunk.path) {
143+
return worktrunk.path
144+
}
145+
146+
// Legacy/alternate location (if nested under the existing "ghostty" resources folder).
147+
let ghosttyWorktrunk = base
148+
.appendingPathComponent("Contents", isDirectory: true)
149+
.appendingPathComponent("Resources", isDirectory: true)
150+
.appendingPathComponent("ghostty", isDirectory: true)
151+
.appendingPathComponent("worktrunk", isDirectory: true)
152+
.appendingPathComponent(name, isDirectory: false)
153+
if FileManager.default.isExecutableFile(atPath: ghosttyWorktrunk.path) {
154+
return ghosttyWorktrunk.path
155+
}
156+
157+
return nil
158+
}
115159
}
Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
import CryptoKit
2+
import Foundation
3+
4+
enum WorktrunkInstallerError: LocalizedError {
5+
case unsupportedArchitecture
6+
case downloadFailed
7+
case integrityCheckFailed
8+
case extractFailed(stderr: String)
9+
case missingBinary(name: String)
10+
case installFailed(message: String)
11+
12+
var errorDescription: String? {
13+
switch self {
14+
case .unsupportedArchitecture:
15+
return "Unsupported Mac architecture for Worktrunk installation."
16+
case .downloadFailed:
17+
return "Failed to download Worktrunk."
18+
case .integrityCheckFailed:
19+
return "Downloaded Worktrunk failed the integrity check."
20+
case .extractFailed(let stderr):
21+
if stderr.isEmpty { return "Failed to extract Worktrunk." }
22+
return "Failed to extract Worktrunk: \(stderr)"
23+
case .missingBinary(let name):
24+
return "Downloaded Worktrunk archive did not contain \(name)."
25+
case .installFailed(let message):
26+
return message
27+
}
28+
}
29+
}
30+
31+
enum WorktrunkInstaller {
32+
private struct Release {
33+
static let version = "0.22.0"
34+
static let assetName = "worktrunk-aarch64-apple-darwin.tar.xz"
35+
static let sha256 = "1fd193d8ed95453dbeadd900035312a6df61ff3fad43dc85eb1a9f7b48895b3c"
36+
37+
static var url: URL {
38+
URL(string: "https://github.com/max-sixty/worktrunk/releases/download/v\(version)/\(assetName)")!
39+
}
40+
}
41+
42+
static func installPinnedWorktrunkIfNeeded() async throws {
43+
guard isSupportedArchitecture() else {
44+
throw WorktrunkInstallerError.unsupportedArchitecture
45+
}
46+
47+
let binDir = AgentStatusPaths.binDir
48+
let wtDest = binDir.appendingPathComponent("wt", isDirectory: false)
49+
let gitWtDest = binDir.appendingPathComponent("git-wt", isDirectory: false)
50+
if FileManager.default.isExecutableFile(atPath: wtDest.path),
51+
FileManager.default.isExecutableFile(atPath: gitWtDest.path) {
52+
return
53+
}
54+
55+
try FileManager.default.createDirectory(at: binDir, withIntermediateDirectories: true)
56+
57+
let tmpDir = FileManager.default.temporaryDirectory
58+
.appendingPathComponent("dev.sidequery.Ghostree", isDirectory: true)
59+
.appendingPathComponent("worktrunk-install-\(UUID().uuidString)", isDirectory: true)
60+
try FileManager.default.createDirectory(at: tmpDir, withIntermediateDirectories: true)
61+
defer { try? FileManager.default.removeItem(at: tmpDir) }
62+
63+
let archiveURL = tmpDir.appendingPathComponent(Release.assetName, isDirectory: false)
64+
65+
do {
66+
let (downloaded, _) = try await URLSession.shared.download(from: Release.url)
67+
try FileManager.default.moveItem(at: downloaded, to: archiveURL)
68+
} catch {
69+
throw WorktrunkInstallerError.downloadFailed
70+
}
71+
72+
let actual = try sha256Hex(url: archiveURL)
73+
guard actual == Release.sha256 else {
74+
throw WorktrunkInstallerError.integrityCheckFailed
75+
}
76+
77+
let extractDir = tmpDir.appendingPathComponent("extract", isDirectory: true)
78+
try FileManager.default.createDirectory(at: extractDir, withIntermediateDirectories: true)
79+
try extractTarXz(archiveURL: archiveURL, to: extractDir)
80+
81+
let root = extractDir.appendingPathComponent("worktrunk-aarch64-apple-darwin", isDirectory: true)
82+
let wtSource = root.appendingPathComponent("wt", isDirectory: false)
83+
let gitWtSource = root.appendingPathComponent("git-wt", isDirectory: false)
84+
85+
guard FileManager.default.fileExists(atPath: wtSource.path) else {
86+
throw WorktrunkInstallerError.missingBinary(name: "wt")
87+
}
88+
guard FileManager.default.fileExists(atPath: gitWtSource.path) else {
89+
throw WorktrunkInstallerError.missingBinary(name: "git-wt")
90+
}
91+
92+
try replaceFile(source: wtSource, dest: wtDest)
93+
try replaceFile(source: gitWtSource, dest: gitWtDest)
94+
95+
try makeExecutable(url: wtDest)
96+
try makeExecutable(url: gitWtDest)
97+
_ = try? removeQuarantine(url: wtDest)
98+
_ = try? removeQuarantine(url: gitWtDest)
99+
}
100+
101+
private static func isSupportedArchitecture() -> Bool {
102+
#if arch(arm64)
103+
return true
104+
#else
105+
return false
106+
#endif
107+
}
108+
109+
private static func sha256Hex(url: URL) throws -> String {
110+
let data = try Data(contentsOf: url)
111+
let digest = SHA256.hash(data: data)
112+
return digest.map { String(format: "%02x", $0) }.joined()
113+
}
114+
115+
private static func extractTarXz(archiveURL: URL, to dir: URL) throws {
116+
let (exitCode, stderr) = try runProcess(
117+
executable: URL(fileURLWithPath: "/usr/bin/tar"),
118+
args: ["-xJf", archiveURL.path, "-C", dir.path]
119+
)
120+
guard exitCode == 0 else {
121+
throw WorktrunkInstallerError.extractFailed(stderr: stderr)
122+
}
123+
}
124+
125+
private static func replaceFile(source: URL, dest: URL) throws {
126+
let fm = FileManager.default
127+
if fm.fileExists(atPath: dest.path) {
128+
try fm.removeItem(at: dest)
129+
}
130+
do {
131+
try fm.copyItem(at: source, to: dest)
132+
} catch {
133+
throw WorktrunkInstallerError.installFailed(message: "Failed to install Worktrunk to \(dest.path).")
134+
}
135+
}
136+
137+
private static func makeExecutable(url: URL) throws {
138+
try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: url.path)
139+
}
140+
141+
private static func removeQuarantine(url: URL) throws {
142+
_ = try runProcess(
143+
executable: URL(fileURLWithPath: "/usr/bin/xattr"),
144+
args: ["-d", "com.apple.quarantine", url.path]
145+
)
146+
}
147+
148+
private static func runProcess(executable: URL, args: [String]) throws -> (Int32, String) {
149+
let process = Process()
150+
process.executableURL = executable
151+
process.arguments = args
152+
153+
let stdinPipe = Pipe()
154+
stdinPipe.fileHandleForWriting.closeFile()
155+
process.standardInput = stdinPipe
156+
157+
let stderrPipe = Pipe()
158+
process.standardError = stderrPipe
159+
process.standardOutput = FileHandle.nullDevice
160+
161+
try process.run()
162+
let stderrData = stderrPipe.fileHandleForReading.readDataToEndOfFile()
163+
process.waitUntilExit()
164+
165+
let stderr = String(data: stderrData, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
166+
return (process.terminationStatus, stderr)
167+
}
168+
}
169+

macos/Sources/Features/Worktrunk/WorktrunkSidebarView.swift

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -69,11 +69,29 @@ struct WorktrunkSidebarView: View {
6969
.padding(.vertical, 8)
7070
if let err = store.errorMessage, !err.isEmpty {
7171
Divider()
72-
Text(err)
73-
.font(.caption)
74-
.foregroundStyle(.secondary)
75-
.padding(8)
76-
.frame(maxWidth: .infinity, alignment: .leading)
72+
VStack(alignment: .leading, spacing: 8) {
73+
Text(err)
74+
.font(.caption)
75+
.foregroundStyle(.secondary)
76+
77+
if store.needsWorktrunkInstall {
78+
HStack(spacing: 8) {
79+
Button {
80+
Task { _ = await store.installWorktrunk() }
81+
} label: {
82+
Text("Install Worktrunk…")
83+
}
84+
.disabled(store.isInstallingWorktrunk)
85+
86+
if store.isInstallingWorktrunk {
87+
ProgressView()
88+
.controlSize(.small)
89+
}
90+
}
91+
}
92+
}
93+
.padding(8)
94+
.frame(maxWidth: .infinity, alignment: .leading)
7795
}
7896
}
7997
.frame(minWidth: 240, idealWidth: 280)

macos/Sources/Features/Worktrunk/WorktrunkStore.swift

Lines changed: 60 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,8 @@ final class WorktrunkStore: ObservableObject {
179179
@Published private(set) var agentStatusByWorktreePath: [String: WorktreeAgentStatusEntry] = [:]
180180
@Published private(set) var sidebarSnapshot: SidebarSnapshot = .empty
181181
@Published var isRefreshing: Bool = false
182+
@Published var isInstallingWorktrunk: Bool = false
183+
@Published var needsWorktrunkInstall: Bool = false
182184
@Published var errorMessage: String? = nil
183185
@Published private(set) var sidebarModelRevision: Int = 0
184186
@Published var worktreeSortOrder: WorktreeSortOrder = .recentActivity {
@@ -341,10 +343,14 @@ final class WorktrunkStore: ObservableObject {
341343
await MainActor.run {
342344
addRepository(path: normalized, displayName: displayName)
343345
errorMessage = nil
346+
needsWorktrunkInstall = false
344347
}
345348
} catch {
346349
let message = (error as? LocalizedError)?.errorDescription ?? String(describing: error)
347-
await MainActor.run { errorMessage = message }
350+
await MainActor.run {
351+
errorMessage = message
352+
needsWorktrunkInstall = isWorktrunkMissing(error)
353+
}
348354
}
349355
}
350356

@@ -557,6 +563,7 @@ final class WorktrunkStore: ObservableObject {
557563
}
558564
worktreesByRepositoryID[repoID] = sortWorktrees(worktrees)
559565
errorMessage = nil
566+
needsWorktrunkInstall = false
560567
reconcilePendingAgentEvents()
561568
pruneWorktreeScopedState(removedPaths: removedPaths)
562569
bumpSidebarModelRevision()
@@ -566,6 +573,7 @@ final class WorktrunkStore: ObservableObject {
566573
} catch {
567574
await MainActor.run {
568575
errorMessage = (error as? LocalizedError)?.errorDescription ?? String(describing: error)
576+
needsWorktrunkInstall = isWorktrunkMissing(error)
569577
}
570578
}
571579
}
@@ -591,10 +599,12 @@ final class WorktrunkStore: ObservableObject {
591599
_ = try await WorktrunkClient.run(args)
592600

593601
await refresh(repoID: repoID)
602+
await MainActor.run { needsWorktrunkInstall = false }
594603
return worktrees(for: repoID).first(where: { $0.branch == branch })
595604
} catch {
596605
await MainActor.run {
597606
errorMessage = (error as? LocalizedError)?.errorDescription ?? String(describing: error)
607+
needsWorktrunkInstall = isWorktrunkMissing(error)
598608
}
599609
return nil
600610
}
@@ -619,16 +629,64 @@ final class WorktrunkStore: ObservableObject {
619629
args.append(trimmedBranch)
620630
_ = try await WorktrunkClient.run(args)
621631
await refresh(repoID: repoID)
622-
await MainActor.run { errorMessage = nil }
632+
await MainActor.run {
633+
errorMessage = nil
634+
needsWorktrunkInstall = false
635+
}
636+
return true
637+
} catch {
638+
await MainActor.run {
639+
errorMessage = (error as? LocalizedError)?.errorDescription ?? String(describing: error)
640+
needsWorktrunkInstall = isWorktrunkMissing(error)
641+
}
642+
return false
643+
}
644+
}
645+
646+
func installWorktrunk() async -> Bool {
647+
await MainActor.run {
648+
isInstallingWorktrunk = true
649+
errorMessage = nil
650+
}
651+
652+
do {
653+
try await WorktrunkInstaller.installPinnedWorktrunkIfNeeded()
654+
await MainActor.run {
655+
isInstallingWorktrunk = false
656+
errorMessage = nil
657+
needsWorktrunkInstall = false
658+
}
659+
await refreshAll()
623660
return true
624661
} catch {
625662
await MainActor.run {
663+
isInstallingWorktrunk = false
626664
errorMessage = (error as? LocalizedError)?.errorDescription ?? String(describing: error)
665+
needsWorktrunkInstall = true
627666
}
628667
return false
629668
}
630669
}
631670

671+
private func isWorktrunkMissing(_ error: Error) -> Bool {
672+
guard let wt = error as? WorktrunkClientError else { return false }
673+
switch wt {
674+
case .executableNotFound:
675+
return true
676+
case .nonZeroExit(let code, let stderr):
677+
// When we fall back to `/usr/bin/env wt ...` and `wt` isn't on PATH, env exits 127.
678+
if code == 127 {
679+
let s = stderr.lowercased()
680+
if s.contains("wt") && (s.contains("not found") || s.contains("no such file")) {
681+
return true
682+
}
683+
}
684+
return false
685+
case .invalidUTF8:
686+
return false
687+
}
688+
}
689+
632690
private func load() {
633691
let ud = UserDefaults.standard
634692
guard let data = ud.data(forKey: repositoriesKey) else { return }

0 commit comments

Comments
 (0)