| 1 | import AppKit |
| 2 | import SwiftUI |
| 3 | |
| 4 | // 🤖 Settings → Advanced → "Command-line tool": install the bundled eleven CLI |
| 5 | // (Contents/Helpers/eleven, embedded by the "Embed eleven CLI" build phase — |
| 6 | // see ios/project.yml and docs/eleven-cli.md) onto the user's PATH. |
| 7 | // |
| 8 | // The app is sandboxed, so it can't just write /usr/local/bin: the Install |
| 9 | // button runs an NSOpenPanel (the powerbox grants write access to whatever |
| 10 | // folder the user picks) and drops a symlink to the bundled binary there. When |
| 11 | // even that fails — stock /usr/local/bin is root-owned — we fall back to |
| 12 | // showing the one Terminal command that does it, with a Copy button. A symlink |
| 13 | // (not a copy) means Sparkle updates update the CLI too. |
| 14 | // The Advanced tab (Settings window, LChatMacApp): power-user machinery that |
| 15 | // would crowd General — today just the CLI install (Paul, 2026-07-15). |
| 16 | struct MacAdvancedSettingsView: View { |
| 17 | var body: some View { |
| 18 | Form { |
| 19 | CLIToolSection() |
| 20 | // CLIToolSection renders nothing when the helper isn't embedded |
| 21 | // (a build without Go) — a wholly blank tab would read as broken, |
| 22 | // so say the quiet part. |
| 23 | if !FileManager.default.fileExists( |
| 24 | atPath: Bundle.main.bundleURL.appendingPathComponent("Contents/Helpers/eleven").path) { |
| 25 | Section { |
| 26 | Text("The command-line tool isn't included in this build.") |
| 27 | .font(.footnote).foregroundStyle(.secondary) |
| 28 | } |
| 29 | } |
| 30 | PerfSettingsSection() |
| 31 | } |
| 32 | .formStyle(.grouped) |
| 33 | // No frame — the Settings window's single fixed size lives on the |
| 34 | // TabView (LChatMacApp), same as every other pane. |
| 35 | } |
| 36 | } |
| 37 | |
| 38 | struct CLIToolSection: View { |
| 39 | @AppStorage("cliInstalledPath") private var installedPath = "" |
| 40 | @State private var note: String? |
| 41 | @State private var fallbackCommand: String? |
| 42 | |
| 43 | private var bundled: URL { |
| 44 | Bundle.main.bundleURL.appendingPathComponent("Contents/Helpers/eleven") |
| 45 | } |
| 46 | |
| 47 | var body: some View { |
| 48 | // Older builds (or a build without Go on the machine) carry no helper; |
| 49 | // don't offer to install what isn't there. |
| 50 | if FileManager.default.fileExists(atPath: bundled.path) { |
| 51 | Section("Command-line tool") { |
| 52 | Text("Chat from your terminal: `eleven chats`, `eleven chat`, and `eleven send`. It signs in with the same passkey (`eleven signin`).") |
| 53 | .font(.callout) |
| 54 | .foregroundStyle(.secondary) |
| 55 | HStack { |
| 56 | if installedPath.isEmpty { |
| 57 | Text("Not installed").foregroundStyle(.secondary) |
| 58 | } else { |
| 59 | Label(installedPath, systemImage: installStillValid ? "checkmark.circle" : "exclamationmark.triangle") |
| 60 | .foregroundStyle(.secondary) |
| 61 | .font(.callout) |
| 62 | } |
| 63 | Spacer() |
| 64 | Button(installedPath.isEmpty ? "Install…" : "Reinstall…") { install() } |
| 65 | } |
| 66 | if let note { |
| 67 | Text(note).font(.footnote).foregroundStyle(.secondary) |
| 68 | } |
| 69 | if let fallbackCommand { |
| 70 | // That folder wasn't writable even via the powerbox (stock |
| 71 | // /usr/local/bin is root-owned) — hand over the one-liner. |
| 72 | VStack(alignment: .leading, spacing: 6) { |
| 73 | Text("That folder needs an administrator — run this in Terminal instead:") |
| 74 | .font(.footnote) |
| 75 | HStack { |
| 76 | Text(fallbackCommand) |
| 77 | .font(.system(.footnote, design: .monospaced)) |
| 78 | .textSelection(.enabled) |
| 79 | .lineLimit(2) |
| 80 | Button("Copy") { |
| 81 | NSPasteboard.general.clearContents() |
| 82 | NSPasteboard.general.setString(fallbackCommand, forType: .string) |
| 83 | } |
| 84 | } |
| 85 | } |
| 86 | } |
| 87 | } |
| 88 | } |
| 89 | } |
| 90 | |
| 91 | // A best-effort staleness check: the symlink should still resolve to a |
| 92 | // binary (the app may have moved). Sandbox may deny the stat — treat |
| 93 | // unreadable as fine rather than alarm on a false negative. |
| 94 | private var installStillValid: Bool { |
| 95 | guard !installedPath.isEmpty else { return false } |
| 96 | let dest = (try? FileManager.default.destinationOfSymbolicLink(atPath: installedPath)) ?? bundled.path |
| 97 | return FileManager.default.fileExists(atPath: dest) |
| 98 | } |
| 99 | |
| 100 | private func install() { |
| 101 | note = nil |
| 102 | fallbackCommand = nil |
| 103 | let panel = NSOpenPanel() |
| 104 | panel.canChooseDirectories = true |
| 105 | panel.canChooseFiles = false |
| 106 | panel.canCreateDirectories = true |
| 107 | panel.allowsMultipleSelection = false |
| 108 | panel.message = "Choose where to install the eleven command. /usr/local/bin keeps it on your PATH." |
| 109 | panel.prompt = "Install Here" |
| 110 | panel.directoryURL = URL(fileURLWithPath: "/usr/local/bin", isDirectory: true) |
| 111 | guard panel.runModal() == .OK, let dir = panel.url else { return } |
| 112 | |
| 113 | let target = dir.appendingPathComponent("eleven") |
| 114 | let accessing = dir.startAccessingSecurityScopedResource() |
| 115 | defer { if accessing { dir.stopAccessingSecurityScopedResource() } } |
| 116 | do { |
| 117 | try? FileManager.default.removeItem(at: target) |
| 118 | try FileManager.default.createSymbolicLink(at: target, withDestinationURL: bundled) |
| 119 | installedPath = target.path |
| 120 | note = onPath(dir.path) |
| 121 | ? "Installed — open a new terminal and run `eleven signin`." |
| 122 | : "Installed. \(dir.path) isn’t usually on PATH — add it, or reinstall to /usr/local/bin." |
| 123 | } catch { |
| 124 | fallbackCommand = "sudo mkdir -p /usr/local/bin && sudo ln -sf \"\(bundled.path)\" /usr/local/bin/eleven" |
| 125 | } |
| 126 | } |
| 127 | |
| 128 | // The folders shells have on PATH without any profile edits. |
| 129 | private func onPath(_ dir: String) -> Bool { |
| 130 | ["/usr/local/bin", "/opt/homebrew/bin", "/usr/bin", "/bin"].contains(dir) |
| 131 | } |
| 132 | } |
| 133 | |