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
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,22 @@
# Changelog

## [2.4.1] - 2026-07-10

### Added
- **Auto-upgrade legionio CLI** — When a newer `legionio` formula is available, Interlink automatically runs `brew upgrade legionio` and restarts the daemon. Enabled by default with an "auto-upgrade cli" checkbox in the Updates tab to disable.

## [2.4.0] - 2026-07-10

### Added
- **Daemon restart after gem updates** — After `legionio update` completes successfully, Interlink now runs `brew services restart legionio` so the daemon picks up the new code immediately. Enabled by default with a "restart daemon" checkbox in the Updates tab header to disable.

### Changed
- **Gem updates use `legionio update`** — Instead of per-gem `legion-gem update <name>`, clicking update for any legion-* or lex-* gem now runs a single `legionio update` which handles the full dependency graph. Only runs once regardless of how many gems are outdated.
- **Claude Code install button** — Replaced the passive `npm i -g` hint with a proper "install" button using Homebrew (`brew install anthropics/tap/claude-code`), matching the Codex and Kai install UX.

### Fixed
- **Self-update relaunch timing** — Increased the relaunch delay from 1s to 3s after termination to ensure the old process fully exits before the new binary opens.

## [2.3.6] - 2026-06-23

### Fixed
Expand Down
37 changes: 24 additions & 13 deletions Sources/ClientsTab.swift
Original file line number Diff line number Diff line change
Expand Up @@ -69,9 +69,7 @@ struct ClientsTab: View {
fm.isExecutableFile(atPath: "/usr/local/bin/claude") ||
fm.isExecutableFile(atPath: "\(home)/.local/bin/claude")

codexInstalled =
fm.fileExists(atPath: "/Applications/Codex.app") ||
fm.fileExists(atPath: "\(home)/Applications/Codex.app")
codexInstalled = fm.fileExists(atPath: "\(home)/.codex")

kaiInstalled =
fm.fileExists(atPath: "/Applications/Kai.app") ||
Expand Down Expand Up @@ -151,7 +149,9 @@ struct ClientsTab: View {
openTerminalWithCommand("claude")
}
} else {
installHintText("npm i -g @anthropic-ai/claude-code")
TerminalActionButton(label: "install", color: TerminalTheme.accent) {
installClaudeCode()
}
}
}
.padding(12)
Expand All @@ -169,7 +169,7 @@ struct ClientsTab: View {
)

