diff --git a/CHANGELOG.md b/CHANGELOG.md index eb0937d..c88feea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 `, 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 diff --git a/Sources/ClientsTab.swift b/Sources/ClientsTab.swift index 5c51046..4202c88 100644 --- a/Sources/ClientsTab.swift +++ b/Sources/ClientsTab.swift @@ -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") || @@ -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) @@ -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) @@ -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) { @@ -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 @@ -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() diff --git a/Sources/UpdateManager.swift b/Sources/UpdateManager.swift index c716ca8..3d467b5 100644 --- a/Sources/UpdateManager.swift +++ b/Sources/UpdateManager.swift @@ -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() } @@ -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) @@ -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() @@ -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 } @@ -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) } } diff --git a/Sources/UpdatesTab.swift b/Sources/UpdatesTab.swift index ef76b84..a681cea 100644 --- a/Sources/UpdatesTab.swift +++ b/Sources/UpdatesTab.swift @@ -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) } diff --git a/VERSION b/VERSION index e75da3e..005119b 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.3.6 +2.4.1