diff --git a/apps/macos/Sources/Clawdis/AgeFormatting.swift b/apps/macos/Sources/Clawdis/AgeFormatting.swift new file mode 100644 index 000000000..f992c2d95 --- /dev/null +++ b/apps/macos/Sources/Clawdis/AgeFormatting.swift @@ -0,0 +1,17 @@ +import Foundation + +// Human-friendly age string (e.g., "2m ago"). +func age(from date: Date, now: Date = .init()) -> String { + let seconds = max(0, Int(now.timeIntervalSince(date))) + let minutes = seconds / 60 + let hours = minutes / 60 + let days = hours / 24 + + if seconds < 60 { return "just now" } + if minutes == 1 { return "1 minute ago" } + if minutes < 60 { return "\(minutes)m ago" } + if hours == 1 { return "1 hour ago" } + if hours < 24 { return "\(hours)h ago" } + if days == 1 { return "yesterday" } + return "\(days)d ago" +} diff --git a/apps/macos/Sources/Clawdis/CLIInstaller.swift b/apps/macos/Sources/Clawdis/CLIInstaller.swift new file mode 100644 index 000000000..eab6e337e --- /dev/null +++ b/apps/macos/Sources/Clawdis/CLIInstaller.swift @@ -0,0 +1,102 @@ +import Foundation + +@MainActor +enum CLIInstaller { + private static func embeddedHelperURL() -> URL { + Bundle.main.bundleURL.appendingPathComponent("Contents/Resources/Relay/clawdis") + } + + static func installedLocation() -> String? { + self.installedLocation( + searchPaths: cliHelperSearchPaths, + embeddedHelper: self.embeddedHelperURL(), + fileManager: .default) + } + + static func installedLocation( + searchPaths: [String], + embeddedHelper: URL, + fileManager: FileManager) -> String? + { + let embedded = embeddedHelper.resolvingSymlinksInPath() + + for basePath in searchPaths { + let candidate = URL(fileURLWithPath: basePath).appendingPathComponent("clawdis").path + var isDirectory: ObjCBool = false + + guard fileManager.fileExists(atPath: candidate, isDirectory: &isDirectory), + !isDirectory.boolValue + else { + continue + } + + guard fileManager.isExecutableFile(atPath: candidate) else { continue } + + let resolved = URL(fileURLWithPath: candidate).resolvingSymlinksInPath() + if resolved == embedded { + return candidate + } + } + + return nil + } + + static func isInstalled() -> Bool { + self.installedLocation() != nil + } + + static func install(statusHandler: @escaping @Sendable (String) async -> Void) async { + let helper = self.embeddedHelperURL() + guard FileManager.default.isExecutableFile(atPath: helper.path) else { + await statusHandler( + "Embedded CLI missing in bundle; repackage via scripts/package-mac-app.sh " + + "(or restart-mac.sh without SKIP_GATEWAY_PACKAGE=1).") + return + } + + let targets = cliHelperSearchPaths.map { "\($0)/clawdis" } + let result = await self.privilegedSymlink(source: helper.path, targets: targets) + await statusHandler(result) + } + + private static func privilegedSymlink(source: String, targets: [String]) async -> String { + let escapedSource = self.shellEscape(source) + let targetList = targets.map(self.shellEscape).joined(separator: " ") + let cmds = [ + "mkdir -p /usr/local/bin /opt/homebrew/bin", + targets.map { "ln -sf \(escapedSource) \($0)" }.joined(separator: "; "), + ].joined(separator: "; ") + + let script = """ + do shell script "\(cmds)" with administrator privileges + """ + + let proc = Process() + proc.executableURL = URL(fileURLWithPath: "/usr/bin/osascript") + proc.arguments = ["-e", script] + + let pipe = Pipe() + proc.standardOutput = pipe + proc.standardError = pipe + + do { + try proc.run() + proc.waitUntilExit() + let data = pipe.fileHandleForReading.readToEndSafely() + let output = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if proc.terminationStatus == 0 { + return output.isEmpty ? "CLI helper linked into \(targetList)" : output + } + if output.lowercased().contains("user canceled") { + return "Install canceled" + } + return "Failed to install CLI helper: \(output)" + } catch { + return "Failed to run installer: \(error.localizedDescription)" + } + } + + private static func shellEscape(_ path: String) -> String { + "'" + path.replacingOccurrences(of: "'", with: "'\"'\"'") + "'" + } +} diff --git a/apps/macos/Sources/Clawdis/Utilities.swift b/apps/macos/Sources/Clawdis/CommandResolver.swift similarity index 64% rename from apps/macos/Sources/Clawdis/Utilities.swift rename to apps/macos/Sources/Clawdis/CommandResolver.swift index e863aef7f..d418cd5e0 100644 --- a/apps/macos/Sources/Clawdis/Utilities.swift +++ b/apps/macos/Sources/Clawdis/CommandResolver.swift @@ -1,237 +1,5 @@ -import AppKit import Foundation -extension ProcessInfo { - var isPreview: Bool { - self.environment["XCODE_RUNNING_FOR_PREVIEWS"] == "1" - } - - var isRunningTests: Bool { - // SwiftPM tests load one or more `.xctest` bundles. With Swift Testing, `Bundle.main` is not - // guaranteed to be the `.xctest` bundle, so check all loaded bundles. - if Bundle.allBundles.contains(where: { $0.bundleURL.pathExtension == "xctest" }) { return true } - if Bundle.main.bundleURL.pathExtension == "xctest" { return true } - - // Backwards-compatible fallbacks for runners that still set XCTest env vars. - return self.environment["XCTestConfigurationFilePath"] != nil - || self.environment["XCTestBundlePath"] != nil - || self.environment["XCTestSessionIdentifier"] != nil - } -} - -enum LaunchdManager { - private static func runLaunchctl(_ args: [String]) { - let process = Process() - process.launchPath = "/bin/launchctl" - process.arguments = args - try? process.run() - } - - static func startClawdis() { - let userTarget = "gui/\(getuid())/\(launchdLabel)" - self.runLaunchctl(["kickstart", "-k", userTarget]) - } - - static func stopClawdis() { - let userTarget = "gui/\(getuid())/\(launchdLabel)" - self.runLaunchctl(["stop", userTarget]) - } -} - -enum LaunchAgentManager { - private static var plistURL: URL { - FileManager.default.homeDirectoryForCurrentUser - .appendingPathComponent("Library/LaunchAgents/com.steipete.clawdis.plist") - } - - static func status() async -> Bool { - guard FileManager.default.fileExists(atPath: self.plistURL.path) else { return false } - let result = await self.runLaunchctl(["print", "gui/\(getuid())/\(launchdLabel)"]) - return result == 0 - } - - static func set(enabled: Bool, bundlePath: String) async { - if enabled { - self.writePlist(bundlePath: bundlePath) - _ = await self.runLaunchctl(["bootout", "gui/\(getuid())/\(launchdLabel)"]) - _ = await self.runLaunchctl(["bootstrap", "gui/\(getuid())", self.plistURL.path]) - _ = await self.runLaunchctl(["kickstart", "-k", "gui/\(getuid())/\(launchdLabel)"]) - } else { - // Disable autostart going forward but leave the current app running. - // bootout would terminate the launchd job immediately (and crash the app if launched via agent). - try? FileManager.default.removeItem(at: self.plistURL) - } - } - - private static func writePlist(bundlePath: String) { - let plist = """ - - - - - Label - com.steipete.clawdis - ProgramArguments - - \(bundlePath)/Contents/MacOS/Clawdis - - WorkingDirectory - \(FileManager.default.homeDirectoryForCurrentUser.path) - RunAtLoad - - KeepAlive - - EnvironmentVariables - - PATH - \(CommandResolver.preferredPaths().joined(separator: ":")) - - StandardOutPath - \(LogLocator.launchdLogPath) - StandardErrorPath - \(LogLocator.launchdLogPath) - - - """ - try? plist.write(to: self.plistURL, atomically: true, encoding: .utf8) - } - - @discardableResult - private static func runLaunchctl(_ args: [String]) async -> Int32 { - await Task.detached(priority: .utility) { () -> Int32 in - let process = Process() - process.launchPath = "/bin/launchctl" - process.arguments = args - process.standardOutput = Pipe() - process.standardError = Pipe() - do { - try process.run() - process.waitUntilExit() - return process.terminationStatus - } catch { - return -1 - } - }.value - } -} - -// Human-friendly age string (e.g., "2m ago"). -func age(from date: Date, now: Date = .init()) -> String { - let seconds = max(0, Int(now.timeIntervalSince(date))) - let minutes = seconds / 60 - let hours = minutes / 60 - let days = hours / 24 - - if seconds < 60 { return "just now" } - if minutes == 1 { return "1 minute ago" } - if minutes < 60 { return "\(minutes)m ago" } - if hours == 1 { return "1 hour ago" } - if hours < 24 { return "\(hours)h ago" } - if days == 1 { return "yesterday" } - return "\(days)d ago" -} - -@MainActor -enum CLIInstaller { - private static func embeddedHelperURL() -> URL { - Bundle.main.bundleURL.appendingPathComponent("Contents/Resources/Relay/clawdis") - } - - static func installedLocation() -> String? { - self.installedLocation( - searchPaths: cliHelperSearchPaths, - embeddedHelper: self.embeddedHelperURL(), - fileManager: .default) - } - - static func installedLocation( - searchPaths: [String], - embeddedHelper: URL, - fileManager: FileManager) -> String? - { - let embedded = embeddedHelper.resolvingSymlinksInPath() - - for basePath in searchPaths { - let candidate = URL(fileURLWithPath: basePath).appendingPathComponent("clawdis").path - var isDirectory: ObjCBool = false - - guard fileManager.fileExists(atPath: candidate, isDirectory: &isDirectory), - !isDirectory.boolValue - else { - continue - } - - guard fileManager.isExecutableFile(atPath: candidate) else { continue } - - let resolved = URL(fileURLWithPath: candidate).resolvingSymlinksInPath() - if resolved == embedded { - return candidate - } - } - - return nil - } - - static func isInstalled() -> Bool { - self.installedLocation() != nil - } - - static func install(statusHandler: @escaping @Sendable (String) async -> Void) async { - let helper = self.embeddedHelperURL() - guard FileManager.default.isExecutableFile(atPath: helper.path) else { - await statusHandler( - "Embedded CLI missing in bundle; repackage via scripts/package-mac-app.sh " + - "(or restart-mac.sh without SKIP_GATEWAY_PACKAGE=1).") - return - } - - let targets = cliHelperSearchPaths.map { "\($0)/clawdis" } - let result = await self.privilegedSymlink(source: helper.path, targets: targets) - await statusHandler(result) - } - - private static func privilegedSymlink(source: String, targets: [String]) async -> String { - let escapedSource = self.shellEscape(source) - let targetList = targets.map(self.shellEscape).joined(separator: " ") - let cmds = [ - "mkdir -p /usr/local/bin /opt/homebrew/bin", - targets.map { "ln -sf \(escapedSource) \($0)" }.joined(separator: "; "), - ].joined(separator: "; ") - - let script = """ - do shell script "\(cmds)" with administrator privileges - """ - - let proc = Process() - proc.executableURL = URL(fileURLWithPath: "/usr/bin/osascript") - proc.arguments = ["-e", script] - - let pipe = Pipe() - proc.standardOutput = pipe - proc.standardError = pipe - - do { - try proc.run() - proc.waitUntilExit() - let data = pipe.fileHandleForReading.readToEndSafely() - let output = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - if proc.terminationStatus == 0 { - return output.isEmpty ? "CLI helper linked into \(targetList)" : output - } - if output.lowercased().contains("user canceled") { - return "Install canceled" - } - return "Failed to install CLI helper: \(output)" - } catch { - return "Failed to run installer: \(error.localizedDescription)" - } - } - - private static func shellEscape(_ path: String) -> String { - "'" + path.replacingOccurrences(of: "'", with: "'\"'\"'") + "'" - } -} - enum CommandResolver { private static let projectRootDefaultsKey = "clawdis.gatewayProjectRootPath" private static let helperName = "clawdis" diff --git a/apps/macos/Sources/Clawdis/LaunchAgentManager.swift b/apps/macos/Sources/Clawdis/LaunchAgentManager.swift new file mode 100644 index 000000000..67abe06ec --- /dev/null +++ b/apps/macos/Sources/Clawdis/LaunchAgentManager.swift @@ -0,0 +1,78 @@ +import Foundation + +enum LaunchAgentManager { + private static var plistURL: URL { + FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent("Library/LaunchAgents/com.steipete.clawdis.plist") + } + + static func status() async -> Bool { + guard FileManager.default.fileExists(atPath: self.plistURL.path) else { return false } + let result = await self.runLaunchctl(["print", "gui/\(getuid())/\(launchdLabel)"]) + return result == 0 + } + + static func set(enabled: Bool, bundlePath: String) async { + if enabled { + self.writePlist(bundlePath: bundlePath) + _ = await self.runLaunchctl(["bootout", "gui/\(getuid())/\(launchdLabel)"]) + _ = await self.runLaunchctl(["bootstrap", "gui/\(getuid())", self.plistURL.path]) + _ = await self.runLaunchctl(["kickstart", "-k", "gui/\(getuid())/\(launchdLabel)"]) + } else { + // Disable autostart going forward but leave the current app running. + // bootout would terminate the launchd job immediately (and crash the app if launched via agent). + try? FileManager.default.removeItem(at: self.plistURL) + } + } + + private static func writePlist(bundlePath: String) { + let plist = """ + + + + + Label + com.steipete.clawdis + ProgramArguments + + \(bundlePath)/Contents/MacOS/Clawdis + + WorkingDirectory + \(FileManager.default.homeDirectoryForCurrentUser.path) + RunAtLoad + + KeepAlive + + EnvironmentVariables + + PATH + \(CommandResolver.preferredPaths().joined(separator: ":")) + + StandardOutPath + \(LogLocator.launchdLogPath) + StandardErrorPath + \(LogLocator.launchdLogPath) + + + """ + try? plist.write(to: self.plistURL, atomically: true, encoding: .utf8) + } + + @discardableResult + private static func runLaunchctl(_ args: [String]) async -> Int32 { + await Task.detached(priority: .utility) { () -> Int32 in + let process = Process() + process.launchPath = "/bin/launchctl" + process.arguments = args + process.standardOutput = Pipe() + process.standardError = Pipe() + do { + try process.run() + process.waitUntilExit() + return process.terminationStatus + } catch { + return -1 + } + }.value + } +} diff --git a/apps/macos/Sources/Clawdis/LaunchdManager.swift b/apps/macos/Sources/Clawdis/LaunchdManager.swift new file mode 100644 index 000000000..1b8f6884b --- /dev/null +++ b/apps/macos/Sources/Clawdis/LaunchdManager.swift @@ -0,0 +1,20 @@ +import Foundation + +enum LaunchdManager { + private static func runLaunchctl(_ args: [String]) { + let process = Process() + process.launchPath = "/bin/launchctl" + process.arguments = args + try? process.run() + } + + static func startClawdis() { + let userTarget = "gui/\(getuid())/\(launchdLabel)" + self.runLaunchctl(["kickstart", "-k", userTarget]) + } + + static func stopClawdis() { + let userTarget = "gui/\(getuid())/\(launchdLabel)" + self.runLaunchctl(["stop", userTarget]) + } +} diff --git a/apps/macos/Sources/Clawdis/ProcessInfo+Clawdis.swift b/apps/macos/Sources/Clawdis/ProcessInfo+Clawdis.swift new file mode 100644 index 000000000..aacf7e83c --- /dev/null +++ b/apps/macos/Sources/Clawdis/ProcessInfo+Clawdis.swift @@ -0,0 +1,19 @@ +import Foundation + +extension ProcessInfo { + var isPreview: Bool { + self.environment["XCODE_RUNNING_FOR_PREVIEWS"] == "1" + } + + var isRunningTests: Bool { + // SwiftPM tests load one or more `.xctest` bundles. With Swift Testing, `Bundle.main` is not + // guaranteed to be the `.xctest` bundle, so check all loaded bundles. + if Bundle.allBundles.contains(where: { $0.bundleURL.pathExtension == "xctest" }) { return true } + if Bundle.main.bundleURL.pathExtension == "xctest" { return true } + + // Backwards-compatible fallbacks for runners that still set XCTest env vars. + return self.environment["XCTestConfigurationFilePath"] != nil + || self.environment["XCTestBundlePath"] != nil + || self.environment["XCTestSessionIdentifier"] != nil + } +}