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.
+
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)