feat: Nix mode config, UX, onboarding, SwiftPM plist, docs
This commit is contained in:
@@ -49,6 +49,9 @@ let package = Package(
|
||||
.product(name: "PeekabooBridge", package: "PeekabooCore"),
|
||||
.product(name: "PeekabooAutomationKit", package: "PeekabooAutomationKit"),
|
||||
],
|
||||
exclude: [
|
||||
"Resources/Info.plist",
|
||||
],
|
||||
resources: [
|
||||
.copy("Resources/Clawdis.icns"),
|
||||
.copy("Resources/DeviceModels"),
|
||||
|
||||
@@ -20,6 +20,7 @@ enum ClawdisConfigFile {
|
||||
}
|
||||
|
||||
static func saveDict(_ dict: [String: Any]) {
|
||||
if ProcessInfo.processInfo.isNixMode { return }
|
||||
do {
|
||||
let data = try JSONSerialization.data(withJSONObject: dict, options: [.prettyPrinted, .sortedKeys])
|
||||
let url = self.url()
|
||||
|
||||
@@ -3,6 +3,7 @@ import SwiftUI
|
||||
@MainActor
|
||||
struct ConfigSettings: View {
|
||||
private let isPreview = ProcessInfo.processInfo.isPreview
|
||||
private let isNixMode = ProcessInfo.processInfo.isNixMode
|
||||
private let state = AppStateStore.shared
|
||||
private let labelColumnWidth: CGFloat = 120
|
||||
private static let browserAttachOnlyHelp =
|
||||
@@ -52,8 +53,11 @@ struct ConfigSettings: View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
self.header
|
||||
self.agentSection
|
||||
.disabled(self.isNixMode)
|
||||
self.heartbeatSection
|
||||
.disabled(self.isNixMode)
|
||||
self.browserSection
|
||||
.disabled(self.isNixMode)
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
@@ -66,7 +70,9 @@ struct ConfigSettings: View {
|
||||
private var header: some View {
|
||||
Text("Clawdis CLI config")
|
||||
.font(.title3.weight(.semibold))
|
||||
Text("Edit ~/.clawdis/clawdis.json (agent / session / routing / messages).")
|
||||
Text(self.isNixMode
|
||||
? "This tab is read-only in Nix mode. Edit config via Nix and rebuild."
|
||||
: "Edit ~/.clawdis/clawdis.json (agent / session / routing / messages).")
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
@@ -300,7 +306,7 @@ struct ConfigSettings: View {
|
||||
}
|
||||
|
||||
private func autosaveConfig() {
|
||||
guard self.allowAutosave else { return }
|
||||
guard self.allowAutosave, !self.isNixMode else { return }
|
||||
Task { await self.saveConfig() }
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ struct GeneralSettings: View {
|
||||
@State private var remoteStatus: RemoteStatus = .idle
|
||||
@State private var showRemoteAdvanced = false
|
||||
private let isPreview = ProcessInfo.processInfo.isPreview
|
||||
private var isNixMode: Bool { ProcessInfo.processInfo.isNixMode }
|
||||
|
||||
var body: some View {
|
||||
ScrollView(.vertical) {
|
||||
@@ -125,7 +126,10 @@ struct GeneralSettings: View {
|
||||
}
|
||||
|
||||
if self.state.connectionMode == .local {
|
||||
self.gatewayInstallerCard
|
||||
// In Nix mode, gateway is managed declaratively - no install buttons.
|
||||
if !self.isNixMode {
|
||||
self.gatewayInstallerCard
|
||||
}
|
||||
TailscaleIntegrationSection(
|
||||
connectionMode: self.state.connectionMode,
|
||||
isPaused: self.state.isPaused)
|
||||
|
||||
@@ -15,6 +15,13 @@ final class OnboardingController {
|
||||
private var window: NSWindow?
|
||||
|
||||
func show() {
|
||||
if ProcessInfo.processInfo.isNixMode {
|
||||
// Nix mode is fully declarative; onboarding would suggest interactive setup that doesn't apply.
|
||||
UserDefaults.standard.set(true, forKey: "clawdis.onboardingSeen")
|
||||
UserDefaults.standard.set(currentOnboardingVersion, forKey: onboardingVersionKey)
|
||||
AppStateStore.shared.onboardingSeen = true
|
||||
return
|
||||
}
|
||||
if let window {
|
||||
DockIconManager.shared.temporarilyShowDock()
|
||||
window.makeKeyAndOrderFront(nil)
|
||||
|
||||
@@ -45,6 +45,24 @@ struct PermissionStatusList: View {
|
||||
.font(.footnote)
|
||||
.padding(.top, 2)
|
||||
.help("Refresh status")
|
||||
|
||||
if (self.status[.accessibility] ?? false) == false || (self.status[.screenRecording] ?? false) == false {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Note: macOS may require restarting Clawdis after enabling Accessibility or Screen Recording.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
Button {
|
||||
LaunchdManager.startClawdis()
|
||||
} label: {
|
||||
Label("Restart Clawdis", systemImage: "arrow.counterclockwise")
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
}
|
||||
.padding(.top, 4)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,11 @@ extension ProcessInfo {
|
||||
self.environment["XCODE_RUNNING_FOR_PREVIEWS"] == "1"
|
||||
}
|
||||
|
||||
var isNixMode: Bool {
|
||||
if self.environment["CLAWDIS_NIX_MODE"] == "1" { return true }
|
||||
return UserDefaults.standard.bool(forKey: "clawdis.nixMode")
|
||||
}
|
||||
|
||||
var isRunningTests: Bool {
|
||||
// SwiftPM tests load one or more `.xctest` bundles. With Swift Testing, `Bundle.main` is not
|
||||
// guaranteed to be the `.xctest` bundle, so check all loaded bundles.
|
||||
|
||||
73
apps/macos/Sources/Clawdis/Resources/Info.plist
Normal file
73
apps/macos/Sources/Clawdis/Resources/Info.plist
Normal file
@@ -0,0 +1,73 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>en</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>Clawdis</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>com.steipete.clawdis</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>Clawdis</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>0.0.0</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>0.0.0</string>
|
||||
<key>CFBundleIconFile</key>
|
||||
<string>Clawdis</string>
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>CFBundleURLName</key>
|
||||
<string>com.steipete.clawdis.deeplink</string>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>clawdis</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
<string>15.0</string>
|
||||
<key>LSUIElement</key>
|
||||
<true/>
|
||||
|
||||
<key>ClawdisBuildTimestamp</key>
|
||||
<string></string>
|
||||
<key>ClawdisGitCommit</key>
|
||||
<string></string>
|
||||
|
||||
<key>NSUserNotificationUsageDescription</key>
|
||||
<string>Clawdis needs notification permission to show alerts for agent actions.</string>
|
||||
<key>NSScreenCaptureDescription</key>
|
||||
<string>Clawdis captures the screen when the agent needs screenshots for context.</string>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>Clawdis can capture photos or short video clips when requested by the agent.</string>
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>Clawdis needs the mic for Voice Wake tests and agent audio capture.</string>
|
||||
<key>NSSpeechRecognitionUsageDescription</key>
|
||||
<string>Clawdis uses speech recognition to detect your Voice Wake trigger phrase.</string>
|
||||
<key>NSAppleEventsUsageDescription</key>
|
||||
<string>Clawdis needs Automation (AppleScript) permission to drive Terminal and other apps for agent actions.</string>
|
||||
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSAllowsArbitraryLoadsInWebContent</key>
|
||||
<true/>
|
||||
<key>NSExceptionDomains</key>
|
||||
<dict>
|
||||
<key>100.100.100.100</key>
|
||||
<dict>
|
||||
<key>NSExceptionAllowsInsecureHTTPLoads</key>
|
||||
<true/>
|
||||
<key>NSIncludesSubdomains</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</dict>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -8,6 +8,7 @@ struct SettingsRootView: View {
|
||||
@State private var selectedTab: SettingsTab = .general
|
||||
let updater: UpdaterProviding?
|
||||
private let isPreview = ProcessInfo.processInfo.isPreview
|
||||
private let isNixMode = ProcessInfo.processInfo.isNixMode
|
||||
|
||||
init(state: AppState, updater: UpdaterProviding?, initialTab: SettingsTab? = nil) {
|
||||
self.state = state
|
||||
@@ -16,55 +17,61 @@ struct SettingsRootView: View {
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
TabView(selection: self.$selectedTab) {
|
||||
GeneralSettings(state: self.state)
|
||||
.tabItem { Label("General", systemImage: "gearshape") }
|
||||
.tag(SettingsTab.general)
|
||||
|
||||
ConnectionsSettings()
|
||||
.tabItem { Label("Connections", systemImage: "link") }
|
||||
.tag(SettingsTab.connections)
|
||||
|
||||
VoiceWakeSettings(state: self.state)
|
||||
.tabItem { Label("Voice Wake", systemImage: "waveform.circle") }
|
||||
.tag(SettingsTab.voiceWake)
|
||||
|
||||
ConfigSettings()
|
||||
.tabItem { Label("Config", systemImage: "slider.horizontal.3") }
|
||||
.tag(SettingsTab.config)
|
||||
|
||||
InstancesSettings()
|
||||
.tabItem { Label("Instances", systemImage: "network") }
|
||||
.tag(SettingsTab.instances)
|
||||
|
||||
SessionsSettings()
|
||||
.tabItem { Label("Sessions", systemImage: "clock.arrow.circlepath") }
|
||||
.tag(SettingsTab.sessions)
|
||||
|
||||
CronSettings()
|
||||
.tabItem { Label("Cron", systemImage: "calendar") }
|
||||
.tag(SettingsTab.cron)
|
||||
|
||||
SkillsSettings(state: self.state)
|
||||
.tabItem { Label("Skills", systemImage: "sparkles") }
|
||||
.tag(SettingsTab.skills)
|
||||
|
||||
PermissionsSettings(
|
||||
status: self.permissionMonitor.status,
|
||||
refresh: self.refreshPerms,
|
||||
showOnboarding: { OnboardingController.shared.show() })
|
||||
.tabItem { Label("Permissions", systemImage: "lock.shield") }
|
||||
.tag(SettingsTab.permissions)
|
||||
|
||||
if self.state.debugPaneEnabled {
|
||||
DebugSettings()
|
||||
.tabItem { Label("Debug", systemImage: "ant") }
|
||||
.tag(SettingsTab.debug)
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
if self.isNixMode {
|
||||
self.nixManagedBanner
|
||||
}
|
||||
|
||||
AboutSettings(updater: self.updater)
|
||||
.tabItem { Label("About", systemImage: "info.circle") }
|
||||
.tag(SettingsTab.about)
|
||||
TabView(selection: self.$selectedTab) {
|
||||
GeneralSettings(state: self.state)
|
||||
.tabItem { Label("General", systemImage: "gearshape") }
|
||||
.tag(SettingsTab.general)
|
||||
|
||||
ConnectionsSettings()
|
||||
.tabItem { Label("Connections", systemImage: "link") }
|
||||
.tag(SettingsTab.connections)
|
||||
|
||||
VoiceWakeSettings(state: self.state)
|
||||
.tabItem { Label("Voice Wake", systemImage: "waveform.circle") }
|
||||
.tag(SettingsTab.voiceWake)
|
||||
|
||||
ConfigSettings()
|
||||
.tabItem { Label("Config", systemImage: "slider.horizontal.3") }
|
||||
.tag(SettingsTab.config)
|
||||
|
||||
InstancesSettings()
|
||||
.tabItem { Label("Instances", systemImage: "network") }
|
||||
.tag(SettingsTab.instances)
|
||||
|
||||
SessionsSettings()
|
||||
.tabItem { Label("Sessions", systemImage: "clock.arrow.circlepath") }
|
||||
.tag(SettingsTab.sessions)
|
||||
|
||||
CronSettings()
|
||||
.tabItem { Label("Cron", systemImage: "calendar") }
|
||||
.tag(SettingsTab.cron)
|
||||
|
||||
SkillsSettings(state: self.state)
|
||||
.tabItem { Label("Skills", systemImage: "sparkles") }
|
||||
.tag(SettingsTab.skills)
|
||||
|
||||
PermissionsSettings(
|
||||
status: self.permissionMonitor.status,
|
||||
refresh: self.refreshPerms,
|
||||
showOnboarding: { OnboardingController.shared.show() })
|
||||
.tabItem { Label("Permissions", systemImage: "lock.shield") }
|
||||
.tag(SettingsTab.permissions)
|
||||
|
||||
if self.state.debugPaneEnabled {
|
||||
DebugSettings()
|
||||
.tabItem { Label("Debug", systemImage: "ant") }
|
||||
.tag(SettingsTab.debug)
|
||||
}
|
||||
|
||||
AboutSettings(updater: self.updater)
|
||||
.tabItem { Label("About", systemImage: "info.circle") }
|
||||
.tag(SettingsTab.about)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 28)
|
||||
.padding(.vertical, 22)
|
||||
@@ -98,6 +105,35 @@ struct SettingsRootView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private var nixManagedBanner: some View {
|
||||
let configPath = ProcessInfo.processInfo.environment["CLAWDIS_CONFIG_PATH"] ?? "~/.clawdis/clawdis.json"
|
||||
let stateDir = ProcessInfo.processInfo.environment["CLAWDIS_STATE_DIR"] ?? "~/.clawdis"
|
||||
|
||||
return VStack(alignment: .leading, spacing: 6) {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "gearshape.2.fill")
|
||||
.foregroundStyle(.secondary)
|
||||
Text("Managed by Nix")
|
||||
.font(.callout.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Config: \(configPath)")
|
||||
Text("State: \(stateDir)")
|
||||
}
|
||||
.font(.caption.monospaced())
|
||||
.foregroundStyle(.secondary)
|
||||
.textSelection(.enabled)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
.padding(.horizontal, 10)
|
||||
.background(Color.gray.opacity(0.12))
|
||||
.cornerRadius(10)
|
||||
}
|
||||
|
||||
private func validTab(for requested: SettingsTab) -> SettingsTab {
|
||||
if requested == .debug, !self.state.debugPaneEnabled { return .general }
|
||||
return requested
|
||||
|
||||
Reference in New Issue
Block a user