diff --git a/.gitignore b/.gitignore index 365c6f2..e9190fc 100644 --- a/.gitignore +++ b/.gitignore @@ -16,7 +16,6 @@ secrets.json _DESIGN_LANGUAGE.md # macOS app build output -macos/build/ # Tauri desktop app build output desktop/build/ diff --git a/Justfile b/Justfile index 44cd273..ab2b456 100644 --- a/Justfile +++ b/Justfile @@ -223,23 +223,6 @@ smoke: python3 scripts/smoke_e2e.py "${endpoint}" -# macOS app: build cot.app (SwiftUI shell + frozen collector + dashboard). -# just mac build full build -# just mac app Swift shell only, for iterating on the UI -# just mac run build and launch -# just mac clean drop macos/build -mac action="build": - #!/usr/bin/env sh - set -eu - - case "{{action}}" in - build) macos/build.sh ;; - app) macos/build.sh --app-only ;; - run) macos/build.sh --run ;; - clean) rm -rf macos/build && printf '%s\n' "removed macos/build" ;; - *) printf '%s\n' "unknown action: {{action}} (build|app|run|clean)" >&2; exit 2 ;; - esac - # Fresh web install in a sandbox: runs `curl cot.run/install | sh` with HOME in a # temp dir, so the collector container, ~/.cot and hook files never touch yours. # just fresh collector built from origin/main diff --git a/backend/tests/test_opencode.py b/backend/tests/test_opencode.py index 3599e6a..d4046fd 100644 --- a/backend/tests/test_opencode.py +++ b/backend/tests/test_opencode.py @@ -75,7 +75,7 @@ def test_manual_plugin_inferred_without_its_own_manifest_entry(fresh_db, monkeyp def test_desktop_spec_serves_the_plugin(tmp_path, monkeypatch): repo = Path(__file__).resolve().parents[2] - tree = ast.parse((repo / "macos/packaging/cot-collector.spec").read_text()) + tree = ast.parse((repo / "desktop/packaging/cot-collector.spec").read_text()) analysis = next(n for n in ast.walk(tree) if isinstance(n, ast.Call) and isinstance(n.func, ast.Name) and n.func.id == "Analysis") datas = next(k.value for k in analysis.keywords if k.arg == "datas") for entry in datas.elts: diff --git a/desktop/README.md b/desktop/README.md index c67f037..09cd714 100644 --- a/desktop/README.md +++ b/desktop/README.md @@ -7,13 +7,10 @@ The React UI and the backend are unchanged. The app bundles: - `Contents/Resources/cot-collector/`, the collector frozen by PyInstaller (onedir, so launches don't unpack anything), built from - `macos/packaging/cot-collector.spec`, which it shares with the Swift app + `desktop/packaging/cot-collector.spec` - `Contents/Resources/static/`, the vite build, passed to the collector as `COT_STATIC_DIR` -The Swift app in `macos/` uses the same bundle id (`run.cot.app`) and `~/.cot`. -Run one or the other, not both. - ## Build ```sh diff --git a/desktop/loading/index.html b/desktop/loading/index.html index e2afe6b..0fbe87d 100644 --- a/desktop/loading/index.html +++ b/desktop/loading/index.html @@ -6,46 +6,70 @@ cot + + + +
- +
cot.

Starting the collector…