VStack(alignment: .leading, spacing: 4) {
Text("Codex")
Text("Codex/ChatGPT")
.font(.system(size: 13, weight: .medium, design: .monospaced))
.foregroundColor(TerminalTheme.text)

Expand All @@ -185,7 +185,7 @@ struct ClientsTab: View {
if codexInstalled {
routingToggle(enabled: $codexRoutingEnabled, active: codexRouted)
TerminalActionButton(label: "open", color: TerminalTheme.green) {
NSWorkspace.shared.open(URL(fileURLWithPath: "/Applications/Codex.app"))
openCodex()
}
} else {
TerminalActionButton(label: "install", color: TerminalTheme.accent) {
Expand Down Expand Up @@ -335,12 +335,19 @@ struct ClientsTab: View {
}
}

private func installHintText(_ hint: String) -> some View {
Text(hint)
.font(.system(size: 9, design: .monospaced))
.foregroundColor(TerminalTheme.textDim.opacity(0.5))
.lineLimit(1)
.truncationMode(.tail)
private func installClaudeCode() {
let brew = ServiceManager.shared.resolvedBrewPathPublic
Task.detached {
let process = Process()
process.executableURL = URL(fileURLWithPath: brew)
process.arguments = ["install", "anthropics/tap/claude-code"]
process.standardOutput = FileHandle.nullDevice
process.standardError = FileHandle.nullDevice
try? process.run()
process.waitUntilExit()
let success = process.terminationStatus == 0
await MainActor.run { if success { claudeInstalled = true } }
}
}

// MARK: - Install Helpers
Expand All @@ -360,12 +367,16 @@ struct ClientsTab: View {
}
}

private func openCodex() {
NSWorkspace.shared.open(URL(fileURLWithPath: "/Applications/ChatGPT.app"))
}

private func installCodex() {
let brew = ServiceManager.shared.resolvedBrewPathPublic
Task.detached {
let process = Process()
process.executableURL = URL(fileURLWithPath: brew)
process.arguments = ["install", "--cask", "codex"]
process.arguments = ["install", "codex"]
process.standardOutput = FileHandle.nullDevice
process.standardError = FileHandle.nullDevice
try? process.run()
Expand Down
153 changes: 99 additions & 54 deletions Sources/UpdateManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -31,15 +31,19 @@ class UpdateManager: ObservableObject {
@Published var lastChecked: Date?
@Published var checkError: String?
@Published var autoUpdateLex = true
@Published var autoUpgradeLegionio = true
@Published var restartAfterUpdate = true

private let resolvedBrewPath: String
private let resolvedLegionGemPath: String
private let resolvedLegionioPath: String
private var backgroundTimer: Timer?
private var diskVersionTimer: Timer?

private init() {
resolvedBrewPath = Self.findPath("/opt/homebrew/bin/brew", fallback: "/usr/local/bin/brew")
resolvedLegionGemPath = Self.findPath("/opt/homebrew/bin/legion-gem", fallback: "/usr/local/bin/legion-gem")
resolvedLegionioPath = Self.findPath("/opt/homebrew/bin/legionio", fallback: "/usr/local/bin/legionio")
startBackgroundChecks()
}

Expand Down Expand Up @@ -84,7 +88,7 @@ class UpdateManager: ObservableObject {
// MARK: - Background Periodic Check

private func startBackgroundChecks() {
backgroundTimer = Timer.scheduledTimer(withTimeInterval: 1800, repeats: true) { [weak self] _ in
backgroundTimer = Timer.scheduledTimer(withTimeInterval: 14400, repeats: true) { [weak self] _ in
guard let self else { return }
Task { @MainActor in
await self.checkForUpdates(force: true, background: true)
Expand Down Expand Up @@ -127,7 +131,7 @@ class UpdateManager: ObservableObject {
let bundlePath = Bundle.main.bundlePath
let process = Process()
process.executableURL = URL(fileURLWithPath: "/bin/sh")
process.arguments = ["-c", "sleep 1 && open '\(bundlePath)'"]
process.arguments = ["-c", "sleep 3 && open '\(bundlePath)'"]
process.standardOutput = FileHandle.nullDevice
process.standardError = FileHandle.nullDevice
try? process.run()
Expand Down Expand Up @@ -182,89 +186,113 @@ class UpdateManager: ObservableObject {
}

if autoUpdateLex {
let lexItems = items.filter(\.isLex)
for item in lexItems {
autoUpdateGem(item)
}
autoUpdateGems()
}

if autoUpgradeLegionio {
autoUpgradeLegionioBrew()
}
}

// MARK: - Update Actions

func updateItem(_ item: UpdateItem) {
guard let idx = items.firstIndex(where: { $0.id == item.id }) else { return }
items[idx].isUpdating = true
if item.isInterlink {
updateInterlink(item)
} else {
runLegionioUpdate()
}
}

let brew = resolvedBrewPath
let legionGem = resolvedLegionGemPath
let name = item.name
let isLegionio = item.isLegionio
let isInterlink = item.isInterlink
func updateAll() {
let hasInterlink = items.contains(where: \.isInterlink)
let hasGems = items.contains { !$0.isInterlink && !$0.isUpdating }

if hasGems { runLegionioUpdate() }
if hasInterlink, let item = items.first(where: \.isInterlink) {
updateInterlink(item)
}
}

/// Run `legionio update` once for all outdated gems, then optionally restart the daemon.
@Published private(set) var isRunningLegionioUpdate = false

private func runLegionioUpdate() {
guard !isRunningLegionioUpdate else { return }
isRunningLegionioUpdate = true

let gemIds = items.filter { !$0.isInterlink }.map(\.id)
for i in items.indices where gemIds.contains(items[i].id) {
items[i].isUpdating = true
}

let legionio = resolvedLegionioPath
let shouldRestart = restartAfterUpdate

Task.detached {
var success = false
if isInterlink {
success = Self.runSync(brew, arguments: ["upgrade", "legion-interlink"])
} else {
success = Self.runSync(legionGem, arguments: ["update", name])
}
let success = Self.runSync(legionio, arguments: ["update"])

// For legionio gem, also run brew upgrade to update the CLI binary
if success && isLegionio {
_ = Self.runSync(brew, arguments: ["upgrade", "legionio"])
if success && shouldRestart {
await ServiceManager.shared.restartService(.legionio)
}

await MainActor.run {
if success {
self.items.removeAll { $0.id == item.id }
self.items.removeAll { gemIds.contains($0.id) }
} else {
if let idx = self.items.firstIndex(where: { $0.id == item.id }) {
self.items[idx].isUpdating = false
for i in self.items.indices where gemIds.contains(self.items[i].id) {
self.items[i].isUpdating = false
}
}
}

if success && isLegionio {
await ServiceManager.shared.restartService(.legionio)
}

// After interlink upgrade, quit — the AppDelegate or cask postflight
// will detect the new version and relaunch.
if success && isInterlink {
await Self.relaunchInterlink()
self.isRunningLegionioUpdate = false
}
}
}

private nonisolated static func relaunchInterlink() {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
let bundlePath = Bundle.main.bundlePath
let process = Process()
process.executableURL = URL(fileURLWithPath: "/bin/sh")
process.arguments = ["-c", "sleep 1 && open '\(bundlePath)'"]
process.standardOutput = FileHandle.nullDevice
process.standardError = FileHandle.nullDevice
try? process.run()
NSApplication.shared.terminate(nil)
}
/// Auto-update all legion-*/lex-* gems via `legionio update`.
private func autoUpdateGems() {
let gemItems = items.filter { !$0.isInterlink && !$0.isLegionio }
guard !gemItems.isEmpty else { return }
runLegionioUpdate()
}

func updateAll() {
for item in items where !item.isUpdating {
updateItem(item)
/// Auto-upgrade the legionio CLI formula via brew and restart the daemon.
private func autoUpgradeLegionioBrew() {
guard items.contains(where: \.isLegionio) else { return }
guard let idx = items.firstIndex(where: \.isLegionio) else { return }
items[idx].isUpdating = true

let brew = resolvedBrewPath
let shouldRestart = restartAfterUpdate

Task.detached {
let success = Self.runSync(brew, arguments: ["upgrade", "legionio"])

if success && shouldRestart {
await ServiceManager.shared.restartService(.legionio)
}

await MainActor.run {
if success {
self.items.removeAll(where: \.isLegionio)
} else {
if let idx = self.items.firstIndex(where: \.isLegionio) {
self.items[idx].isUpdating = false
}
}
}
}
}

/// Auto-update a lex-* gem silently. legion-gem keeps the old version installed.
private func autoUpdateGem(_ item: UpdateItem) {
private func updateInterlink(_ item: UpdateItem) {
guard let idx = items.firstIndex(where: { $0.id == item.id }) else { return }
items[idx].isUpdating = true

let legionGem = resolvedLegionGemPath
let name = item.name
let brew = resolvedBrewPath

Task.detached {
let success = Self.runSync(legionGem, arguments: ["update", name])
let success = Self.runSync(brew, arguments: ["upgrade", "legion-interlink"])

await MainActor.run {
if success {
self.items.removeAll { $0.id == item.id }
Expand All @@ -274,6 +302,23 @@ class UpdateManager: ObservableObject {
}
}
}

if success {
await Self.relaunchInterlink()
}
}
}

private nonisolated static func relaunchInterlink() {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
let bundlePath = Bundle.main.bundlePath
let process = Process()
process.executableURL = URL(fileURLWithPath: "/bin/sh")
process.arguments = ["-c", "sleep 3 && open '\(bundlePath)'"]
process.standardOutput = FileHandle.nullDevice
process.standardError = FileHandle.nullDevice
try? process.run()
NSApplication.shared.terminate(nil)
}
}

Expand Down
16 changes: 15 additions & 1 deletion Sources/UpdatesTab.swift
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,21 @@ struct UpdatesTab: View {
}

Toggle(isOn: $updateManager.autoUpdateLex) {
Text("auto-update lex")
Text("auto-update gems")
.font(.system(size: 9, design: .monospaced))
.foregroundColor(TerminalTheme.textDim)
}
.toggleStyle(TerminalCheckboxStyle())

Toggle(isOn: $updateManager.autoUpgradeLegionio) {
Text("auto-upgrade cli")
.font(.system(size: 9, design: .monospaced))
.foregroundColor(TerminalTheme.textDim)
}
.toggleStyle(TerminalCheckboxStyle())

Toggle(isOn: $updateManager.restartAfterUpdate) {
Text("restart daemon")
.font(.system(size: 9, design: .monospaced))
.foregroundColor(TerminalTheme.textDim)
}
Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
2.3.6
2.4.1
Loading