feat: add mac companion app
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -6,3 +6,6 @@ coverage
|
||||
.pnpm-store
|
||||
.DS_Store
|
||||
src/.DS_Store
|
||||
apps/macos/.build/
|
||||
apps/macos/Assets.xcassets/LobsterTemplate.imageset/lobster-template.pdf
|
||||
bin/clawdis-mac
|
||||
|
||||
16
apps/macos/Assets.xcassets/LobsterTemplate.imageset/Contents.json
vendored
Normal file
16
apps/macos/Assets.xcassets/LobsterTemplate.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "lobster-template.pdf",
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
},
|
||||
"properties" : {
|
||||
"template-rendering-intent" : "template"
|
||||
}
|
||||
}
|
||||
15
apps/macos/Package.resolved
Normal file
15
apps/macos/Package.resolved
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"originHash" : "606518c5b87c69959b38cf07ab1ce9ec606bb1900efdd52a82f3178a39da8881",
|
||||
"pins" : [
|
||||
{
|
||||
"identity" : "asyncxpcconnection",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/ChimeHQ/AsyncXPCConnection",
|
||||
"state" : {
|
||||
"revision" : "da31dbcaa1b57949e46dcc19360b17d1a8de06bd",
|
||||
"version" : "1.3.0"
|
||||
}
|
||||
}
|
||||
],
|
||||
"version" : 3
|
||||
}
|
||||
56
apps/macos/Package.swift
Normal file
56
apps/macos/Package.swift
Normal file
@@ -0,0 +1,56 @@
|
||||
// swift-tools-version: 6.2
|
||||
// Package manifest for the Clawdis macOS companion (menu bar app + CLI + IPC library).
|
||||
|
||||
import PackageDescription
|
||||
|
||||
let package = Package(
|
||||
name: "Clawdis",
|
||||
platforms: [
|
||||
.macOS(.v15),
|
||||
],
|
||||
products: [
|
||||
.library(name: "ClawdisIPC", targets: ["ClawdisIPC"]),
|
||||
.executable(name: "Clawdis", targets: ["Clawdis"]),
|
||||
.executable(name: "ClawdisCLI", targets: ["ClawdisCLI"]),
|
||||
],
|
||||
dependencies: [
|
||||
.package(url: "https://github.com/ChimeHQ/AsyncXPCConnection", from: "1.3.0"),
|
||||
],
|
||||
targets: [
|
||||
.target(
|
||||
name: "ClawdisIPC",
|
||||
dependencies: [],
|
||||
swiftSettings: [
|
||||
.enableUpcomingFeature("StrictConcurrency"),
|
||||
]
|
||||
),
|
||||
.executableTarget(
|
||||
name: "Clawdis",
|
||||
dependencies: [
|
||||
"ClawdisIPC",
|
||||
.product(name: "AsyncXPCConnection", package: "AsyncXPCConnection"),
|
||||
],
|
||||
swiftSettings: [
|
||||
.enableUpcomingFeature("StrictConcurrency"),
|
||||
]
|
||||
),
|
||||
.executableTarget(
|
||||
name: "ClawdisCLI",
|
||||
dependencies: [
|
||||
"ClawdisIPC",
|
||||
.product(name: "AsyncXPCConnection", package: "AsyncXPCConnection"),
|
||||
],
|
||||
swiftSettings: [
|
||||
.enableUpcomingFeature("StrictConcurrency"),
|
||||
]
|
||||
),
|
||||
.testTarget(
|
||||
name: "ClawdisIPCTests",
|
||||
dependencies: ["ClawdisIPC"],
|
||||
swiftSettings: [
|
||||
.enableUpcomingFeature("StrictConcurrency"),
|
||||
.enableExperimentalFeature("SwiftTesting"),
|
||||
]
|
||||
),
|
||||
]
|
||||
)
|
||||
797
apps/macos/Sources/Clawdis/AppMain.swift
Normal file
797
apps/macos/Sources/Clawdis/AppMain.swift
Normal file
@@ -0,0 +1,797 @@
|
||||
import AppKit
|
||||
import ApplicationServices
|
||||
import AsyncXPCConnection
|
||||
import ClawdisIPC
|
||||
import Foundation
|
||||
import OSLog
|
||||
import CoreGraphics
|
||||
@preconcurrency import ScreenCaptureKit
|
||||
import VideoToolbox
|
||||
import ServiceManagement
|
||||
import SwiftUI
|
||||
import UserNotifications
|
||||
|
||||
private let serviceName = "com.steipete.clawdis.xpc"
|
||||
private let pauseDefaultsKey = "clawdis.pauseEnabled"
|
||||
|
||||
// MARK: - App model
|
||||
|
||||
@MainActor
|
||||
final class AppState: ObservableObject {
|
||||
@Published var isPaused: Bool {
|
||||
didSet { UserDefaults.standard.set(isPaused, forKey: pauseDefaultsKey) }
|
||||
}
|
||||
@Published var defaultSound: String {
|
||||
didSet { UserDefaults.standard.set(defaultSound, forKey: "clawdis.defaultSound") }
|
||||
}
|
||||
@Published var launchAtLogin: Bool {
|
||||
didSet { Task { AppStateStore.updateLaunchAtLogin(enabled: launchAtLogin) } }
|
||||
}
|
||||
@Published var onboardingSeen: Bool {
|
||||
didSet { UserDefaults.standard.set(onboardingSeen, forKey: "clawdis.onboardingSeen") }
|
||||
}
|
||||
|
||||
init() {
|
||||
self.isPaused = UserDefaults.standard.bool(forKey: pauseDefaultsKey)
|
||||
self.defaultSound = UserDefaults.standard.string(forKey: "clawdis.defaultSound") ?? ""
|
||||
self.launchAtLogin = SMAppService.mainApp.status == .enabled
|
||||
self.onboardingSeen = UserDefaults.standard.bool(forKey: "clawdis.onboardingSeen")
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
enum AppStateStore {
|
||||
static let shared = AppState()
|
||||
static var isPausedFlag: Bool { UserDefaults.standard.bool(forKey: pauseDefaultsKey) }
|
||||
static var defaultSound: String { UserDefaults.standard.string(forKey: "clawdis.defaultSound") ?? "" }
|
||||
|
||||
static func updateLaunchAtLogin(enabled: Bool) {
|
||||
if enabled {
|
||||
try? SMAppService.mainApp.register()
|
||||
} else {
|
||||
try? SMAppService.mainApp.unregister()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - XPC service protocol
|
||||
|
||||
@objc protocol ClawdisXPCProtocol {
|
||||
func handle(_ data: Data, withReply reply: @escaping @Sendable (Data?, Error?) -> Void)
|
||||
}
|
||||
|
||||
// MARK: - XPC service implementation
|
||||
|
||||
final class ClawdisXPCService: NSObject, ClawdisXPCProtocol {
|
||||
private let logger = Logger(subsystem: "com.steipete.clawdis", category: "xpc")
|
||||
|
||||
func handle(_ data: Data, withReply reply: @escaping @Sendable (Data?, Error?) -> Void) {
|
||||
let logger = logger
|
||||
Task.detached(priority: nil) { @Sendable in
|
||||
do {
|
||||
let request = try JSONDecoder().decode(Request.self, from: data)
|
||||
let response = try await Self.process(request: request, notifier: NotificationManager(), logger: logger)
|
||||
let encoded = try JSONEncoder().encode(response)
|
||||
reply(encoded, nil)
|
||||
} catch {
|
||||
logger.error("Failed to handle XPC request: \(error.localizedDescription, privacy: .public)")
|
||||
let resp = Response(ok: false, message: "decode/handle error: \(error.localizedDescription)")
|
||||
reply(try? JSONEncoder().encode(resp), error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func process(request: Request, notifier: NotificationManager, logger: Logger) async throws -> Response {
|
||||
let paused = await MainActor.run { AppStateStore.isPausedFlag }
|
||||
if paused {
|
||||
return Response(ok: false, message: "clawdis paused")
|
||||
}
|
||||
|
||||
switch request {
|
||||
case let .notify(title, body, sound):
|
||||
let chosenSound: String
|
||||
if let sound { chosenSound = sound } else { chosenSound = await MainActor.run { AppStateStore.defaultSound } }
|
||||
let ok = await notifier.send(title: title, body: body, sound: chosenSound)
|
||||
return ok ? Response(ok: true) : Response(ok: false, message: "notification not authorized")
|
||||
case let .ensurePermissions(caps, interactive):
|
||||
let statuses = await PermissionManager.ensure(caps, interactive: interactive)
|
||||
let missing = statuses.filter { !$0.value }.map { $0.key.rawValue }
|
||||
let ok = missing.isEmpty
|
||||
let msg = ok ? "all granted" : "missing: \(missing.joined(separator: ","))"
|
||||
return Response(ok: ok, message: msg)
|
||||
case .status:
|
||||
return Response(ok: true, message: "ready")
|
||||
case let .screenshot(displayID, windowID, _):
|
||||
let authorized = await PermissionManager.ensure([.screenRecording], interactive: false)[.screenRecording] ?? false
|
||||
guard authorized else { return Response(ok: false, message: "screen recording permission missing") }
|
||||
if let data = await Screenshotter.capture(displayID: displayID, windowID: windowID) {
|
||||
return Response(ok: true, payload: data)
|
||||
}
|
||||
return Response(ok: false, message: "screenshot failed")
|
||||
case let .runShell(command, cwd, env, timeoutSec, needsSR):
|
||||
if needsSR {
|
||||
let authorized = await PermissionManager.ensure([.screenRecording], interactive: false)[.screenRecording] ?? false
|
||||
guard authorized else { return Response(ok: false, message: "screen recording permission missing") }
|
||||
}
|
||||
return await ShellRunner.run(command: command, cwd: cwd, env: env, timeout: timeoutSec)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Notification manager
|
||||
|
||||
@MainActor
|
||||
struct NotificationManager {
|
||||
func send(title: String, body: String, sound: String?) async -> Bool {
|
||||
let center = UNUserNotificationCenter.current()
|
||||
let status = await center.notificationSettings()
|
||||
if status.authorizationStatus == .notDetermined {
|
||||
let granted = try? await center.requestAuthorization(options: [.alert, .sound, .badge])
|
||||
if granted != true { return false }
|
||||
} else if status.authorizationStatus != .authorized {
|
||||
return false
|
||||
}
|
||||
|
||||
let content = UNMutableNotificationContent()
|
||||
content.title = title
|
||||
content.body = body
|
||||
if let soundName = sound, !soundName.isEmpty {
|
||||
content.sound = UNNotificationSound(named: UNNotificationSoundName(soundName))
|
||||
}
|
||||
|
||||
let req = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil)
|
||||
do {
|
||||
try await center.add(req)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Permission manager (minimal stub)
|
||||
|
||||
enum PermissionManager {
|
||||
@MainActor
|
||||
static func ensure(_ caps: [Capability], interactive: Bool) async -> [Capability: Bool] {
|
||||
var results: [Capability: Bool] = [:]
|
||||
for cap in caps {
|
||||
switch cap {
|
||||
case .notifications:
|
||||
let center = UNUserNotificationCenter.current()
|
||||
let status = await center.notificationSettings()
|
||||
if status.authorizationStatus == .notDetermined && interactive {
|
||||
_ = try? await center.requestAuthorization(options: [.alert, .sound, .badge])
|
||||
let post = await center.notificationSettings()
|
||||
results[cap] = post.authorizationStatus == .authorized
|
||||
} else {
|
||||
results[cap] = status.authorizationStatus == .authorized
|
||||
}
|
||||
case .accessibility:
|
||||
// Accessing AX APIs must be on main thread.
|
||||
let trusted = AXIsProcessTrusted()
|
||||
results[cap] = trusted
|
||||
if interactive && !trusted {
|
||||
_ = AXIsProcessTrustedWithOptions(nil)
|
||||
}
|
||||
case .screenRecording:
|
||||
let granted = ScreenRecordingProbe.isAuthorized()
|
||||
if interactive && !granted {
|
||||
await ScreenRecordingProbe.requestAuthorization()
|
||||
}
|
||||
results[cap] = ScreenRecordingProbe.isAuthorized()
|
||||
}
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
@MainActor
|
||||
static func status(_ caps: [Capability] = Capability.allCases) async -> [Capability: Bool] {
|
||||
var results: [Capability: Bool] = [:]
|
||||
for cap in caps {
|
||||
switch cap {
|
||||
case .notifications:
|
||||
let center = UNUserNotificationCenter.current()
|
||||
let settings = await center.notificationSettings()
|
||||
results[cap] = settings.authorizationStatus == .authorized || settings.authorizationStatus == .provisional
|
||||
case .accessibility:
|
||||
results[cap] = AXIsProcessTrusted()
|
||||
case .screenRecording:
|
||||
if #available(macOS 10.15, *) {
|
||||
results[cap] = CGPreflightScreenCaptureAccess()
|
||||
} else {
|
||||
results[cap] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return results
|
||||
}
|
||||
}
|
||||
|
||||
enum ScreenRecordingProbe {
|
||||
static func isAuthorized() -> Bool {
|
||||
if #available(macOS 10.15, *) {
|
||||
return CGPreflightScreenCaptureAccess()
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
@MainActor
|
||||
static func requestAuthorization() async {
|
||||
if #available(macOS 10.15, *) {
|
||||
_ = CGRequestScreenCaptureAccess()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Screenshot
|
||||
|
||||
enum Screenshotter {
|
||||
@MainActor
|
||||
static func capture(displayID: UInt32?, windowID: UInt32?) async -> Data? {
|
||||
guard let content = try? await SCShareableContent.current else { return nil }
|
||||
|
||||
let targetDisplay: SCDisplay?
|
||||
if let displayID {
|
||||
targetDisplay = content.displays.first(where: { $0.displayID == displayID })
|
||||
} else {
|
||||
targetDisplay = content.displays.first
|
||||
}
|
||||
|
||||
let filter: SCContentFilter
|
||||
if let windowID, let win = content.windows.first(where: { $0.windowID == windowID }) {
|
||||
filter = SCContentFilter(desktopIndependentWindow: win)
|
||||
} else if let display = targetDisplay {
|
||||
filter = SCContentFilter(display: display, excludingWindows: [])
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let config = SCStreamConfiguration()
|
||||
if let display = targetDisplay {
|
||||
config.width = display.width
|
||||
config.height = display.height
|
||||
}
|
||||
config.scalesToFit = true
|
||||
config.colorSpaceName = CGColorSpace.displayP3
|
||||
|
||||
let stream = SCStream(filter: filter, configuration: config, delegate: nil)
|
||||
let grabber = FrameGrabber()
|
||||
try? stream.addStreamOutput(grabber, type: .screen, sampleHandlerQueue: DispatchQueue(label: "com.steipete.clawdis.sshot"))
|
||||
do {
|
||||
try await stream.startCapture()
|
||||
let data = await grabber.awaitPNG()
|
||||
try? await stream.stopCapture()
|
||||
return data
|
||||
} catch {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final class FrameGrabber: NSObject, SCStreamOutput {
|
||||
private var continuation: CheckedContinuation<Data?, Never>?
|
||||
private var delivered = false
|
||||
|
||||
func awaitPNG() async -> Data? {
|
||||
await withCheckedContinuation { cont in
|
||||
self.continuation = cont
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated func stream(_ stream: SCStream, didOutputSampleBuffer sampleBuffer: CMSampleBuffer, of outputType: SCStreamOutputType) {
|
||||
guard outputType == .screen else { return }
|
||||
if delivered { return }
|
||||
guard let imageBuffer = sampleBuffer.imageBuffer else { return }
|
||||
var cgImage: CGImage?
|
||||
let result = VTCreateCGImageFromCVPixelBuffer(imageBuffer, options: nil, imageOut: &cgImage)
|
||||
guard result == noErr, let cgImage else { return }
|
||||
let rep = NSBitmapImageRep(cgImage: cgImage)
|
||||
guard let data = rep.representation(using: .png, properties: [:]) else { return }
|
||||
|
||||
delivered = true
|
||||
continuation?.resume(returning: data)
|
||||
continuation = nil
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Shell runner
|
||||
|
||||
enum ShellRunner {
|
||||
static func run(command: [String], cwd: String?, env: [String: String]?, timeout: Double?) async -> Response {
|
||||
guard !command.isEmpty else { return Response(ok: false, message: "empty command") }
|
||||
|
||||
let process = Process()
|
||||
process.executableURL = URL(fileURLWithPath: "/usr/bin/env")
|
||||
process.arguments = command
|
||||
if let cwd { process.currentDirectoryURL = URL(fileURLWithPath: cwd) }
|
||||
if let env { process.environment = env }
|
||||
|
||||
let stdoutPipe = Pipe()
|
||||
let stderrPipe = Pipe()
|
||||
process.standardOutput = stdoutPipe
|
||||
process.standardError = stderrPipe
|
||||
|
||||
do {
|
||||
try process.run()
|
||||
} catch {
|
||||
return Response(ok: false, message: "failed to start: \(error.localizedDescription)")
|
||||
}
|
||||
|
||||
let waitTask = Task.detached { () -> (Int32, Data, Data) in
|
||||
process.waitUntilExit()
|
||||
let out = stdoutPipe.fileHandleForReading.readDataToEndOfFile()
|
||||
let err = stderrPipe.fileHandleForReading.readDataToEndOfFile()
|
||||
return (process.terminationStatus, out, err)
|
||||
}
|
||||
|
||||
if let timeout, timeout > 0 {
|
||||
let nanos = UInt64(timeout * 1_000_000_000)
|
||||
try? await Task.sleep(nanoseconds: nanos)
|
||||
if process.isRunning {
|
||||
process.terminate()
|
||||
return Response(ok: false, message: "timeout")
|
||||
}
|
||||
}
|
||||
|
||||
let (status, out, err) = await waitTask.value
|
||||
let combined = out.isEmpty ? err : out
|
||||
return Response(ok: status == 0, message: status == 0 ? nil : "exit \(status)", payload: combined)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - App + menu UI
|
||||
|
||||
@main
|
||||
struct ClawdisApp: App {
|
||||
@NSApplicationDelegateAdaptor(AppDelegate.self) private var delegate
|
||||
@StateObject private var state: AppState
|
||||
|
||||
init() {
|
||||
_state = StateObject(wrappedValue: AppStateStore.shared)
|
||||
}
|
||||
|
||||
var body: some Scene {
|
||||
MenuBarExtra { menuContent } label: { LobsterStatusLabel(isPaused: state.isPaused) }
|
||||
.menuBarExtraStyle(.menu)
|
||||
|
||||
Settings {
|
||||
SettingsView(state: state)
|
||||
.frame(width: 420, height: 320)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var menuContent: some View {
|
||||
Toggle(isOn: $state.isPaused) {
|
||||
Text(state.isPaused ? "Clawdis Paused" : "Pause Clawdis")
|
||||
}
|
||||
Divider()
|
||||
Button("Test Notification") {
|
||||
Task { _ = await NotificationManager().send(title: "Clawdis", body: "Test notification", sound: nil) }
|
||||
}
|
||||
Divider()
|
||||
Button("Permissions…") { PermissionsSheetController.shared.show(state: state) }
|
||||
Button("Quit") { NSApplication.shared.terminate(nil) }
|
||||
}
|
||||
}
|
||||
|
||||
private struct LobsterStatusLabel: View {
|
||||
var isPaused: Bool
|
||||
|
||||
var body: some View {
|
||||
let imageView: Image = {
|
||||
if let img = NSImage(named: "LobsterTemplate") {
|
||||
img.isTemplate = true
|
||||
return Image(nsImage: img).renderingMode(.template)
|
||||
}
|
||||
return Image(systemName: "antenna.radiowaves.left.and.right")
|
||||
}()
|
||||
|
||||
return imageView.foregroundStyle(isPaused ? .secondary : .primary)
|
||||
}
|
||||
}
|
||||
|
||||
final class AppDelegate: NSObject, NSApplicationDelegate, NSXPCListenerDelegate {
|
||||
private var listener: NSXPCListener?
|
||||
private var state: AppState?
|
||||
|
||||
@MainActor
|
||||
func applicationDidFinishLaunching(_ notification: Notification) {
|
||||
NSApp.setActivationPolicy(.accessory)
|
||||
state = AppStateStore.shared
|
||||
startListener()
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func startListener() {
|
||||
guard state != nil else { return }
|
||||
let listener = NSXPCListener(machServiceName: serviceName)
|
||||
listener.delegate = self
|
||||
listener.resume()
|
||||
self.listener = listener
|
||||
}
|
||||
|
||||
func listener(_ listener: NSXPCListener, shouldAcceptNewConnection connection: NSXPCConnection) -> Bool {
|
||||
let interface = NSXPCInterface(with: ClawdisXPCProtocol.self)
|
||||
connection.exportedInterface = interface
|
||||
connection.exportedObject = ClawdisXPCService()
|
||||
connection.resume()
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Settings UI
|
||||
|
||||
struct SettingsView: View {
|
||||
@ObservedObject var state: AppState
|
||||
@State private var permStatus: [Capability: Bool] = [:]
|
||||
@State private var loadingPerms = false
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
if !state.onboardingSeen {
|
||||
Label("Complete onboarding to finish setup", systemImage: "sparkles")
|
||||
.foregroundColor(.accentColor)
|
||||
}
|
||||
Toggle(isOn: $state.isPaused) {
|
||||
Text("Pause Clawdis (disables notifications & privileged actions)")
|
||||
}
|
||||
Toggle(isOn: $state.launchAtLogin) {
|
||||
Text("Launch at login")
|
||||
}
|
||||
HStack {
|
||||
Text("Default sound")
|
||||
Spacer()
|
||||
Picker("Sound", selection: $state.defaultSound) {
|
||||
Text("None").tag("")
|
||||
Text("Glass").tag("Glass")
|
||||
Text("Basso").tag("Basso")
|
||||
Text("Ping").tag("Ping")
|
||||
}
|
||||
.labelsHidden()
|
||||
.frame(width: 140)
|
||||
}
|
||||
Divider()
|
||||
Text("Permissions")
|
||||
.font(.headline)
|
||||
PermissionStatusList(status: permStatus, refresh: refreshPerms)
|
||||
Button("Show Onboarding") {
|
||||
OnboardingController.shared.show()
|
||||
}
|
||||
Spacer()
|
||||
HStack {
|
||||
Spacer()
|
||||
Text("Clawdis Companion")
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.task { await refreshPerms() }
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func refreshPerms() async {
|
||||
guard !loadingPerms else { return }
|
||||
loadingPerms = true
|
||||
permStatus = await PermissionManager.status()
|
||||
loadingPerms = false
|
||||
}
|
||||
}
|
||||
|
||||
struct PermissionStatusList: View {
|
||||
let status: [Capability: Bool]
|
||||
let refresh: () async -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
row(label: "Notifications", cap: .notifications, action: requestNotifications)
|
||||
row(label: "Accessibility", cap: .accessibility) {
|
||||
openSettings("x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility")
|
||||
}
|
||||
row(label: "Screen Recording", cap: .screenRecording) {
|
||||
openSettings("x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenRecording")
|
||||
}
|
||||
Button("Refresh status") { Task { await refresh() } }
|
||||
.font(.footnote)
|
||||
}
|
||||
}
|
||||
|
||||
private func row(label: String, cap: Capability, action: @escaping () -> Void) -> some View {
|
||||
let ok = status[cap] ?? false
|
||||
return HStack {
|
||||
Circle()
|
||||
.fill(ok ? Color.green : Color.red)
|
||||
.frame(width: 10, height: 10)
|
||||
Text(label)
|
||||
Spacer()
|
||||
Button(ok ? "Granted" : "Open Settings", action: action)
|
||||
.disabled(ok)
|
||||
}
|
||||
}
|
||||
|
||||
private func requestNotifications() {
|
||||
Task {
|
||||
let center = UNUserNotificationCenter.current()
|
||||
_ = try? await center.requestAuthorization(options: [.alert, .sound, .badge])
|
||||
await refresh()
|
||||
}
|
||||
}
|
||||
|
||||
private func openSettings(_ path: String) {
|
||||
if let url = URL(string: path) {
|
||||
NSWorkspace.shared.open(url)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Permissions window stub
|
||||
|
||||
@MainActor
|
||||
final class PermissionsSheetController {
|
||||
static let shared = PermissionsSheetController()
|
||||
|
||||
private var window: NSWindow?
|
||||
|
||||
func show(state: AppState) {
|
||||
if let window {
|
||||
window.makeKeyAndOrderFront(nil)
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
return
|
||||
}
|
||||
|
||||
let hosting = NSHostingController(rootView: PermissionsView())
|
||||
let window = NSWindow(contentViewController: hosting)
|
||||
window.title = "Permissions"
|
||||
window.setContentSize(NSSize(width: 360, height: 220))
|
||||
window.styleMask = [.titled, .closable, .miniaturizable]
|
||||
window.isReleasedWhenClosed = false
|
||||
window.center()
|
||||
window.makeKeyAndOrderFront(nil)
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
self.window = window
|
||||
}
|
||||
}
|
||||
|
||||
struct PermissionsView: View {
|
||||
@State private var notificationStatus: String = "Unknown"
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("Grant the permissions below so Clawdis can help.")
|
||||
if AppStateStore.isPausedFlag {
|
||||
Text("Clawdis is paused. Unpause to enable actions.")
|
||||
.foregroundColor(.orange)
|
||||
}
|
||||
Divider()
|
||||
HStack {
|
||||
Text("Notifications")
|
||||
Spacer()
|
||||
Text(notificationStatus).foregroundColor(.secondary)
|
||||
Button("Request") { requestNotifications() }
|
||||
}
|
||||
HStack {
|
||||
Text("Accessibility")
|
||||
Spacer()
|
||||
Button("Open Settings") { openSettings(path: "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility") }
|
||||
}
|
||||
HStack {
|
||||
Text("Screen Recording")
|
||||
Spacer()
|
||||
Button("Open Settings") { openSettings(path: "x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenRecording") }
|
||||
}
|
||||
Spacer()
|
||||
Text("Tip: run 'clawdis-mac ensure-permissions --interactive' from terminal to trigger prompts.")
|
||||
.font(.footnote)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
.padding()
|
||||
.task { await refreshNotificationStatus() }
|
||||
}
|
||||
|
||||
private func requestNotifications() {
|
||||
Task {
|
||||
let center = UNUserNotificationCenter.current()
|
||||
_ = try? await center.requestAuthorization(options: [.alert, .sound, .badge])
|
||||
await refreshNotificationStatus()
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func refreshNotificationStatus() async {
|
||||
let settings = await UNUserNotificationCenter.current().notificationSettings()
|
||||
switch settings.authorizationStatus {
|
||||
case .notDetermined: notificationStatus = "Not determined"
|
||||
case .denied: notificationStatus = "Denied"
|
||||
case .authorized, .provisional, .ephemeral: notificationStatus = "Authorized"
|
||||
@unknown default: notificationStatus = "Unknown"
|
||||
}
|
||||
}
|
||||
|
||||
private func openSettings(path: String) {
|
||||
if let url = URL(string: path) {
|
||||
NSWorkspace.shared.open(url)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Onboarding (VibeTunnel-style, multi-step)
|
||||
|
||||
@MainActor
|
||||
final class OnboardingController {
|
||||
static let shared = OnboardingController()
|
||||
private var window: NSWindow?
|
||||
|
||||
func show() {
|
||||
if let window {
|
||||
window.makeKeyAndOrderFront(nil)
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
return
|
||||
}
|
||||
let hosting = NSHostingController(rootView: OnboardingView())
|
||||
let window = NSWindow(contentViewController: hosting)
|
||||
window.title = "Welcome to Clawdis"
|
||||
window.setContentSize(NSSize(width: 520, height: 420))
|
||||
window.styleMask = [.titled, .closable]
|
||||
window.center()
|
||||
window.makeKeyAndOrderFront(nil)
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
self.window = window
|
||||
}
|
||||
|
||||
func close() {
|
||||
window?.close()
|
||||
window = nil
|
||||
}
|
||||
}
|
||||
|
||||
struct OnboardingView: View {
|
||||
@State private var stepIndex = 0
|
||||
@State private var permStatus: [Capability: Bool] = [:]
|
||||
@State private var copied = false
|
||||
|
||||
private var steps: [OnboardingStep] {
|
||||
[
|
||||
.init(title: "Welcome aboard", detail: "Clawdis is your macOS companion for notifications and privileged agent actions.", accent: "sparkles"),
|
||||
.init(title: "Grant permissions", detail: "Enable Notifications, Accessibility, and Screen Recording so actions succeed.", accent: "lock.shield", showsPermissions: true),
|
||||
.init(title: "Install the CLI", detail: "Make the helper available to scripts via a quick symlink.", accent: "terminal", showsCLI: true),
|
||||
.init(title: "Done", detail: "You can pause Clawdis anytime from the menu. Happy automating!", accent: "hand.thumbsup")
|
||||
]
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
let step = steps[stepIndex]
|
||||
VStack(spacing: 16) {
|
||||
header(step: step)
|
||||
contentCard(step: step)
|
||||
progressDots
|
||||
footerButtons
|
||||
}
|
||||
.padding(20)
|
||||
.task { await refreshPerms() }
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func header(step: OnboardingStep) -> some View {
|
||||
ZStack(alignment: .leading) {
|
||||
RoundedRectangle(cornerRadius: 14)
|
||||
.fill(LinearGradient(colors: [Color.blue.opacity(0.9), Color.purple.opacity(0.85)], startPoint: .topLeading, endPoint: .bottomTrailing))
|
||||
.frame(height: 100)
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Label(step.title, systemImage: step.accent)
|
||||
.font(.title3.bold())
|
||||
.foregroundColor(.white)
|
||||
Text(step.detail)
|
||||
.foregroundColor(Color.white.opacity(0.92))
|
||||
.font(.subheadline)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func contentCard(step: OnboardingStep) -> some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
if step.showsPermissions {
|
||||
PermissionStatusList(status: permStatus, refresh: refreshPerms)
|
||||
.padding(10)
|
||||
.background(RoundedRectangle(cornerRadius: 10).fill(Color(NSColor.controlBackgroundColor)))
|
||||
}
|
||||
if step.showsCLI {
|
||||
CLIInstallCard(copied: $copied)
|
||||
}
|
||||
if !step.showsPermissions && !step.showsCLI {
|
||||
Text("Keep Clawdis running in your menu bar. Use the Pause toggle anytime if you need to mute actions.")
|
||||
}
|
||||
}
|
||||
.padding(14)
|
||||
.background(RoundedRectangle(cornerRadius: 14).stroke(Color.gray.opacity(0.2)))
|
||||
}
|
||||
|
||||
private var progressDots: some View {
|
||||
HStack(spacing: 8) {
|
||||
ForEach(Array(steps.indices), id: \.self) { idx in
|
||||
Circle()
|
||||
.fill(idx == stepIndex ? Color.accentColor : Color.gray.opacity(0.4))
|
||||
.frame(width: 8, height: 8)
|
||||
.scaleEffect(idx == stepIndex ? 1.25 : 1.0)
|
||||
.animation(.spring(response: 0.35, dampingFraction: 0.7), value: stepIndex)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 4)
|
||||
}
|
||||
|
||||
private var footerButtons: some View {
|
||||
HStack {
|
||||
Button("Skip") { finish() }
|
||||
.buttonStyle(.plain)
|
||||
Spacer()
|
||||
if stepIndex > 0 {
|
||||
Button("Back") { stepIndex = max(0, stepIndex - 1) }
|
||||
}
|
||||
Button(stepIndex == steps.count - 1 ? "Finish" : "Next") {
|
||||
advance()
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
}
|
||||
|
||||
private func advance() {
|
||||
if stepIndex + 1 < steps.count {
|
||||
stepIndex += 1
|
||||
} else {
|
||||
finish()
|
||||
}
|
||||
}
|
||||
|
||||
private func finish() {
|
||||
UserDefaults.standard.set(true, forKey: "clawdis.onboardingSeen")
|
||||
OnboardingController.shared.close()
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func refreshPerms() async {
|
||||
permStatus = await PermissionManager.status()
|
||||
}
|
||||
}
|
||||
|
||||
struct OnboardingStep {
|
||||
let title: String
|
||||
let detail: String
|
||||
let accent: String
|
||||
var showsPermissions: Bool = false
|
||||
var showsCLI: Bool = false
|
||||
}
|
||||
|
||||
struct CLIInstallCard: View {
|
||||
@Binding var copied: Bool
|
||||
private let command = "ln -sf $(pwd)/apps/macos/.build/debug/ClawdisCLI /usr/local/bin/clawdis-mac"
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Install the helper CLI")
|
||||
.font(.headline)
|
||||
Text("Run this once to expose the helper to your shell:")
|
||||
HStack {
|
||||
Text(command)
|
||||
.font(.system(.footnote, design: .monospaced))
|
||||
.lineLimit(2)
|
||||
Spacer()
|
||||
Button(copied ? "Copied" : "Copy") {
|
||||
copyToPasteboard(command)
|
||||
}
|
||||
}
|
||||
.padding(8)
|
||||
.background(RoundedRectangle(cornerRadius: 8).fill(Color(NSColor.controlBackgroundColor)))
|
||||
}
|
||||
}
|
||||
|
||||
private func copyToPasteboard(_ text: String) {
|
||||
let pb = NSPasteboard.general
|
||||
pb.clearContents()
|
||||
pb.setString(text, forType: .string)
|
||||
copied = true
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1.2) { copied = false }
|
||||
}
|
||||
}
|
||||
140
apps/macos/Sources/ClawdisCLI/main.swift
Normal file
140
apps/macos/Sources/ClawdisCLI/main.swift
Normal file
@@ -0,0 +1,140 @@
|
||||
import AsyncXPCConnection
|
||||
import ClawdisIPC
|
||||
import Foundation
|
||||
|
||||
private let serviceName = "com.steipete.clawdis.xpc"
|
||||
|
||||
@objc protocol ClawdisXPCProtocol {
|
||||
func handle(_ data: Data, withReply reply: @escaping @Sendable (Data?, Error?) -> Void)
|
||||
}
|
||||
|
||||
@main
|
||||
struct ClawdisCLI {
|
||||
static func main() async {
|
||||
do {
|
||||
let request = try parseCommandLine()
|
||||
let response = try await send(request: request)
|
||||
let payloadString: String?
|
||||
if let payload = response.payload, let text = String(data: payload, encoding: .utf8) {
|
||||
payloadString = text
|
||||
} else {
|
||||
payloadString = nil
|
||||
}
|
||||
let output: [String: Any] = [
|
||||
"ok": response.ok,
|
||||
"message": response.message ?? "",
|
||||
"payload": payloadString ?? "",
|
||||
]
|
||||
let json = try JSONSerialization.data(withJSONObject: output, options: [.prettyPrinted])
|
||||
FileHandle.standardOutput.write(json)
|
||||
FileHandle.standardOutput.write(Data([0x0A]))
|
||||
exit(response.ok ? 0 : 1)
|
||||
} catch {
|
||||
fputs("clawdis-mac error: \(error)\n", stderr)
|
||||
exit(2)
|
||||
}
|
||||
}
|
||||
|
||||
private static func parseCommandLine() throws -> Request {
|
||||
var args = Array(CommandLine.arguments.dropFirst())
|
||||
guard let command = args.first else { throw CLIError.help }
|
||||
args = Array(args.dropFirst())
|
||||
|
||||
switch command {
|
||||
case "notify":
|
||||
var title: String?
|
||||
var body: String?
|
||||
var sound: String?
|
||||
while !args.isEmpty {
|
||||
let arg = args.removeFirst()
|
||||
switch arg {
|
||||
case "--title": title = args.popFirst()
|
||||
case "--body": body = args.popFirst()
|
||||
case "--sound": sound = args.popFirst()
|
||||
default: break
|
||||
}
|
||||
}
|
||||
guard let t = title, let b = body else { throw CLIError.help }
|
||||
return .notify(title: t, body: b, sound: sound)
|
||||
case "ensure-permissions":
|
||||
var caps: [Capability] = []
|
||||
var interactive = false
|
||||
while !args.isEmpty {
|
||||
let arg = args.removeFirst()
|
||||
switch arg {
|
||||
case "--cap":
|
||||
if let val = args.popFirst(), let cap = Capability(rawValue: val) { caps.append(cap) }
|
||||
case "--interactive": interactive = true
|
||||
default: break
|
||||
}
|
||||
}
|
||||
if caps.isEmpty { caps = Capability.allCases }
|
||||
return .ensurePermissions(caps, interactive: interactive)
|
||||
case "screenshot":
|
||||
var displayID: UInt32?
|
||||
var windowID: UInt32?
|
||||
while !args.isEmpty {
|
||||
let arg = args.removeFirst()
|
||||
switch arg {
|
||||
case "--display-id": if let val = args.popFirst(), let num = UInt32(val) { displayID = num }
|
||||
case "--window-id": if let val = args.popFirst(), let num = UInt32(val) { windowID = num }
|
||||
default: break
|
||||
}
|
||||
}
|
||||
return .screenshot(displayID: displayID, windowID: windowID, format: "png")
|
||||
case "run":
|
||||
var cwd: String?
|
||||
var env: [String: String] = [:]
|
||||
var timeout: Double?
|
||||
var needsSR = false
|
||||
var cmd: [String] = []
|
||||
while !args.isEmpty {
|
||||
let arg = args.removeFirst()
|
||||
switch arg {
|
||||
case "--cwd": cwd = args.popFirst()
|
||||
case "--env":
|
||||
if let pair = args.popFirst(), let eq = pair.firstIndex(of: "=") {
|
||||
let k = String(pair[..<eq]); let v = String(pair[pair.index(after: eq)...]); env[k] = v
|
||||
}
|
||||
case "--timeout": if let val = args.popFirst(), let dbl = Double(val) { timeout = dbl }
|
||||
case "--needs-screen-recording": needsSR = true
|
||||
default:
|
||||
cmd.append(arg)
|
||||
}
|
||||
}
|
||||
return .runShell(command: cmd, cwd: cwd, env: env.isEmpty ? nil : env, timeoutSec: timeout, needsScreenRecording: needsSR)
|
||||
case "status":
|
||||
return .status
|
||||
default:
|
||||
throw CLIError.help
|
||||
}
|
||||
}
|
||||
|
||||
private static func send(request: Request) async throws -> Response {
|
||||
let conn = NSXPCConnection(machServiceName: serviceName)
|
||||
let interface = NSXPCInterface(with: ClawdisXPCProtocol.self)
|
||||
conn.remoteObjectInterface = interface
|
||||
conn.resume()
|
||||
defer { conn.invalidate() }
|
||||
|
||||
let data = try JSONEncoder().encode(request)
|
||||
|
||||
let service = AsyncXPCConnection.RemoteXPCService<ClawdisXPCProtocol>(connection: conn)
|
||||
let raw: Data = try await service.withValueErrorCompletion { proxy, completion in
|
||||
struct CompletionBox: @unchecked Sendable { let handler: (Data?, Error?) -> Void }
|
||||
let box = CompletionBox(handler: completion)
|
||||
proxy.handle(data, withReply: { data, error in box.handler(data, error) })
|
||||
}
|
||||
return try JSONDecoder().decode(Response.self, from: raw)
|
||||
}
|
||||
}
|
||||
|
||||
enum CLIError: Error { case help }
|
||||
|
||||
extension Array where Element == String {
|
||||
mutating func popFirst() -> String? {
|
||||
guard let first else { return nil }
|
||||
self = Array(self.dropFirst())
|
||||
return first
|
||||
}
|
||||
}
|
||||
114
apps/macos/Sources/ClawdisIPC/IPC.swift
Normal file
114
apps/macos/Sources/ClawdisIPC/IPC.swift
Normal file
@@ -0,0 +1,114 @@
|
||||
import Foundation
|
||||
|
||||
// MARK: - Capabilities
|
||||
|
||||
public enum Capability: String, Codable, CaseIterable, Sendable {
|
||||
case notifications
|
||||
case accessibility
|
||||
case screenRecording
|
||||
}
|
||||
|
||||
// MARK: - Requests
|
||||
|
||||
public enum Request: Sendable {
|
||||
case notify(title: String, body: String, sound: String?)
|
||||
case ensurePermissions([Capability], interactive: Bool)
|
||||
case screenshot(displayID: UInt32?, windowID: UInt32?, format: String)
|
||||
case runShell(command: [String], cwd: String?, env: [String: String]?, timeoutSec: Double?, needsScreenRecording: Bool)
|
||||
case status
|
||||
}
|
||||
|
||||
// MARK: - Responses
|
||||
|
||||
public struct Response: Codable, Sendable {
|
||||
public var ok: Bool
|
||||
public var message: String?
|
||||
/// Optional payload (PNG bytes, stdout text, etc.).
|
||||
public var payload: Data?
|
||||
|
||||
public init(ok: Bool, message: String? = nil, payload: Data? = nil) {
|
||||
self.ok = ok
|
||||
self.message = message
|
||||
self.payload = payload
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Codable conformance for Request
|
||||
|
||||
extension Request: Codable {
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case type
|
||||
case title, body, sound
|
||||
case caps, interactive
|
||||
case displayID, windowID, format
|
||||
case command, cwd, env, timeoutSec, needsScreenRecording
|
||||
}
|
||||
|
||||
private enum Kind: String, Codable {
|
||||
case notify
|
||||
case ensurePermissions
|
||||
case screenshot
|
||||
case runShell
|
||||
case status
|
||||
}
|
||||
|
||||
public func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||
switch self {
|
||||
case let .notify(title, body, sound):
|
||||
try container.encode(Kind.notify, forKey: .type)
|
||||
try container.encode(title, forKey: .title)
|
||||
try container.encode(body, forKey: .body)
|
||||
try container.encodeIfPresent(sound, forKey: .sound)
|
||||
case let .ensurePermissions(caps, interactive):
|
||||
try container.encode(Kind.ensurePermissions, forKey: .type)
|
||||
try container.encode(caps, forKey: .caps)
|
||||
try container.encode(interactive, forKey: .interactive)
|
||||
case let .screenshot(displayID, windowID, format):
|
||||
try container.encode(Kind.screenshot, forKey: .type)
|
||||
try container.encodeIfPresent(displayID, forKey: .displayID)
|
||||
try container.encodeIfPresent(windowID, forKey: .windowID)
|
||||
try container.encode(format, forKey: .format)
|
||||
case let .runShell(command, cwd, env, timeoutSec, needsSR):
|
||||
try container.encode(Kind.runShell, forKey: .type)
|
||||
try container.encode(command, forKey: .command)
|
||||
try container.encodeIfPresent(cwd, forKey: .cwd)
|
||||
try container.encodeIfPresent(env, forKey: .env)
|
||||
try container.encodeIfPresent(timeoutSec, forKey: .timeoutSec)
|
||||
try container.encode(needsSR, forKey: .needsScreenRecording)
|
||||
case .status:
|
||||
try container.encode(Kind.status, forKey: .type)
|
||||
}
|
||||
}
|
||||
|
||||
public init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
let kind = try container.decode(Kind.self, forKey: .type)
|
||||
switch kind {
|
||||
case .notify:
|
||||
let title = try container.decode(String.self, forKey: .title)
|
||||
let body = try container.decode(String.self, forKey: .body)
|
||||
let sound = try container.decodeIfPresent(String.self, forKey: .sound)
|
||||
self = .notify(title: title, body: body, sound: sound)
|
||||
case .ensurePermissions:
|
||||
let caps = try container.decode([Capability].self, forKey: .caps)
|
||||
let interactive = try container.decode(Bool.self, forKey: .interactive)
|
||||
self = .ensurePermissions(caps, interactive: interactive)
|
||||
case .screenshot:
|
||||
let displayID = try container.decodeIfPresent(UInt32.self, forKey: .displayID)
|
||||
let windowID = try container.decodeIfPresent(UInt32.self, forKey: .windowID)
|
||||
let format = try container.decode(String.self, forKey: .format)
|
||||
self = .screenshot(displayID: displayID, windowID: windowID, format: format)
|
||||
case .runShell:
|
||||
let command = try container.decode([String].self, forKey: .command)
|
||||
let cwd = try container.decodeIfPresent(String.self, forKey: .cwd)
|
||||
let env = try container.decodeIfPresent([String: String].self, forKey: .env)
|
||||
let timeout = try container.decodeIfPresent(Double.self, forKey: .timeoutSec)
|
||||
let needsSR = try container.decode(Bool.self, forKey: .needsScreenRecording)
|
||||
self = .runShell(command: command, cwd: cwd, env: env, timeoutSec: timeout, needsScreenRecording: needsSR)
|
||||
case .status:
|
||||
self = .status
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
8
apps/macos/Tests/ClawdisIPCTests/Placeholder.swift
Normal file
8
apps/macos/Tests/ClawdisIPCTests/Placeholder.swift
Normal file
@@ -0,0 +1,8 @@
|
||||
import Testing
|
||||
|
||||
@Suite struct PlaceholderTests {
|
||||
@Test func placeholder() {
|
||||
#expect(true)
|
||||
}
|
||||
}
|
||||
|
||||
87
docs/clawdis-mac.md
Normal file
87
docs/clawdis-mac.md
Normal file
@@ -0,0 +1,87 @@
|
||||
# Clawdis macOS Companion (menu bar + XPC broker)
|
||||
|
||||
Author: steipete · Status: draft spec · Date: 2025-12-05
|
||||
|
||||
## Purpose
|
||||
- Single macOS menu-bar app named **Clawdis** that:
|
||||
- Shows native notifications for Clawdis/warelay events.
|
||||
- Owns TCC prompts (Notifications, Accessibility, Screen Recording).
|
||||
- Brokers privileged actions (screen capture, shell with elevated UI context) via XPC.
|
||||
- Provides a tiny CLI (`clawdis-mac`) that talks to the app; Node/TS shells out to it.
|
||||
- Replace the separate notifier helper pattern (Oracle) with a built-in notifier.
|
||||
- Offer a first-run experience similar to VibeTunnel’s onboarding (permissions + CLI install).
|
||||
|
||||
## High-level design
|
||||
- SwiftPM package in `apps/macos/` (macOS 15+, Swift 6):
|
||||
- Dependency: `https://github.com/ChimeHQ/AsyncXPCConnection` (>=0.6.0).
|
||||
- Targets:
|
||||
- `ClawdisIPC` (shared Codable types + helpers).
|
||||
- `Clawdis` (LSUIElement MenuBarExtra app; embeds XPC listener and notifier).
|
||||
- `ClawdisCLI` (client that forms requests, talks XPC, prints JSON for scripts).
|
||||
- Bundle ID: `com.steipete.clawdis`; XPC service name: `com.steipete.clawdis.xpc`.
|
||||
- The CLI lives in the app bundle `Contents/Helpers/clawdis-mac`; dev symlink `bin/clawdis-mac` points there.
|
||||
- Node/TS layer calls the CLI; no direct XPC from Node.
|
||||
|
||||
## IPC contract (ClawdisIPC)
|
||||
- Codable enums; small payloads (<1 MB enforced in listener):
|
||||
|
||||
```
|
||||
enum Capability { notifications, accessibility, screenRecording }
|
||||
enum Request {
|
||||
notify(title, body, sound?)
|
||||
ensurePermissions([Capability], interactive: Bool)
|
||||
screenshot(displayID?, windowID?, format="png")
|
||||
runShell(command:[String], cwd?, env?, timeoutSec?, needsScreenRecording: Bool)
|
||||
status
|
||||
}
|
||||
struct Response { ok: Bool; message?: String; payload?: Data }
|
||||
```
|
||||
- Listener validates caller `auditToken` == same UID, rejects oversize/unknown cases.
|
||||
|
||||
## App UX (Clawdis)
|
||||
- MenuBarExtra icon only (LSUIElement; no Dock).
|
||||
- Menu items: Status, Test Notification, Permissions…, **Pause Clawdis** toggle (temporarily deny privileged actions/notifications without quitting), Quit.
|
||||
- Settings window (Trimmy-style tabs):
|
||||
- General: launch at login toggle, default sound, logging verbosity.
|
||||
- Permissions: live status + “Request” buttons for Notifications/Accessibility/Screen Recording; links to System Settings.
|
||||
- About: version, links, license.
|
||||
- Pause behavior: matches Trimmy’s “Auto Trim” toggle. When paused, XPC listener returns `ok=false, message="clawdis paused"` for actions that would touch TCC (notify/run/screenshot). State is persisted (UserDefaults) and surfaced in menu and status view.
|
||||
- Onboarding (VibeTunnel-inspired): Welcome → What it does → Install CLI (shows `ln -s .../clawdis-mac /usr/local/bin`) → Permissions checklist with live status → Test notification → Done. Re-show when `welcomeVersion` bumps or CLI/app version mismatch.
|
||||
|
||||
## Built-in services
|
||||
- NotificationManager: UNUserNotificationCenter primary; AppleScript `display notification` fallback; respects sound setting.
|
||||
- PermissionManager: checks/requests Notifications, Accessibility (AX), Screen Recording (capture probe); publishes changes for UI.
|
||||
- ScreenCaptureManager: window/display PNG capture; gated on permission.
|
||||
- ShellRunner: executes `Process` with timeout; rejects when `needsScreenRecording` and permission missing; returns stdout/stderr in payload.
|
||||
- XPCListener actor: routes Request → managers; logs via OSLog.
|
||||
|
||||
## CLI (`clawdis-mac`)
|
||||
- Subcommands (JSON out, non-zero exit on failure):
|
||||
- `notify --title --body [--sound]`
|
||||
- `ensure-permissions --cap accessibility --cap screenRecording [--interactive]`
|
||||
- `screenshot [--display-id N | --window-id N] [--out path]`
|
||||
- `run -- cmd args... [--cwd] [--env KEY=VAL] [--timeout 30] [--needs-screen-recording]`
|
||||
- `status`
|
||||
- Internals: builds Request, connects via AsyncXPCConnection, prints Response as JSON to stdout.
|
||||
|
||||
## Integration with warelay/Clawdis (Node/TS)
|
||||
- Add helper module that shells to `clawdis-mac`:
|
||||
- Prefer `ensure-permissions` before actions that need TCC.
|
||||
- Use `notify` for desktop toasts; fall back to JS notifier only if CLI missing or platform ≠ macOS.
|
||||
- Use `run` for tasks requiring privileged UI context (screen-recorded terminal runs, etc.).
|
||||
|
||||
## Permissions strategy
|
||||
- All TCC prompts originate from the app bundle; CLI and Node stay headless.
|
||||
- Permission checks are idempotent; onboarding surfaces missing grants and provides one-click request buttons.
|
||||
|
||||
## Build & dev workflow (native)
|
||||
- `cd native && swift build` (debug) / `swift build -c release`.
|
||||
- Run app for dev: `swift run Clawdis` (or Xcode scheme).
|
||||
- Package app + helper: `swift build -c release && swift package --allow-writing-to-directory ../dist` (tbd exact script).
|
||||
- Tests: add Swift Testing suites under `apps/macos/Tests` (especially IPC round-trips and permission probing fakes).
|
||||
|
||||
## Open questions / decisions
|
||||
- Where to place the dev symlink `bin/clawdis-mac` (repo root vs. `apps/macos/bin`)?
|
||||
- Should `runShell` support streaming stdout/stderr (XPC with AsyncSequence) or just buffered? (Start buffered; streaming later.)
|
||||
- Icon: reuse Clawdis lobster or new mac-specific glyph?
|
||||
- Sparkle updates: out of scope initially; add later if we ship signed builds.
|
||||
Reference in New Issue
Block a user