diff --git a/macos/packaging/collector_entry.py b/desktop/packaging/collector_entry.py similarity index 100% rename from macos/packaging/collector_entry.py rename to desktop/packaging/collector_entry.py diff --git a/macos/packaging/cot-collector.spec b/desktop/packaging/cot-collector.spec similarity index 88% rename from macos/packaging/cot-collector.spec rename to desktop/packaging/cot-collector.spec index 6320afc..c09dee0 100644 --- a/macos/packaging/cot-collector.spec +++ b/desktop/packaging/cot-collector.spec @@ -1,12 +1,12 @@ # PyInstaller spec for the collector binary embedded in cot.app. -# Built from macos/build.sh; `pathex` points at backend/ so `app.main` resolves. +# Built by desktop/scripts/prepare-collector.sh; `pathex` points at backend/ so `app.main` resolves. import os REPO = os.environ["COT_REPO_ROOT"] a = Analysis( - [os.path.join(REPO, "macos", "packaging", "collector_entry.py")], + [os.path.join(REPO, "desktop", "packaging", "collector_entry.py")], pathex=[os.path.join(REPO, "backend")], binaries=[], datas=[ diff --git a/desktop/scripts/prepare-collector.sh b/desktop/scripts/prepare-collector.sh index 827af21..5c47b4c 100755 --- a/desktop/scripts/prepare-collector.sh +++ b/desktop/scripts/prepare-collector.sh @@ -5,8 +5,8 @@ # scripts/prepare-collector.sh always rebuild # scripts/prepare-collector.sh --if-missing skip when resources exist (tauri dev) # -# Reuses the Swift app's PyInstaller spec and entrypoint in macos/packaging, so -# both apps ship the same collector. Python deps go into desktop/build/venv. +# PyInstaller spec and entrypoint live in desktop/packaging. Python deps go +# into desktop/build/venv. set -euo pipefail DESKTOP_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" @@ -40,7 +40,7 @@ COT_REPO_ROOT="${REPO_ROOT}" "${VENV}/bin/pyinstaller" \ --noconfirm --clean --log-level WARN \ --distpath "${BUILD_DIR}/dist" \ --workpath "${BUILD_DIR}/work" \ - "${REPO_ROOT}/macos/packaging/cot-collector.spec" + "${DESKTOP_DIR}/packaging/cot-collector.spec" step "Staging resources" rm -rf "${RESOURCES}" diff --git a/desktop/src-tauri/src/collector.rs b/desktop/src-tauri/src/collector.rs index 36d317b..a2f89df 100644 --- a/desktop/src-tauri/src/collector.rs +++ b/desktop/src-tauri/src/collector.rs @@ -1,7 +1,6 @@ //! Where the collector lives and how its process is started and stopped. //! -//! Ported from macos/Sources/CollectorController.swift and -//! CollectorEndpoint.swift. The rules are the same with one change: if another +//! Ported from the original Swift app, with one change: if another //! collector already answers on the port, the app refuses to start rather than //! attaching to it, so there is never a second writer on ~/.cot/cot.db. diff --git a/macos/README.md b/macos/README.md deleted file mode 100644 index 7bc1310..0000000 --- a/macos/README.md +++ /dev/null @@ -1,108 +0,0 @@ -# cot.app — the macOS app - -A native SwiftUI shell around the collector. The collector runs in the -background as a child process of the app; the dashboard renders in a WKWebView -pointed at it. No Docker, no Python on the host. - -``` -cot.app/Contents/ - MacOS/cot SwiftUI shell (menu bar + window) - Resources/cot-collector/ the FastAPI collector, frozen by PyInstaller - Resources/static/ the dashboard, built by vite -``` - -## Build - -```bash -macos/build.sh # dashboard + collector + app → macos/build/cot.app -macos/build.sh --run # …and launch it -macos/build.sh --app-only # Swift only, when iterating on the shell -``` - -Python dependencies go into `macos/build/venv`; nothing is installed on the -host. Requires the Xcode Command Line Tools (`swiftc`, `codesign`) — full Xcode -is not needed. - -The bundle is ad-hoc signed, which is enough to run locally and to register the -login item. For a distributable build set `COT_SIGN_IDENTITY` to a Developer ID -Application identity; the script then also enables the hardened runtime. - -## Chrome - -There is no titlebar. The window is `fullSizeContentView` with a transparent, -title-less titlebar, so the dashboard renders edge to edge and its own header -row is the only header — the traffic lights sit on that row beside the wordmark. - -Two pieces make that work, and they share their geometry through -`HeaderMetrics`: - -- A user script pads the shell header down from the window edge and in past the - buttons. It marks only the header at the top of the page — the app has other - `
` elements nested inside pages — and re-marks on route changes. -- The titlebar is grown with an empty, hit-transparent accessory and the traffic - lights are re-centred in it, since AppKit centres them at y=16 and puts them - back there on every resize and full-screen transition. - -That leaves the window with nothing to drag by: a transparent titlebar hands -every event to the web view. `WindowDragHandle` puts a handle back in the empty -middle of the header row, clear of the wordmark and the header controls. It is -installed into the window's frame view, not the SwiftUI hierarchy — a -representable in an `.overlay`, or a subview of the content view, draws in the -right place but never receives the mouse, because the hosting view hit-tests the -SwiftUI tree, finds nothing interactive, and passes the event down to the page. - -The dashboard keeps its theme in `localStorage`, not in the system appearance, -so the window can't read `colorScheme` — the same user script reports -`data-theme` back and the window background and traffic lights follow the page. - -`packaging/make_icon.swift` draws `AppIcon.icns` at build time from the same -mark as `public/apple-touch-icon.svg`, rendering each size at native pixels -rather than downscaling one master. - -## How it behaves at launch - -The app resolves a port from `~/.cot/config.json` (default **31337**, same as -every other install path) and then: - -| Situation | What happens | -|-----------|--------------| -| Nothing on the port | Spawns the bundled collector and renders it | -| A collector serving the dashboard | Attaches and renders it — the Docker install keeps working | -| An API-only collector (`docker compose up` in dev) | Says so, and offers the Vite dev server instead | -| Port busy, no collector | Takes the next free port and rewrites `config.json` so hooks follow | - -Attaching rather than spawning is deliberate: two uvicorn processes writing one -SQLite file is the failure mode worth designing around. - -## Lifetime - -The collector is meant to outlive the window and die with the app. - -- Closing the dashboard window leaves it collecting; the menu bar item stays. -- Quit, or SIGTERM, stops it through the app. -- Crash or `kill -9` is covered by a watchdog thread inside the collector that - polls its parent pid and exits when the app is gone. -- A stale collector from a previous run is reaped at launch via - `~/.cot/app-collector.pid`. - -## State - -Identical to every other install — nothing is app-specific: - -| Path | What | -|------|------| -| `~/.cot/cot.db` | traces | -| `~/.cot/config.json` | endpoint the bridge reads | -| `~/.cot/logs/collector.log` | collector stdout/stderr | -| `~/.cot/app-collector.pid` | pid of the collector this app spawned | - -Agent hooks are installed from the menu bar, which runs the same -`install.sh` the curl path uses — served by the collector the app just started. - -## Overrides - -| Variable | Purpose | -|----------|---------| -| `COT_PORT` | Force a port, bypassing `config.json` (useful for testing a second instance) | -| `COT_SIGN_IDENTITY` | Developer ID identity for a distributable build | -| `COT_PYTHON` | Python used to create the build venv (default `python3`) | diff --git a/macos/Resources/Info.plist b/macos/Resources/Info.plist deleted file mode 100644 index 9116538..0000000 --- a/macos/Resources/Info.plist +++ /dev/null @@ -1,37 +0,0 @@ - - - - - CFBundleName - cot - CFBundleDisplayName - cot - CFBundleExecutable - cot - CFBundleIconFile - AppIcon - CFBundleIdentifier - run.cot.app - CFBundlePackageType - APPL - CFBundleShortVersionString - __COT_VERSION__ - CFBundleVersion - __COT_VERSION__ - LSMinimumSystemVersion - 14.0 - NSHighResolutionCapable - - NSSupportsAutomaticTermination - - NSSupportsSuddenTermination - - NSAppleEventsUsageDescription - cot opens Terminal to run the agent hook installer. - NSAppTransportSecurity - - NSAllowsLocalNetworking - - - - diff --git a/macos/Resources/cot.entitlements b/macos/Resources/cot.entitlements deleted file mode 100644 index 16c0494..0000000 --- a/macos/Resources/cot.entitlements +++ /dev/null @@ -1,15 +0,0 @@ - - - - - - com.apple.security.app-sandbox - - com.apple.security.cs.allow-jit - - com.apple.security.cs.allow-unsigned-executable-memory - - com.apple.security.cs.disable-library-validation - - - diff --git a/macos/Sources/BridgeInstaller.swift b/macos/Sources/BridgeInstaller.swift deleted file mode 100644 index c32fe73..0000000 --- a/macos/Sources/BridgeInstaller.swift +++ /dev/null @@ -1,33 +0,0 @@ -import Foundation - -/// Wires agent hooks by running the same `install.sh` the curl path uses, served -/// by the collector we just started. Interactive prompts need a terminal, so it -/// runs in Terminal.app rather than a headless Process. -enum BridgeInstaller { - static func openInstaller(port: Int) { - let command = "COT_ENDPOINT=http://127.0.0.1:\(port) " - + "curl -fsSL http://127.0.0.1:\(port)/install.sh | sh" - runInTerminal(command) - } - - static func openRepair(port: Int) { - let command = "COT_ENDPOINT=http://127.0.0.1:\(port) " - + "curl -fsSL http://127.0.0.1:\(port)/install.sh | sh -s -- --repair" - runInTerminal(command) - } - - private static func runInTerminal(_ command: String) { - let escaped = command.replacingOccurrences(of: "\\", with: "\\\\") - .replacingOccurrences(of: "\"", with: "\\\"") - let script = """ - tell application "Terminal" - activate - do script "\(escaped)" - end tell - """ - let task = Process() - task.executableURL = URL(fileURLWithPath: "/usr/bin/osascript") - task.arguments = ["-e", script] - try? task.run() - } -} diff --git a/macos/Sources/CollectorController.swift b/macos/Sources/CollectorController.swift deleted file mode 100644 index 92bdcfb..0000000 --- a/macos/Sources/CollectorController.swift +++ /dev/null @@ -1,268 +0,0 @@ -import Combine -import Foundation - -enum CollectorState: Equatable { - /// Nothing running and nothing to attach to. - case stopped - /// Spawning the bundled binary and waiting for `/health`. - case starting - /// The collector we spawned is serving. - case running(port: Int, health: CollectorHealth) - /// Someone else's collector (the Docker install, or `cot up`) already owns - /// the port. We render it instead of fighting over the database. - case attached(port: Int, health: CollectorHealth) - /// An API-only collector holds the port — `docker compose up` in dev, where - /// Vite serves the frontend. There is no dashboard to render, and taking the - /// port would give us two writers on one SQLite file. - case apiOnly(port: Int, health: CollectorHealth) - case failed(String) - - var port: Int? { - switch self { - case let .running(port, _), let .attached(port, _): return port - case let .apiOnly(port, _): return port - case .stopped, .starting, .failed: return nil - } - } - - /// Live *and* renderable. `apiOnly` is live but has no dashboard to show. - var isLive: Bool { - switch self { - case .running, .attached: return true - case .stopped, .starting, .apiOnly, .failed: return false - } - } - - /// Serving requests, whether or not it can render. - var isServing: Bool { port != nil } - - var summary: String { - switch self { - case .stopped: return "Collector stopped" - case .starting: return "Starting collector…" - case let .running(port, health): return "Running on \(port) · v\(health.version)" - case let .attached(port, health): return "Attached to \(port) · v\(health.version)" - case let .apiOnly(port, _): return "API-only collector on \(port)" - case let .failed(message): return "Failed — \(message)" - } - } -} - -/// Owns the lifetime of the collector process for the app: spawns the frozen -/// binary from `Contents/Resources`, keeps `~/.cot/config.json` pointed at it, -/// and tears it down on quit. -@MainActor -final class CollectorController: ObservableObject { - @Published private(set) var state: CollectorState = .stopped - - private var process: Process? - private var healthTimer: Timer? - - /// The frozen collector, laid down by macos/build.sh. - private var bundledBinary: URL? { - guard let resources = Bundle.main.resourceURL else { return nil } - let candidate = resources - .appendingPathComponent("cot-collector", isDirectory: true) - .appendingPathComponent("cot-collector") - return FileManager.default.isExecutableFile(atPath: candidate.path) ? candidate : nil - } - - /// Dashboard built by `npm run build` and copied into the bundle. - private var bundledStatic: URL? { - guard let resources = Bundle.main.resourceURL else { return nil } - let candidate = resources.appendingPathComponent("static", isDirectory: true) - var isDirectory: ObjCBool = false - FileManager.default.fileExists(atPath: candidate.path, isDirectory: &isDirectory) - return isDirectory.boolValue ? candidate : nil - } - - var dashboardURL: URL? { - guard state.isLive, let port = state.port else { return nil } - return CollectorEndpoint.url(port: port) - } - - // MARK: - Lifecycle - - func start() async { - guard !state.isServing, process == nil else { return } - state = .starting - - reapOrphanedCollector() - - let preferred = CollectorEndpoint.savedPort() ?? CollectorEndpoint.defaultPort - - // A collector may already be up — the Docker install, or a second copy of - // this app. Two writers on one SQLite file is the one thing worth - // avoiding, so attach rather than spawn. - if let health = await CollectorEndpoint.health(port: preferred) { - state = await CollectorEndpoint.servesDashboard(port: preferred) - ? .attached(port: preferred, health: health) - : .apiOnly(port: preferred, health: health) - beginHealthMonitoring() - return - } - - guard let binary = bundledBinary else { - state = .failed("Bundled collector missing — run macos/build.sh") - return - } - guard let port = CollectorEndpoint.firstFreePort(from: preferred) else { - state = .failed("No free port near \(preferred)") - return - } - - do { - try spawn(binary: binary, port: port) - } catch { - state = .failed(error.localizedDescription) - return - } - - // uvicorn binds in well under a second cold; the frozen binary unpacks - // first, so allow a generous window before calling it a failure. - for _ in 0..<60 { - if process?.isRunning != true { - state = .failed("Collector exited on startup — see \(CollectorEndpoint.logFile.path)") - return - } - if let health = await CollectorEndpoint.health(port: port) { - try? CollectorEndpoint.saveEndpoint(port: port) - state = .running(port: port, health: health) - beginHealthMonitoring() - return - } - try? await Task.sleep(nanoseconds: 250_000_000) - } - - stop() - state = .failed("Collector did not become healthy — see \(CollectorEndpoint.logFile.path)") - } - - func stop() { - healthTimer?.invalidate() - healthTimer = nil - - if let process, process.isRunning { - process.terminate() - // Give uvicorn a moment to close the SQLite connection cleanly. - let deadline = Date().addingTimeInterval(5) - while process.isRunning && Date() < deadline { - usleep(100_000) - } - if process.isRunning { - kill(process.processIdentifier, SIGKILL) - } - } - process = nil - try? FileManager.default.removeItem(at: CollectorEndpoint.pidFile) - state = .stopped - } - - func restart() async { - stop() - await start() - } - - // MARK: - Internals - - private func spawn(binary: URL, port: Int) throws { - let fileManager = FileManager.default - try fileManager.createDirectory( - at: CollectorEndpoint.logFile.deletingLastPathComponent(), - withIntermediateDirectories: true - ) - if !fileManager.fileExists(atPath: CollectorEndpoint.logFile.path) { - fileManager.createFile(atPath: CollectorEndpoint.logFile.path, contents: nil) - } - let log = try FileHandle(forWritingTo: CollectorEndpoint.logFile) - log.seekToEndOfFile() - - var environment = ProcessInfo.processInfo.environment - environment["COT_HOST"] = "127.0.0.1" - environment["COT_PORT"] = String(port) - environment["COT_DB_PATH"] = CollectorEndpoint.databaseFile.path - environment["PYTHONUNBUFFERED"] = "1" - // The collector exits on its own if this app dies without cleaning up. - environment["COT_PARENT_PID"] = String(ProcessInfo.processInfo.processIdentifier) - if let staticDir = bundledStatic { - environment["COT_STATIC_DIR"] = staticDir.path - } - - let task = Process() - task.executableURL = binary - task.environment = environment - task.standardOutput = log - task.standardError = log - task.terminationHandler = { [weak self] _ in - Task { @MainActor in self?.handleUnexpectedExit() } - } - try task.run() - process = task - try? String(task.processIdentifier).write( - to: CollectorEndpoint.pidFile, atomically: true, encoding: .utf8 - ) - } - - /// A collector we spawned that outlived its app — force quit, crash, or a - /// SIGKILL. Two uvicorn processes on one SQLite file is worth preventing, so - /// take the previous one down before claiming the port. - private func reapOrphanedCollector() { - guard let raw = try? String(contentsOf: CollectorEndpoint.pidFile, encoding: .utf8), - let pid = Int32(raw.trimmingCharacters(in: .whitespacesAndNewlines)), - pid > 0 - else { return } - - defer { try? FileManager.default.removeItem(at: CollectorEndpoint.pidFile) } - - // kill(pid, 0) only probes; ESRCH means the process is already gone. - guard kill(pid, 0) == 0 else { return } - kill(pid, SIGTERM) - - let deadline = Date().addingTimeInterval(5) - while kill(pid, 0) == 0 && Date() < deadline { - usleep(100_000) - } - if kill(pid, 0) == 0 { - kill(pid, SIGKILL) - } - } - - private func handleUnexpectedExit() { - guard case .running = state else { return } - state = .failed("Collector exited — see \(CollectorEndpoint.logFile.path)") - process = nil - } - - /// Cheap liveness poll so the menu bar reflects an externally stopped - /// collector (e.g. `cot down` against an attached container). - private func beginHealthMonitoring() { - healthTimer?.invalidate() - healthTimer = Timer.scheduledTimer(withTimeInterval: 10, repeats: true) { [weak self] _ in - Task { @MainActor in await self?.refreshHealth() } - } - } - - private func refreshHealth() async { - guard let port = state.port else { return } - let health = await CollectorEndpoint.health(port: port) - - switch (state, health) { - case let (.running, .some(health)): - state = .running(port: port, health: health) - case let (.attached, .some(health)): - state = .attached(port: port, health: health) - case let (.apiOnly, .some(health)): - state = await CollectorEndpoint.servesDashboard(port: port) - ? .attached(port: port, health: health) - : .apiOnly(port: port, health: health) - case (.attached, .none), (.apiOnly, .none): - // The container we were borrowing went away; take over the port. - state = .stopped - await start() - case (.running, .none): - break // terminationHandler reports the real failure. - default: - break - } - } -} diff --git a/macos/Sources/CollectorEndpoint.swift b/macos/Sources/CollectorEndpoint.swift deleted file mode 100644 index bc0de2a..0000000 --- a/macos/Sources/CollectorEndpoint.swift +++ /dev/null @@ -1,113 +0,0 @@ -import Darwin -import Foundation - -/// Everything about *where* the collector lives: the `~/.cot` home the Docker -/// install already uses, the endpoint the bridge reads, and free-port probing. -enum CollectorEndpoint { - static let defaultPort = 31337 - - static var cotHome: URL { - FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".cot", isDirectory: true) - } - - static var configFile: URL { cotHome.appendingPathComponent("config.json") } - static var databaseFile: URL { cotHome.appendingPathComponent("cot.db") } - static var logFile: URL { cotHome.appendingPathComponent("logs/collector.log") } - /// PID of the collector this app spawned, so a crashed or force-quit app - /// doesn't leave one running against the database. - static var pidFile: URL { cotHome.appendingPathComponent("app-collector.pid") } - - static func url(port: Int) -> URL { - URL(string: "http://127.0.0.1:\(port)")! - } - - /// Port recorded by a previous install, so the app keeps serving hooks that - /// were wired to a non-default port. `COT_PORT` overrides it for testing. - static func savedPort() -> Int? { - if let override = ProcessInfo.processInfo.environment["COT_PORT"], let port = Int(override) { - return port - } - guard let data = try? Data(contentsOf: configFile), - let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], - let endpoint = json["endpoint"] as? String, - let port = URLComponents(string: endpoint)?.port - else { return nil } - return port - } - - /// Point the bridge at whatever port we ended up on. Hooks read this file on - /// every event, so writing it is what makes a non-default port work. - static func saveEndpoint(port: Int) throws { - try FileManager.default.createDirectory(at: cotHome, withIntermediateDirectories: true) - let body = "{\"endpoint\": \"http://127.0.0.1:\(port)\"}\n" - try body.write(to: configFile, atomically: true, encoding: .utf8) - } - - /// True when nothing holds the port on the loopback interface. - static func isPortFree(_ port: Int) -> Bool { - let fd = socket(AF_INET, SOCK_STREAM, 0) - guard fd >= 0 else { return false } - defer { close(fd) } - - var yes: Int32 = 1 - setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &yes, socklen_t(MemoryLayout.size)) - - var addr = sockaddr_in() - addr.sin_family = sa_family_t(AF_INET) - addr.sin_port = UInt16(port).bigEndian - addr.sin_addr.s_addr = inet_addr("127.0.0.1") - - let bound = withUnsafePointer(to: &addr) { - $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { - Darwin.bind(fd, $0, socklen_t(MemoryLayout.size)) - } - } - return bound == 0 - } - - /// First free port at or after `start`, matching the installer's behaviour. - static func firstFreePort(from start: Int, attempts: Int = 32) -> Int? { - for port in start..<(start + attempts) where isPortFree(port) { - return port - } - return nil - } - - /// `/health` from a collector already listening on `port`, or nil. - static func health(port: Int, timeout: TimeInterval = 1.5) async -> CollectorHealth? { - var request = URLRequest(url: url(port: port).appendingPathComponent("health")) - request.timeoutInterval = timeout - request.cachePolicy = .reloadIgnoringLocalCacheData - - guard let (data, response) = try? await URLSession.shared.data(for: request), - (response as? HTTPURLResponse)?.statusCode == 200, - let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], - json["status"] as? String == "ok" - else { return nil } - - return CollectorHealth( - version: json["version"] as? String ?? "unknown", - databasePath: json["db_path"] as? String ?? databaseFile.path - ) - } - /// Whether this collector also serves the dashboard. The production image - /// and the frozen binary both do; a bare `docker compose` API container - /// does not, and rendering that in the window just shows JSON. - static func servesDashboard(port: Int, timeout: TimeInterval = 2) async -> Bool { - var request = URLRequest(url: url(port: port)) - request.timeoutInterval = timeout - request.cachePolicy = .reloadIgnoringLocalCacheData - - guard let (_, response) = try? await URLSession.shared.data(for: request), - let http = response as? HTTPURLResponse, http.statusCode == 200 - else { return false } - - let contentType = http.value(forHTTPHeaderField: "Content-Type") ?? "" - return contentType.contains("text/html") - } -} - -struct CollectorHealth: Equatable { - let version: String - let databasePath: String -} diff --git a/macos/Sources/CotApp.swift b/macos/Sources/CotApp.swift deleted file mode 100644 index 2280399..0000000 --- a/macos/Sources/CotApp.swift +++ /dev/null @@ -1,96 +0,0 @@ -import SwiftUI - -@main -struct CotApp: App { - @NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate - @StateObject private var collector = CollectorController() - @State private var reloadToken = 0 - - var body: some Scene { - Window("cot", id: "dashboard") { - DashboardScene(collector: collector, reloadToken: reloadToken) - .task { - appDelegate.collector = collector - await collector.start() - } - } - .defaultSize(width: 1280, height: 860) - .windowStyle(.hiddenTitleBar) - .commands { - CommandGroup(after: .toolbar) { - Button("Reload Dashboard") { reloadToken += 1 } - .keyboardShortcut("r", modifiers: .command) - } - CommandGroup(replacing: .newItem) {} - } - - MenuBarExtra { - MenuBarView(collector: collector, reloadToken: $reloadToken) - } label: { - Image(systemName: collector.state.isLive ? "circle.fill" : "circle") - .accessibilityLabel(collector.state.summary) - } - } -} - -private struct DashboardScene: View { - @ObservedObject var collector: CollectorController - let reloadToken: Int - /// Mirrors the dashboard's own theme; the status screens have no page to - /// read from, so they keep the last theme the dashboard reported. - @State private var theme: DashboardTheme = .light - - var body: some View { - Group { - if let url = collector.dashboardURL { - DashboardWebView(url: url, reloadToken: reloadToken) { theme = $0 } - } else { - CollectorStatusView(state: collector.state, theme: theme) { - Task { await collector.restart() } - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .background(theme.background) - } - } - .frame(minWidth: 900, minHeight: 600) - // The page runs under the traffic lights; the drag handle is installed - // into the window itself by WindowChrome. - .ignoresSafeArea(.all) - .background(WindowChrome(theme: theme)) - } -} - -/// The collector has to outlive the window — closing the dashboard should leave -/// traces being collected — but must not outlive the app. -final class AppDelegate: NSObject, NSApplicationDelegate { - @MainActor var collector: CollectorController? - private var signalSources: [DispatchSourceSignal] = [] - - func applicationDidFinishLaunching(_ notification: Notification) { - installSignalHandlers() - } - - func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { - false - } - - func applicationWillTerminate(_ notification: Notification) { - MainActor.assumeIsolated { collector?.stop() } - } - - /// `applicationWillTerminate` never fires for a plain SIGTERM (a `pkill`, or - /// a logout that runs out of patience), which would leave the collector - /// running. Catch the signal and shut it down first. - private func installSignalHandlers() { - for sig in [SIGTERM, SIGINT, SIGHUP] { - signal(sig, SIG_IGN) - let source = DispatchSource.makeSignalSource(signal: sig, queue: .main) - source.setEventHandler { - MainActor.assumeIsolated { self.collector?.stop() } - exit(0) - } - source.resume() - signalSources.append(source) - } - } -} diff --git a/macos/Sources/DashboardWebView.swift b/macos/Sources/DashboardWebView.swift deleted file mode 100644 index b9c0eb9..0000000 --- a/macos/Sources/DashboardWebView.swift +++ /dev/null @@ -1,210 +0,0 @@ -import SwiftUI -import WebKit - -/// Hosts the existing React dashboard. Nothing about the frontend changes — the -/// app is a native window around the same pages the browser gets. -struct DashboardWebView: NSViewRepresentable { - let url: URL - /// Bumping this forces a reload, e.g. after the collector restarts on a new port. - let reloadToken: Int - /// The page's theme, reported back so the native titlebar can match it. - let onThemeChange: (DashboardTheme) -> Void - - func makeCoordinator() -> Coordinator { Coordinator(onThemeChange: onThemeChange) } - - func makeNSView(context: Context) -> WKWebView { - let configuration = WKWebViewConfiguration() - configuration.websiteDataStore = .default() - - // Reports the page's theme and clears room for the traffic lights in the - // shell header — see `Coordinator.chromeBridge`. - let controller = WKUserContentController() - controller.add(context.coordinator, name: Coordinator.themeChannel) - controller.addUserScript( - WKUserScript(source: Coordinator.chromeBridge, injectionTime: .atDocumentEnd, forMainFrameOnly: true) - ) - configuration.userContentController = controller - - let webView = WKWebView(frame: .zero, configuration: configuration) - webView.navigationDelegate = context.coordinator - webView.allowsBackForwardNavigationGestures = true - webView.setValue(false, forKey: "drawsBackground") - webView.load(URLRequest(url: url)) - context.coordinator.loadedURL = url - context.coordinator.loadedToken = reloadToken - return webView - } - - func updateNSView(_ webView: WKWebView, context: Context) { - guard context.coordinator.loadedURL != url || context.coordinator.loadedToken != reloadToken - else { return } - context.coordinator.loadedURL = url - context.coordinator.loadedToken = reloadToken - webView.load(URLRequest(url: url)) - } - - final class Coordinator: NSObject, WKNavigationDelegate, WKScriptMessageHandler { - static let themeChannel = "cotTheme" - - /// Two jobs, both about making the window chromeless rather than - /// changing the dashboard: report the page's theme so the window - /// appearance can follow it, and clear a landing strip for the traffic - /// lights in the app shell's own header. - /// - /// The shell header's 32px content box is pinned below `topPadding` so - /// the row is not flush against the window edge, and the titlebar is - /// grown to match (see `HeaderMetrics`) so the traffic lights land on - /// that same row. The left pad clears the lights themselves. Only the - /// shell header is touched: the app has other
elements nested - /// inside pages. - static let chromeBridge = """ - (function () { - var CHROME_CLASS = 'cot-native-chrome'; - - var style = document.createElement('style'); - style.textContent = - '.' + CHROME_CLASS + '{padding-top:\(Int(HeaderMetrics.topPadding))px!important;' + - 'padding-left:\(Int(HeaderMetrics.contentLeading))px!important}'; - document.head.appendChild(style); - - var markShellHeader = function () { - var headers = document.querySelectorAll('header'); - var shell = null; - for (var i = 0; i < headers.length; i++) { - var box = headers[i].getBoundingClientRect(); - var spansWindow = box.width > window.innerWidth * 0.8; - // Already shifted, or sitting at the top edge and full width. - if (spansWindow && (box.top <= \(Int(HeaderMetrics.topPadding)) || headers[i].classList.contains(CHROME_CLASS))) { - shell = headers[i]; - break; - } - } - for (var j = 0; j < headers.length; j++) { - headers[j].classList.toggle(CHROME_CLASS, headers[j] === shell); - } - }; - - var sendTheme = function () { - window.webkit.messageHandlers.\(themeChannel).postMessage( - document.documentElement.getAttribute('data-theme') || 'light' - ); - }; - - new MutationObserver(sendTheme).observe(document.documentElement, { - attributes: true, attributeFilter: ['data-theme'] - }); - // Routes swap the shell header out, so re-mark on any body change. - new MutationObserver(markShellHeader).observe(document.body, { - childList: true, subtree: true - }); - window.addEventListener('hashchange', markShellHeader); - - markShellHeader(); - sendTheme(); - })(); - """ - - var loadedURL: URL? - var loadedToken = -1 - private let onThemeChange: (DashboardTheme) -> Void - - init(onThemeChange: @escaping (DashboardTheme) -> Void) { - self.onThemeChange = onThemeChange - } - - func userContentController( - _ controller: WKUserContentController, - didReceive message: WKScriptMessage - ) { - guard message.name == Self.themeChannel, - let raw = message.body as? String, - let theme = DashboardTheme(rawValue: raw) - else { return } - onThemeChange(theme) - } - - /// Keep the app on the local collector; anything else (docs, GitHub) - /// opens in the user's browser. - func webView( - _ webView: WKWebView, - decidePolicyFor navigationAction: WKNavigationAction, - decisionHandler: @escaping (WKNavigationActionPolicy) -> Void - ) { - guard let target = navigationAction.request.url else { - decisionHandler(.allow) - return - } - let isLocal = target.host == "127.0.0.1" || target.host == "localhost" - if isLocal || target.scheme == "about" || target.scheme == "blob" || target.scheme == "data" { - decisionHandler(.allow) - } else { - decisionHandler(.cancel) - NSWorkspace.shared.open(target) - } - } - } -} - -/// Shown while the collector is starting, or when it could not start at all. -struct CollectorStatusView: View { - let state: CollectorState - let theme: DashboardTheme - let onRetry: () -> Void - - var body: some View { - VStack(spacing: 14) { - switch state { - case .starting: - ProgressView() - Text("Starting the collector…") - .foregroundStyle(.secondary) - case let .apiOnly(port, health): - Image(systemName: "square.stack.3d.up.slash") - .font(.system(size: 28)) - .foregroundStyle(.secondary) - // String(port) — plain Int interpolation renders "31,337". - Text("A dev collector is using port \(String(port))") - .font(.headline) - .foregroundStyle(theme.foreground) - Text( - "cot v\(health.version) is serving the API there but not the dashboard — " - + "that's what `docker compose up` does, with Vite rendering the frontend " - + "separately. Stop it (`docker rm -f cot-api`) and retry to use the app's " - + "own collector." - ) - .font(.callout) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - HStack { - Button("Open Vite dev server") { - NSWorkspace.shared.open(URL(string: "http://localhost:4000")!) - } - Button("Retry", action: onRetry) - } - case let .failed(message): - Image(systemName: "exclamationmark.triangle") - .font(.system(size: 28)) - .foregroundStyle(.orange) - Text("The collector could not start") - .font(.headline) - .foregroundStyle(theme.foreground) - Text(message) - .font(.callout) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - .textSelection(.enabled) - Button("Try again", action: onRetry) - default: - Image(systemName: "moon.zzz") - .font(.system(size: 28)) - .foregroundStyle(.secondary) - Text("Collector stopped") - .font(.headline) - .foregroundStyle(theme.foreground) - Button("Start collector", action: onRetry) - } - } - .frame(maxWidth: 420) - .padding(40) - } -} diff --git a/macos/Sources/LoginItem.swift b/macos/Sources/LoginItem.swift deleted file mode 100644 index 2619a05..0000000 --- a/macos/Sources/LoginItem.swift +++ /dev/null @@ -1,25 +0,0 @@ -import Foundation -import ServiceManagement - -/// "Start cot at login" — SMAppService registers the whole app bundle, so the -/// collector comes back with it. -enum LoginItem { - static var isEnabled: Bool { - SMAppService.mainApp.status == .enabled - } - - @discardableResult - static func setEnabled(_ enabled: Bool) -> Bool { - do { - if enabled { - try SMAppService.mainApp.register() - } else { - try SMAppService.mainApp.unregister() - } - return true - } catch { - NSLog("cot: login item update failed — \(error.localizedDescription)") - return false - } - } -} diff --git a/macos/Sources/MenuBarView.swift b/macos/Sources/MenuBarView.swift deleted file mode 100644 index bf14228..0000000 --- a/macos/Sources/MenuBarView.swift +++ /dev/null @@ -1,73 +0,0 @@ -import SwiftUI - -struct MenuBarView: View { - @ObservedObject var collector: CollectorController - @Binding var reloadToken: Int - @Environment(\.openWindow) private var openWindow - @State private var launchAtLogin = LoginItem.isEnabled - - var body: some View { - Text(collector.state.summary) - - Divider() - - Button("Open Dashboard") { - openWindow(id: "dashboard") - NSApp.activate(ignoringOtherApps: true) - } - .disabled(!collector.state.isLive) - - Button("Copy Endpoint") { - guard let port = collector.state.port else { return } - NSPasteboard.general.clearContents() - NSPasteboard.general.setString( - CollectorEndpoint.url(port: port).absoluteString, forType: .string - ) - } - .disabled(!collector.state.isServing) - - Divider() - - if collector.state.isServing { - Button("Stop Collector") { collector.stop() } - Button("Restart Collector") { Task { await collector.restart() } } - } else { - Button("Start Collector") { Task { await collector.start() } } - } - - Divider() - - Button("Install Agent Hooks…") { - if let port = collector.state.port { BridgeInstaller.openInstaller(port: port) } - } - .disabled(!collector.state.isServing) - - Button("Repair Agent Hooks…") { - if let port = collector.state.port { BridgeInstaller.openRepair(port: port) } - } - .disabled(!collector.state.isServing) - - Button("Reveal Data Folder") { - NSWorkspace.shared.selectFile( - CollectorEndpoint.databaseFile.path, - inFileViewerRootedAtPath: CollectorEndpoint.cotHome.path - ) - } - - Button("Open Collector Log") { - NSWorkspace.shared.open(CollectorEndpoint.logFile) - } - - Toggle("Start cot at Login", isOn: $launchAtLogin) - .onChange(of: launchAtLogin) { _, newValue in - if !LoginItem.setEnabled(newValue) { - launchAtLogin = LoginItem.isEnabled - } - } - - Divider() - - Button("Quit cot") { NSApp.terminate(nil) } - .keyboardShortcut("q") - } -} diff --git a/macos/Sources/TitleBar.swift b/macos/Sources/TitleBar.swift deleted file mode 100644 index e2e8d5d..0000000 --- a/macos/Sources/TitleBar.swift +++ /dev/null @@ -1,191 +0,0 @@ -import SwiftUI - -/// Brand palette from _DESIGN_LANGUAGE.md. -enum Brand { - static let ink = Color(red: 0x11 / 255, green: 0x11 / 255, blue: 0x11 / 255) - static let cream = Color(red: 0xF4 / 255, green: 0xF0 / 255, blue: 0xEA / 255) -} - -/// The dashboard owns its theme (localStorage, not system appearance), so the -/// window has to hear about it from the page rather than read `colorScheme`. -enum DashboardTheme: String { - case light - case dark - - var background: Color { self == .dark ? Brand.ink : Brand.cream } - var foreground: Color { self == .dark ? Brand.cream : Brand.ink } - - var appearance: NSAppearance? { - NSAppearance(named: self == .dark ? .darkAqua : .aqua) - } -} - -/// Geometry shared by the window chrome and the injected page CSS: the header -/// row the dashboard draws, and where the traffic lights have to sit to land on -/// it. A standard titlebar centres its buttons at y=16, which would force the -/// header flush against the top edge — so the titlebar is grown instead and the -/// buttons re-centred in it, leaving the row real breathing room. -enum HeaderMetrics { - /// Height of the shell header's content box (a 32px control row). - static let rowHeight: CGFloat = 32 - /// Breathing room above that row. - static let topPadding: CGFloat = 14 - /// Where the row's centre lands, and so where the traffic lights must. - static var rowCenterY: CGFloat { topPadding + rowHeight / 2 } - /// Titlebar tall enough to hold the buttons at that centre. - static var titlebarHeight: CGFloat { rowCenterY * 2 } - /// Left edge of the page content, clear of the buttons. - static let contentLeading: CGFloat = 92 -} - -/// A chromeless window passes every event in the titlebar band straight through -/// to the web view, so there is nothing left to drag the window by. This puts a -/// real handle back in the empty middle of the shell header — clear of the -/// wordmark on the left and the header controls on the right, which stay -/// clickable because nothing covers them. -/// -/// It is installed into the window's frame view — the traffic lights' own -/// superview — rather than anywhere inside the SwiftUI hierarchy. Both a -/// representable in an `.overlay` and a subview of the content view draw in the -/// right place but never receive the mouse: the hosting view hit-tests the -/// SwiftUI tree, finds nothing interactive, and hands the event to the web view. -private final class WindowDragHandle: NSView { - static let leadingClearance: CGFloat = 150 - static let trailingClearance: CGFloat = 300 - - override func mouseDown(with event: NSEvent) { - if event.clickCount == 2 { - window?.performZoom(nil) - } else { - window?.performDrag(with: event) - } - } - - static func install(in window: NSWindow) { - guard let frameView = window.contentView?.superview, - !frameView.subviews.contains(where: { $0 is WindowDragHandle }) - else { return } - - let handle = WindowDragHandle(frame: .zero) - handle.translatesAutoresizingMaskIntoConstraints = false - frameView.addSubview(handle, positioned: .above, relativeTo: nil) - - NSLayoutConstraint.activate([ - handle.topAnchor.constraint(equalTo: frameView.topAnchor), - handle.heightAnchor.constraint( - equalToConstant: HeaderMetrics.rowCenterY + HeaderMetrics.rowHeight / 2 - ), - handle.leadingAnchor.constraint(equalTo: frameView.leadingAnchor, constant: leadingClearance), - handle.trailingAnchor.constraint(equalTo: frameView.trailingAnchor, constant: -trailingClearance), - ]) - } -} - -/// There is no titlebar view: the window is chromeless and the dashboard renders -/// edge to edge beneath the traffic lights, which the page's own header row is -/// shifted to sit alongside. This keeps the window's appearance in step with the -/// page so the traffic lights and resize chrome match it. -struct WindowChrome: NSViewRepresentable { - let theme: DashboardTheme - - func makeCoordinator() -> Coordinator { Coordinator() } - - func makeNSView(context: Context) -> NSView { - let view = NSView(frame: .zero) - // The window isn't attached yet at make time; apply on the next pass. - DispatchQueue.main.async { apply(to: view.window, context.coordinator) } - return view - } - - func updateNSView(_ nsView: NSView, context: Context) { - apply(to: nsView.window, context.coordinator) - } - - /// Writing `appearance` or `backgroundColor` invalidates the view hierarchy, - /// which calls back into `updateNSView` — so only write on an actual change. - private func apply(to window: NSWindow?, _ coordinator: Coordinator) { - guard let window else { return } - - if !coordinator.didConfigureChrome { - coordinator.didConfigureChrome = true - window.styleMask.insert(.fullSizeContentView) - window.titlebarAppearsTransparent = true - window.titleVisibility = .hidden - window.titlebarSeparatorStyle = .none - window.isMovableByWindowBackground = false - growTitlebar(window) - WindowDragHandle.install(in: window) - coordinator.keepTrafficLightsCentered(on: window) - } - - centerTrafficLights(in: window) - - guard coordinator.appliedTheme != theme else { return } - coordinator.appliedTheme = theme - window.backgroundColor = NSColor(theme.background) - window.appearance = theme.appearance - } - - /// An empty accessory is the supported way to make the titlebar taller; it - /// is hit-transparent so the page underneath keeps receiving clicks. - private func growTitlebar(_ window: NSWindow) { - let content = window.contentRect(forFrameRect: window.frame) - let extra = HeaderMetrics.titlebarHeight - (window.frame.height - content.height) - guard extra > 0 else { return } - - let accessory = NSTitlebarAccessoryViewController() - accessory.layoutAttribute = .bottom - accessory.view = PassthroughView(frame: NSRect(x: 0, y: 0, width: 1, height: extra)) - accessory.view.translatesAutoresizingMaskIntoConstraints = false - accessory.view.heightAnchor.constraint(equalToConstant: extra).isActive = true - window.addTitlebarAccessoryViewController(accessory) - } - - final class Coordinator { - var didConfigureChrome = false - var appliedTheme: DashboardTheme? - private var observers: [NSObjectProtocol] = [] - - /// AppKit puts the buttons back in the top 28pt on resize and on - /// full-screen transitions, so re-centre them whenever the window moves. - func keepTrafficLightsCentered(on window: NSWindow) { - let center = NotificationCenter.default - for name in [ - NSWindow.didResizeNotification, - NSWindow.didBecomeKeyNotification, - NSWindow.didEnterFullScreenNotification, - NSWindow.didExitFullScreenNotification, - ] { - let token = center.addObserver(forName: name, object: window, queue: .main) { note in - guard let window = note.object as? NSWindow else { return } - centerTrafficLights(in: window) - } - observers.append(token) - } - } - - deinit { - observers.forEach(NotificationCenter.default.removeObserver) - } - } -} - -/// Moves the traffic lights down into the grown titlebar so they land on the -/// dashboard's header row rather than in the top 28pt AppKit defaults them to. -private func centerTrafficLights(in window: NSWindow) { - let buttons = [NSWindow.ButtonType.closeButton, .miniaturizeButton, .zoomButton] - .compactMap { window.standardWindowButton($0) } - guard buttons.count == 3, let container = buttons[0].superview else { return } - - for button in buttons { - // The titlebar container is unflipped, so y counts up from its bottom. - let targetY = container.bounds.height - HeaderMetrics.rowCenterY - button.frame.height / 2 - guard abs(button.frame.origin.y - targetY) > 0.5 else { continue } - button.setFrameOrigin(NSPoint(x: button.frame.origin.x, y: targetY)) - } -} - -/// Lets mouse events fall through to the web view beneath the titlebar. -private final class PassthroughView: NSView { - override func hitTest(_ point: NSPoint) -> NSView? { nil } -} diff --git a/macos/build.sh b/macos/build.sh deleted file mode 100755 index 1e2dcb8..0000000 --- a/macos/build.sh +++ /dev/null @@ -1,112 +0,0 @@ -#!/usr/bin/env bash -# Build cot.app — SwiftUI shell + the frozen collector + the built dashboard. -# -# macos/build.sh full build into macos/build/cot.app -# macos/build.sh --run build, then launch it -# macos/build.sh --app-only skip the dashboard and collector steps -# -# Nothing is installed on the host: Python deps go into macos/build/venv. -set -euo pipefail - -REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -MAC_DIR="${REPO_ROOT}/macos" -BUILD_DIR="${MAC_DIR}/build" -APP="${BUILD_DIR}/cot.app" -VENV="${BUILD_DIR}/venv" -PYTHON_BIN="${COT_PYTHON:-python3}" - -APP_ONLY=0 -RUN_AFTER=0 -for arg in "$@"; do - case "${arg}" in - --app-only) APP_ONLY=1 ;; - --run) RUN_AFTER=1 ;; - *) echo "unknown flag: ${arg}" >&2; exit 2 ;; - esac -done - -step() { printf '\n\033[1m› %s\033[0m\n' "$1"; } - -VERSION="$(sed -n 's/^__version__ = "\(.*\)"$/\1/p' "${REPO_ROOT}/backend/app/__init__.py")" -[ -n "${VERSION}" ] || { echo "could not read backend/app/__init__.py version" >&2; exit 1; } - -mkdir -p "${BUILD_DIR}" - -# --- 1. Dashboard ------------------------------------------------------------- -if [ "${APP_ONLY}" = "0" ]; then - step "Building dashboard (vite)" - cd "${REPO_ROOT}" - [ -d node_modules ] || npm ci - npm run build -fi - -# --- 2. Collector ------------------------------------------------------------- -if [ "${APP_ONLY}" = "0" ]; then - step "Freezing collector (pyinstaller)" - if [ ! -x "${VENV}/bin/python" ]; then - "${PYTHON_BIN}" -m venv "${VENV}" - fi - "${VENV}/bin/pip" install --quiet --upgrade pip - "${VENV}/bin/pip" install --quiet -r "${REPO_ROOT}/backend/requirements.txt" pyinstaller - - cd "${BUILD_DIR}" - COT_REPO_ROOT="${REPO_ROOT}" "${VENV}/bin/pyinstaller" \ - --noconfirm --clean \ - --distpath "${BUILD_DIR}/dist" \ - --workpath "${BUILD_DIR}/work" \ - "${MAC_DIR}/packaging/cot-collector.spec" -fi - -# --- 3. App icon -------------------------------------------------------------- -step "Rendering app icon" -swift "${MAC_DIR}/packaging/make_icon.swift" "${BUILD_DIR}" >/dev/null -iconutil -c icns -o "${BUILD_DIR}/AppIcon.icns" "${BUILD_DIR}/AppIcon.iconset" - -# --- 4. Swift shell ----------------------------------------------------------- -step "Compiling SwiftUI app" -rm -rf "${APP}" -mkdir -p "${APP}/Contents/MacOS" "${APP}/Contents/Resources" - -sed "s/__COT_VERSION__/${VERSION}/g" "${MAC_DIR}/Resources/Info.plist" \ - > "${APP}/Contents/Info.plist" - -SDK="$(xcrun --show-sdk-path)" -swiftc \ - -swift-version 5 \ - -sdk "${SDK}" \ - -target "$(uname -m)-apple-macosx14.0" \ - -framework AppKit -framework WebKit -framework ServiceManagement \ - -O \ - -o "${APP}/Contents/MacOS/cot" \ - "${MAC_DIR}"/Sources/*.swift - -# --- 5. Assemble -------------------------------------------------------------- -step "Assembling bundle" -cp "${BUILD_DIR}/AppIcon.icns" "${APP}/Contents/Resources/AppIcon.icns" - -if [ -d "${BUILD_DIR}/dist/cot-collector" ]; then - cp -R "${BUILD_DIR}/dist/cot-collector" "${APP}/Contents/Resources/cot-collector" -else - echo "warning: no frozen collector — the app will report it as missing" >&2 -fi - -if [ -d "${REPO_ROOT}/dist" ]; then - cp -R "${REPO_ROOT}/dist" "${APP}/Contents/Resources/static" -else - echo "warning: no dashboard build at dist/ — the app window will 404" >&2 -fi - -# Ad-hoc signature: enough for local use and for the login item to register. -# Set COT_SIGN_IDENTITY for a Developer ID build. -step "Signing" -sign_args=(--force --deep --sign "${COT_SIGN_IDENTITY:--}" - --entitlements "${MAC_DIR}/Resources/cot.entitlements") -# Hardened runtime only matters for a Developer ID build; ad-hoc signing rejects it. -[ -n "${COT_SIGN_IDENTITY:-}" ] && sign_args+=(--options runtime --timestamp) -codesign "${sign_args[@]}" "${APP}" 2>&1 | sed 's/^/ /' - -printf '\n\033[1mBuilt %s (v%s)\033[0m\n' "${APP}" "${VERSION}" - -if [ "${RUN_AFTER}" = "1" ]; then - open "${APP}" -fi diff --git a/macos/packaging/make_icon.swift b/macos/packaging/make_icon.swift deleted file mode 100644 index bc2d84c..0000000 --- a/macos/packaging/make_icon.swift +++ /dev/null @@ -1,97 +0,0 @@ -// Generates AppIcon.icns from the cot mark — ink squircle, vermilion italic -// serif wordmark, matching public/apple-touch-icon.svg and the design language. -// -// swift macos/packaging/make_icon.swift -// -// Each size is drawn at its native pixel dimensions rather than downscaled from -// one master, so the 16pt icon keeps its edges. - -import AppKit -import Foundation - -let ink = NSColor(srgbRed: 0x11 / 255, green: 0x11 / 255, blue: 0x11 / 255, alpha: 1) -let vermilion = NSColor(srgbRed: 1.0, green: 0x45 / 255, blue: 0, alpha: 1) - -/// macOS app icons sit in a squircle inset from the canvas, so the shape lines -/// up with every other icon in the Dock. -let contentInset = 0.104 -let cornerRatio = 0.2237 - -func drawIcon(pixels: Int) -> NSBitmapImageRep { - let rep = NSBitmapImageRep( - bitmapDataPlanes: nil, - pixelsWide: pixels, pixelsHigh: pixels, - bitsPerSample: 8, samplesPerPixel: 4, - hasAlpha: true, isPlanar: false, - colorSpaceName: .calibratedRGB, - bytesPerRow: 0, bitsPerPixel: 0 - )! - rep.size = NSSize(width: pixels, height: pixels) - - NSGraphicsContext.saveGraphicsState() - NSGraphicsContext.current = NSGraphicsContext(bitmapImageRep: rep) - - let side = Double(pixels) - let inset = side * contentInset - let box = NSRect(x: inset, y: inset, width: side - inset * 2, height: side - inset * 2) - let radius = box.width * cornerRatio - - let squircle = NSBezierPath(roundedRect: box, xRadius: radius, yRadius: radius) - ink.setFill() - squircle.fill() - - // A hairline lip so the mark still reads on a dark desktop background. - vermilion.withAlphaComponent(0.28).setStroke() - squircle.lineWidth = max(1, side * 0.006) - squircle.stroke() - - // Sized to fill the squircle — the mark has to survive a 16pt titlebar and - // a 16pt Finder row, not just the Dock. - let fontSize = box.width * 0.58 - let font = NSFont(name: "Georgia-Italic", size: fontSize) - ?? NSFont(name: "Times-Italic", size: fontSize)! - let mark = NSAttributedString( - string: "cot.", - attributes: [ - .font: font, - .foregroundColor: vermilion, - .kern: -fontSize * 0.02, - ] - ) - - // Optically centre on the x-height rather than the full line box, which the - // descender-free "cot." otherwise pushes high. - let measured = mark.size() - let origin = NSPoint( - x: box.midX - measured.width / 2, - y: box.midY - measured.height / 2 + box.height * 0.05 - ) - mark.draw(at: origin) - - NSGraphicsContext.restoreGraphicsState() - return rep -} - -let outputDir = CommandLine.arguments.count > 1 ? CommandLine.arguments[1] : "." -let iconset = URL(fileURLWithPath: outputDir).appendingPathComponent("AppIcon.iconset") -try? FileManager.default.removeItem(at: iconset) -try FileManager.default.createDirectory(at: iconset, withIntermediateDirectories: true) - -// (point size, scale) — the set `iconutil` expects. -let variants: [(Int, Int)] = [ - (16, 1), (16, 2), (32, 1), (32, 2), (128, 1), - (128, 2), (256, 1), (256, 2), (512, 1), (512, 2), -] - -for (points, scale) in variants { - let rep = drawIcon(pixels: points * scale) - guard let png = rep.representation(using: .png, properties: [:]) else { - FileHandle.standardError.write("failed to encode \(points)@\(scale)x\n".data(using: .utf8)!) - exit(1) - } - let suffix = scale == 1 ? "" : "@\(scale)x" - let name = "icon_\(points)x\(points)\(suffix).png" - try png.write(to: iconset.appendingPathComponent(name)) -} - -print(iconset.path)