diff --git a/.github/workflows/pr-build.yml b/.github/workflows/pr-build.yml new file mode 100644 index 00000000..bb7bf296 --- /dev/null +++ b/.github/workflows/pr-build.yml @@ -0,0 +1,38 @@ +name: PR Build + +on: + pull_request: + branches: [main] + +jobs: + build: + runs-on: macos-latest + + steps: + - uses: actions/checkout@v4 + + - name: Cache SPM dependencies + uses: actions/cache@v4 + with: + path: .build/checkouts + key: spm-${{ runner.os }}-${{ hashFiles('Package.resolved') }} + restore-keys: | + spm-${{ runner.os }}- + + - name: Select Xcode + run: sudo xcode-select -s /Applications/Xcode.app + + - name: Build and assemble bundle + run: make bundle + + - name: Zip app bundle + run: | + cd .build/universal/debug + zip -r ControlPlane.zip ControlPlane.app + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: ControlPlane-PR${{ github.event.pull_request.number }} + path: .build/universal/debug/ControlPlane.zip + retention-days: 14 diff --git a/CLAUDE.md b/CLAUDE.md index 145f82d7..ff94636c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -209,7 +209,7 @@ All new sensors extend `BaseSensor` (in `ControlPlaneSDK`). `BaseSensor` impleme | `RunningApplicationSensor` | `RunningApplicationSensor` | `com.controlplane.sensors.runningapplication` | Push+Dynamic | Keys are bundle IDs; reading per key → boolean | | `MountedVolumeSensor` | `MountedVolumeSensor` | `com.controlplane.sensors.mountedvolume` | Push | NSWorkspace mount/unmount; reads `mounted` (strings) + per-volume boolean | | `ScreenLockSensor` | `ScreenLockSensor` | `com.controlplane.sensors.screenlock` | Push | DistributedNotificationCenter; reads `locked` boolean | -| `USBSensor` | `USBSensor` | `com.controlplane.sensors.usb` | Push+Dynamic | IOKit; keys are `"vendorID:productID"`; reads `devices` strings | +| `USBSensor` | `USBSensor` | `com.controlplane.sensors.usb` | Push+Dynamic | IOKit; keys are lowercase hex `"vvvv:pppp"` (e.g. `"05ac:12a8"`); emits one boolean reading per connected device (label = product name) plus `devices` strings summary | | `BluetoothSensor` | `BluetoothSensor` | `com.controlplane.sensors.bluetooth` | Push | IOBluetooth; reads `powered`, `devices`, per-MAC boolean | | `NetworkLinkSensor` | `NetworkLinkSensor` | `com.controlplane.sensors.networklink` | Push | SCDynamicStore; reads per-interface boolean + `activeInterfaces` | | `IPAddressSensor` | `IPAddressSensor` | `com.controlplane.sensors.ipaddress` | Push | SCDynamicStore; reads `.ipv4`, `.ipv6`, `allAddresses` | diff --git a/Makefile b/Makefile index abe22223..23189ea9 100644 --- a/Makefile +++ b/Makefile @@ -13,15 +13,19 @@ APP_BINARY := $(APP_BUNDLE)/Contents/MacOS/ControlPlane INFO_PLIST := Resources/ControlPlane-Info.plist ICON := Resources/AppIcon.icns -.PHONY: all build install run clean +.PHONY: all build install run bundle clean ## Default: build universal binaries and install cpctl all: build install -## Build for arm64 and x86_64, then lipo into universal binaries +## Build for arm64 and x86_64, then lipo into universal binaries. +## Each product is built with a separate invocation so SPM cannot silently +## skip one when multiple --product flags are passed on some toolchain versions. build: - $(SWIFT) build -c $(CONFIG) --arch arm64 --product ControlPlane --product cpctl - $(SWIFT) build -c $(CONFIG) --arch x86_64 --product ControlPlane --product cpctl + $(SWIFT) build -c $(CONFIG) --arch arm64 --product ControlPlane + $(SWIFT) build -c $(CONFIG) --arch arm64 --product cpctl + $(SWIFT) build -c $(CONFIG) --arch x86_64 --product ControlPlane + $(SWIFT) build -c $(CONFIG) --arch x86_64 --product cpctl @mkdir -p $(UNIV_DIR) lipo -create $(BIN_ARM)/ControlPlane $(BIN_X86)/ControlPlane -output $(UNIV_DIR)/ControlPlane lipo -create $(BIN_ARM)/cpctl $(BIN_X86)/cpctl -output $(UNIV_DIR)/cpctl @@ -49,7 +53,21 @@ run: build install open "$(APP_BUNDLE)" @echo "ControlPlane running from $(APP_BUNDLE)" -## Remove all build artifacts +## Assemble the app bundle and sign it ad-hoc, without launching. +## Used by CI to produce a downloadable artifact. +bundle: build + @mkdir -p "$(APP_BUNDLE)/Contents/MacOS" + @mkdir -p "$(APP_BUNDLE)/Contents/Resources" + cp $(INFO_PLIST) "$(APP_BUNDLE)/Contents/Info.plist" + cp $(UNIV_DIR)/ControlPlane "$(APP_BINARY)" + cp $(UNIV_DIR)/cpctl "$(APP_BUNDLE)/Contents/MacOS/cpctl" + cp $(ICON) "$(APP_BUNDLE)/Contents/Resources/AppIcon.icns" + codesign --force --deep --sign - --identifier "com.controlplane.app" "$(APP_BUNDLE)" + @echo "Bundle assembled → $(APP_BUNDLE)" + +## Remove all build artifacts. +## Uses rm -rf instead of 'swift package clean' to guarantee a truly clean +## state — swift package clean can leave stale SPM metadata that causes the +## next build to produce only some products. clean: - $(SWIFT) package clean - rm -rf .build/universal + rm -rf .build diff --git a/Sources/ControlPlaneApp/ActionConfigForm.swift b/Sources/ControlPlaneApp/ActionConfigForm.swift new file mode 100644 index 00000000..edf98ac5 --- /dev/null +++ b/Sources/ControlPlaneApp/ActionConfigForm.swift @@ -0,0 +1,631 @@ +import SwiftUI +import AppKit +import AVFoundation +import UniformTypeIdentifiers +import ControlPlaneSDK + +/// Per-action configuration UI rendered inside the Create/Edit Action sheet. +/// Each known plugin ID gets its own purpose-built section; unknown plugins fall +/// back to generic text fields from the plugin's configDescriptors. +struct ActionConfigForm: View { + + let pluginID: String + @Binding var config: [String: String] + + // Lazily-loaded external data + @State private var networkLocations: [String] = [] + @State private var printerNames: [String] = [] + @State private var voices: [(id: String, name: String, locale: String)] = [] + @State private var shortcuts: [(id: String, name: String)] = [] + @State private var installedApps: [(path: String, name: String, bundleID: String)] = [] + @State private var mountedVolumes: [String] = [] + @State private var isLoadingExternal = false + + var body: some View { + Group { + switch pluginID { + case "com.controlplane.action.shellscript": + shellScriptConfig + case "com.controlplane.action.open": + openFileConfig + case "com.controlplane.action.openandhide": + openAndHideConfig + case "com.controlplane.action.openurl": + openURLConfig + case "com.controlplane.action.quitapplication": + quitAppConfig + case "com.controlplane.action.speak": + speakConfig + case "com.controlplane.action.mountvolume": + mountVolumeConfig + case "com.controlplane.action.unmountvolume": + unmountVolumeConfig + case "com.controlplane.action.desktopbackground": + desktopBackgroundConfig + case "com.controlplane.action.togglewifi": + onOffConfig(key: "state", label: "WiFi") + case "com.controlplane.action.preventdisplaysleep": + onOffConfig(key: "state", label: "Prevent Display Sleep") + case "com.controlplane.action.preventsystemsleep": + onOffConfig(key: "state", label: "Prevent System Sleep") + case "com.controlplane.action.networklocation": + networkLocationConfig + case "com.controlplane.action.defaultprinter": + defaultPrinterConfig + case "com.controlplane.action.shortcut": + shortcutConfig + case "com.controlplane.action.timemachinedestination": + timeMachineDestConfig + case "com.controlplane.action.starttimemachine", + "com.controlplane.action.startscreensaver", + "com.controlplane.action.lockkeychain": + // No configuration needed + noConfigNeeded + default: + EmptyView() + } + } + .onAppear { loadExternalData() } + .onChange(of: pluginID) { _ in loadExternalData() } + } + + // MARK: - No-config actions + + private var noConfigNeeded: some View { + Section { + Text("No configuration required for this action.") + .foregroundStyle(.secondary) + } header: { + Text("Configuration") + } + } + + // MARK: - Shell Script + + private var shellScriptConfig: some View { + Section { + pathField( + key: "scriptPath", + label: "Script", + placeholder: "/usr/local/bin/my-script.sh", + panelConfig: PathPanelConfig( + title: "Choose a shell script", + canChooseFiles: true, + canChooseDirectories: false, + allowedTypes: [.shellScript, .unixExecutable, .plainText] + ) + ) + + LabeledContent("Arguments") { + VStack(alignment: .leading, spacing: 4) { + TextField("Optional", text: configBinding("arguments")) + .textFieldStyle(.roundedBorder) + Text("Space-separated arguments passed to the script.") + .font(.caption).foregroundStyle(.secondary) + } + } + } header: { Text("Configuration") } + } + + // MARK: - Open File or Application + + private var openFileConfig: some View { + Section { + pathField( + key: "path", + label: "File or App", + placeholder: "/Applications/Safari.app", + panelConfig: PathPanelConfig( + title: "Choose a file or application to open", + canChooseFiles: true, + canChooseDirectories: true, + allowedTypes: nil + ) + ) + } header: { Text("Configuration") } + } + + // MARK: - Open and Hide Application + + private var openAndHideConfig: some View { + Section { + pathField( + key: "path", + label: "Application", + placeholder: "/Applications/Mail.app", + panelConfig: PathPanelConfig( + title: "Choose an application to open and hide", + canChooseFiles: false, + canChooseDirectories: true, + allowedTypes: [.applicationBundle] + ) + ) + Text("The application launches in the background — its windows will not come to the front.") + .font(.caption).foregroundStyle(.secondary) + } header: { Text("Configuration") } + } + + // MARK: - Open URL + + private var openURLConfig: some View { + Section { + LabeledContent("URL") { + VStack(alignment: .leading, spacing: 4) { + TextField("https://example.com", text: configBinding("url")) + .textFieldStyle(.roundedBorder) + Text("Any URL scheme supported by macOS, e.g. https://, ftp://, or a custom app URL.") + .font(.caption).foregroundStyle(.secondary) + } + } + } header: { Text("Configuration") } + } + + // MARK: - Quit Application + + private var quitAppConfig: some View { + Section { + if installedApps.isEmpty { + LabeledContent("Application") { + VStack(alignment: .leading, spacing: 4) { + TextField("com.apple.Safari", text: configBinding("bundleIdentifier")) + .textFieldStyle(.roundedBorder) + if isLoadingExternal { + ProgressView().controlSize(.small) + } + } + } + } else { + LabeledContent("Application") { + Picker("", selection: configBinding("bundleIdentifier")) { + Text("Choose…").tag("") + ForEach(installedApps, id: \.bundleID) { app in + Text(app.name).tag(app.bundleID) + } + } + .labelsHidden() + } + } + + LabeledContent("Quit Mode") { + Picker("", selection: configBinding("force")) { + Text("Graceful (ask to save)").tag("false") + Text("Force quit (no save prompt)").tag("true") + } + .pickerStyle(.segmented) + .labelsHidden() + .onAppear { + if config["force"] == nil { config["force"] = "false" } + } + } + } header: { Text("Configuration") } + } + + // MARK: - Speak Text + + private var speakConfig: some View { + Section { + LabeledContent("Text to Speak") { + TextField("Welcome home.", text: configBinding("text")) + .textFieldStyle(.roundedBorder) + } + + LabeledContent("Voice") { + if voices.isEmpty { + HStack { + TextField("System default", text: configBinding("voice")) + .textFieldStyle(.roundedBorder) + if isLoadingExternal { ProgressView().controlSize(.small) } + } + } else { + Picker("", selection: configBinding("voice")) { + Text("System default").tag("") + ForEach(groupedVoices, id: \.locale) { group in + Section(group.locale) { + ForEach(group.voices, id: \.id) { voice in + Text(voice.name).tag(voice.id) + } + } + } + } + .labelsHidden() + } + } + } header: { Text("Configuration") } + } + + private var groupedVoices: [(locale: String, voices: [(id: String, name: String)])] { + let grouped = Dictionary(grouping: voices, by: \.locale) + return grouped + .map { (locale: $0.key, voices: $0.value.map { (id: $0.id, name: $0.name) }) } + .sorted { $0.locale < $1.locale } + } + + // MARK: - Mount Volume + + private var mountVolumeConfig: some View { + Section { + LabeledContent("Server URL") { + VStack(alignment: .leading, spacing: 4) { + TextField("smb://server/share", text: configBinding("serverURL")) + .textFieldStyle(.roundedBorder) + Text("Supports smb://, afp://, and nfs:// schemes.") + .font(.caption).foregroundStyle(.secondary) + } + } + } header: { Text("Configuration") } + } + + // MARK: - Unmount Volume + + private var unmountVolumeConfig: some View { + Section { + if mountedVolumes.isEmpty { + pathField( + key: "volumePath", + label: "Volume", + placeholder: "/Volumes/MyDrive", + panelConfig: PathPanelConfig( + title: "Choose a volume to unmount", + canChooseFiles: false, + canChooseDirectories: true, + allowedTypes: nil, + directoryURL: URL(fileURLWithPath: "/Volumes") + ) + ) + } else { + LabeledContent("Volume") { + Picker("", selection: configBinding("volumePath")) { + Text("Choose…").tag("") + ForEach(mountedVolumes, id: \.self) { vol in + Text(vol.hasPrefix("/Volumes/") ? String(vol.dropFirst(9)) : vol) + .tag(vol) + } + } + .labelsHidden() + } + } + } header: { Text("Configuration") } + } + + // MARK: - Desktop Background + + private var desktopBackgroundConfig: some View { + Section { + pathField( + key: "imagePath", + label: "Image", + placeholder: "/path/to/wallpaper.jpg", + panelConfig: PathPanelConfig( + title: "Choose a background image", + canChooseFiles: true, + canChooseDirectories: false, + allowedTypes: [.image] + ) + ) + + LabeledContent("Apply To") { + Picker("", selection: configBinding("screen")) { + Text("All Displays").tag("all") + Text("Main Display Only").tag("main") + } + .pickerStyle(.segmented) + .labelsHidden() + .onAppear { + if config["screen"] == nil { config["screen"] = "all" } + } + } + } header: { Text("Configuration") } + } + + // MARK: - On / Off toggle (WiFi, sleep prevention) + + private func onOffConfig(key: String, label: String) -> some View { + Section { + LabeledContent(label) { + Picker("", selection: configBinding(key)) { + Text("Enable").tag("on") + Text("Disable").tag("off") + } + .pickerStyle(.segmented) + .labelsHidden() + .onAppear { + if config[key] == nil { config[key] = "on" } + } + } + } header: { Text("Configuration") } + } + + // MARK: - Switch Network Location + + private var networkLocationConfig: some View { + Section { + LabeledContent("Location") { + if networkLocations.isEmpty { + HStack { + TextField("Automatic", text: configBinding("locationName")) + .textFieldStyle(.roundedBorder) + if isLoadingExternal { ProgressView().controlSize(.small) } + } + } else { + Picker("", selection: configBinding("locationName")) { + Text("Choose…").tag("") + ForEach(networkLocations, id: \.self) { loc in + Text(loc).tag(loc) + } + } + .labelsHidden() + } + } + } header: { Text("Configuration") } + } + + // MARK: - Set Default Printer + + private var defaultPrinterConfig: some View { + Section { + LabeledContent("Printer") { + if printerNames.isEmpty { + HStack { + TextField("Printer name", text: configBinding("printerName")) + .textFieldStyle(.roundedBorder) + if isLoadingExternal { ProgressView().controlSize(.small) } + } + } else { + Picker("", selection: configBinding("printerName")) { + Text("Choose…").tag("") + ForEach(printerNames, id: \.self) { name in + Text(name).tag(name) + } + } + .labelsHidden() + } + } + } header: { Text("Configuration") } + } + + // MARK: - Run Shortcut + + private var shortcutConfig: some View { + Section { + LabeledContent("Shortcut") { + if shortcuts.isEmpty { + HStack { + TextField("Shortcut UUID", text: configBinding("shortcutID")) + .textFieldStyle(.roundedBorder) + if isLoadingExternal { ProgressView().controlSize(.small) } + } + } else { + Picker("", selection: configBinding("shortcutID")) { + Text("Choose…").tag("") + ForEach(shortcuts, id: \.id) { sc in + Text(sc.name).tag(sc.id) + } + } + .labelsHidden() + .onChange(of: config["shortcutID"]) { newID in + // Auto-fill the display name when the user picks a shortcut. + if let sc = shortcuts.first(where: { $0.id == newID }) { + config["shortcutName"] = sc.name + } + } + } + } + if let name = config["shortcutName"], !name.isEmpty { + LabeledContent("Name") { + Text(name).foregroundStyle(.secondary) + } + } + } header: { Text("Configuration") } + } + + // MARK: - Time Machine Destination + + private var timeMachineDestConfig: some View { + Section { + pathField( + key: "destination", + label: "Destination", + placeholder: "/Volumes/Backup", + panelConfig: PathPanelConfig( + title: "Choose a Time Machine destination", + canChooseFiles: false, + canChooseDirectories: true, + allowedTypes: nil + ) + ) + } header: { Text("Configuration") } + } + + // MARK: - Shared helpers + + /// A text field + Browse button for a file-system path. + private func pathField( + key: String, + label: String, + placeholder: String, + panelConfig: PathPanelConfig + ) -> some View { + LabeledContent(label) { + HStack(spacing: 6) { + TextField(placeholder, text: configBinding(key)) + .textFieldStyle(.roundedBorder) + Button("Browse…") { browseForPath(key: key, config: panelConfig) } + .controlSize(.small) + } + } + } + + private func configBinding(_ key: String) -> Binding { + Binding( + get: { config[key] ?? "" }, + set: { config[key] = $0.isEmpty ? nil : $0 } + ) + } + + private func browseForPath(key: String, config panelConfig: PathPanelConfig) { + let panel = NSOpenPanel() + panel.title = panelConfig.title + panel.canChooseFiles = panelConfig.canChooseFiles + panel.canChooseDirectories = panelConfig.canChooseDirectories + panel.allowsMultipleSelection = false + panel.canCreateDirectories = false + if let types = panelConfig.allowedTypes { panel.allowedContentTypes = types } + if let dir = panelConfig.directoryURL { panel.directoryURL = dir } + if panel.runModal() == .OK, let url = panel.url { + config[key] = url.path + } + } + + // MARK: - External data loading + + private func loadExternalData() { + switch pluginID { + case "com.controlplane.action.networklocation": + loadNetworkLocations() + case "com.controlplane.action.defaultprinter": + loadPrinters() + case "com.controlplane.action.speak": + loadVoices() + case "com.controlplane.action.shortcut": + loadShortcuts() + case "com.controlplane.action.quitapplication": + loadInstalledApps() + case "com.controlplane.action.unmountvolume": + loadMountedVolumes() + default: + break + } + } + + private func loadNetworkLocations() { + guard networkLocations.isEmpty else { return } + isLoadingExternal = true + Task.detached(priority: .userInitiated) { + let pipe = Pipe() + let proc = Process() + proc.executableURL = URL(fileURLWithPath: "/usr/sbin/networksetup") + proc.arguments = ["-listlocations"] + proc.standardOutput = pipe + proc.standardError = Pipe() + try? proc.run() + proc.waitUntilExit() + let data = pipe.fileHandleForReading.readDataToEndOfFile() + let locations = (String(data: data, encoding: .utf8) ?? "") + .components(separatedBy: .newlines) + .map { $0.trimmingCharacters(in: .whitespaces) } + .filter { !$0.isEmpty } + await MainActor.run { + self.networkLocations = locations + self.isLoadingExternal = false + } + } + } + + private func loadPrinters() { + guard printerNames.isEmpty else { return } + let names = NSPrinter.printerNames + printerNames = names.sorted() + } + + private func loadVoices() { + guard voices.isEmpty else { return } + let allVoices = NSSpeechSynthesizer.availableVoices + voices = allVoices.compactMap { voiceID in + let attrs = NSSpeechSynthesizer.attributes(forVoice: voiceID) + guard let name = attrs[NSSpeechSynthesizer.VoiceAttributeKey.name] as? String else { return nil } + let locale = attrs[NSSpeechSynthesizer.VoiceAttributeKey.localeIdentifier] as? String ?? "Other" + // Convert locale like "en_US" to "English (US)" + let displayLocale = Locale(identifier: locale) + .localizedString(forIdentifier: locale) ?? locale + return (id: voiceID.rawValue, name: name, locale: displayLocale) + } + .sorted { $0.locale < $1.locale } + } + + private func loadShortcuts() { + guard shortcuts.isEmpty else { return } + isLoadingExternal = true + Task.detached(priority: .userInitiated) { + let pipe = Pipe() + let proc = Process() + proc.executableURL = URL(fileURLWithPath: "/usr/bin/shortcuts") + proc.arguments = ["list", "--show-identifiers"] + proc.standardOutput = pipe + proc.standardError = Pipe() + try? proc.run() + proc.waitUntilExit() + let data = pipe.fileHandleForReading.readDataToEndOfFile() + let output = String(data: data, encoding: .utf8) ?? "" + // Each line: "Shortcut Name (UUID)" + let parsed: [(id: String, name: String)] = output + .components(separatedBy: .newlines) + .compactMap { line in + let trimmed = line.trimmingCharacters(in: .whitespaces) + guard trimmed.hasSuffix(")"), + let openParen = trimmed.lastIndex(of: "(") else { return nil } + let name = String(trimmed[trimmed.startIndex.. Action { + let action = Action(name: name, actionPluginID: actionPluginID, config: config) + let record = try ActionRecord(action) + try await db.dbQueue.write { db in try record.insert(db) } + log("Action created: \(action.id) name=\(name) plugin=\(actionPluginID)", CPLogger.actions) + return action + } + + func get(_ id: UUID) async throws -> Action { + guard let record = try await db.dbQueue.read({ db in + try ActionRecord.fetchOne(db, key: id.uuidString) + }) else { + throw CPError.invalidData("Action not found: \(id)") + } + return try record.toAction() + } + + func update( + id: UUID, + name: String, + actionPluginID: String, + config: [String: String], + enabled: Bool + ) async throws -> Action { + let existing = try await get(id) + let updated = Action( + id: existing.id, + name: name, + actionPluginID: actionPluginID, + config: config, + enabled: enabled, + createdAt: existing.createdAt, + updatedAt: Date() + ) + let record = try ActionRecord(updated) + try await db.dbQueue.write { db in try record.update(db) } + log("Action updated: \(id) name=\(name)", CPLogger.actions) + return updated + } + + func delete(_ id: UUID) async throws { + try await db.dbQueue.write { db in + guard try ActionRecord.fetchOne(db, key: id.uuidString) != nil else { + throw CPError.invalidData("Action not found: \(id)") + } + try ActionRecord.deleteOne(db, key: id.uuidString) + } + log("Action deleted: \(id)", CPLogger.actions) + } + + func listAll() async throws -> [Action] { + let records = try await db.dbQueue.read { db in + try ActionRecord.order(Column("createdAt")).fetchAll(db) + } + return try records.map { try $0.toAction() } + } +} + +// MARK: - ProfileActionLinkStore + +/// Persists profile ↔ action links to SQLite. +actor ProfileActionLinkStore { + private let db: AppDatabase + + init(db: AppDatabase) { + self.db = db + } + + // MARK: - CRUD + + func link(profileID: UUID, actionID: UUID, trigger: ActionTrigger) async throws -> ProfileActionLink { + let link = ProfileActionLink(profileID: profileID, actionID: actionID, trigger: trigger) + let record = ProfileActionLinkRecord(link) + try await db.dbQueue.write { db in try record.insert(db) } + log("ProfileActionLink created: \(link.id) profile=\(profileID) action=\(actionID) trigger=\(trigger.rawValue)", CPLogger.actions) + return link + } + + func unlink(_ id: UUID) async throws { + try await db.dbQueue.write { db in + try ProfileActionLinkRecord.deleteOne(db, key: id.uuidString) + } + log("ProfileActionLink deleted: \(id)", CPLogger.actions) + } + + func setEnabled(_ id: UUID, enabled: Bool) async throws { + try await db.dbQueue.write { db in + try db.execute( + sql: "UPDATE profileActionLinks SET enabled = ? WHERE id = ?", + arguments: [enabled, id.uuidString] + ) + } + } + + func list(forProfile profileID: UUID) async throws -> [ProfileActionLink] { + let records = try await db.dbQueue.read { db in + try ProfileActionLinkRecord + .filter(Column("profileId") == profileID.uuidString) + .order(Column("createdAt")) + .fetchAll(db) + } + return records.map { $0.toLink() } + } + + func listAll() async throws -> [ProfileActionLink] { + let records = try await db.dbQueue.read { db in + try ProfileActionLinkRecord.order(Column("createdAt")).fetchAll(db) + } + return records.map { $0.toLink() } + } + + func recordTriggered(_ id: UUID) async throws { + let ts = ISO8601DateFormatter().string(from: Date()) + try await db.dbQueue.write { db in + try db.execute( + sql: "UPDATE profileActionLinks SET lastTriggeredAt = ? WHERE id = ?", + arguments: [ts, id.uuidString] + ) + } + } +} + +// MARK: - GRDB Records + +private struct ActionRecord: Codable, FetchableRecord, PersistableRecord { + static let databaseTableName = "actions" + + var id: String + var name: String + var actionPluginId: String + var config: String // JSON-encoded [String: String] + var enabled: Bool + var createdAt: String + var updatedAt: String + + private static let iso = ISO8601DateFormatter() + private static let jsonEncoder = JSONEncoder() + private static let jsonDecoder = JSONDecoder() + + init(_ action: Action) throws { + id = action.id.uuidString + name = action.name + actionPluginId = action.actionPluginID + let configData = try Self.jsonEncoder.encode(action.config) + config = String(data: configData, encoding: .utf8) ?? "{}" + enabled = action.enabled + createdAt = Self.iso.string(from: action.createdAt) + updatedAt = Self.iso.string(from: action.updatedAt) + } + + func toAction() throws -> Action { + let configData = config.data(using: .utf8) ?? Data() + let configDict = (try? Self.jsonDecoder.decode([String: String].self, from: configData)) ?? [:] + return Action( + id: UUID(uuidString: id)!, + name: name, + actionPluginID: actionPluginId, + config: configDict, + enabled: enabled, + createdAt: Self.iso.date(from: createdAt) ?? Date(), + updatedAt: Self.iso.date(from: updatedAt) ?? Date() + ) + } +} + +private struct ProfileActionLinkRecord: Codable, FetchableRecord, PersistableRecord { + static let databaseTableName = "profileActionLinks" + + var id: String + var profileId: String + var actionId: String + var trigger: String + var enabled: Bool + var createdAt: String + var lastTriggeredAt: String? + + private static let iso = ISO8601DateFormatter() + + init(_ link: ProfileActionLink) { + id = link.id.uuidString + profileId = link.profileID.uuidString + actionId = link.actionID.uuidString + trigger = link.trigger.rawValue + enabled = link.enabled + createdAt = Self.iso.string(from: link.createdAt) + lastTriggeredAt = link.lastTriggeredAt.map { Self.iso.string(from: $0) } + } + + func toLink() -> ProfileActionLink { + ProfileActionLink( + id: UUID(uuidString: id)!, + profileID: UUID(uuidString: profileId)!, + actionID: UUID(uuidString: actionId)!, + trigger: ActionTrigger(rawValue: trigger) ?? .onActivate, + enabled: enabled, + createdAt: Self.iso.date(from: createdAt) ?? Date(), + lastTriggeredAt: lastTriggeredAt.flatMap { Self.iso.date(from: $0) } + ) + } +} diff --git a/Sources/ControlPlaneApp/ActionsListView.swift b/Sources/ControlPlaneApp/ActionsListView.swift index 16bd4005..6056c694 100644 --- a/Sources/ControlPlaneApp/ActionsListView.swift +++ b/Sources/ControlPlaneApp/ActionsListView.swift @@ -10,7 +10,7 @@ struct ActionsListView: View { @State private var selectedActionIDs = Set() @State private var showingCreateAction = false - private var actions: [ProfileAction] { store.actions(for: profile.id) } + private var actions: [ProfileAction] { store.legacyActions(for: profile.id) } var body: some View { VStack(spacing: 0) { diff --git a/Sources/ControlPlaneApp/ActionsTabView.swift b/Sources/ControlPlaneApp/ActionsTabView.swift new file mode 100644 index 00000000..4893dab8 --- /dev/null +++ b/Sources/ControlPlaneApp/ActionsTabView.swift @@ -0,0 +1,261 @@ +import SwiftUI +import ControlPlaneSDK + +/// Global action library tab. +/// Each row shows the action type, name, and which profiles it is linked to. +/// Actions are defined here; assignment to profiles happens on the Profiles tab. +struct ActionsTabView: View { + + @ObservedObject var store: ControlPlaneStore + @State private var selectedActionIDs = Set() + @State private var showingCreateAction = false + @State private var editingAction: Action? = nil + @State private var deletingAction: Action? = nil + + private var singleSelection: Action? { + guard selectedActionIDs.count == 1, let id = selectedActionIDs.first else { return nil } + return store.actions.first { $0.id == id } + } + + var body: some View { + VStack(spacing: 0) { + if store.actions.isEmpty { + emptyState + } else { + actionTable + } + Divider() + toolbar + } + .sheet(isPresented: $showingCreateAction) { + CreateOrEditActionView(store: store) + } + .sheet(item: $editingAction) { action in + CreateOrEditActionView(store: store, existingAction: action) + } + .alert( + "Delete Action", + isPresented: Binding(get: { deletingAction != nil }, set: { if !$0 { deletingAction = nil } }) + ) { + Button("Cancel", role: .cancel) { deletingAction = nil } + Button("Delete", role: .destructive) { + if let a = deletingAction { + Task { await store.deleteAction(a) } + deletingAction = nil + } + } + } message: { + if let a = deletingAction { + let usedBy = store.profileNames(linkedTo: a) + if usedBy.isEmpty { + Text("Delete \"\(a.name)\"?") + } else { + Text("\"\(a.name)\" is assigned to \(usedBy). Deleting it will remove it from those profiles.") + } + } + } + } + + // MARK: - Empty state + + private var emptyState: some View { + VStack(spacing: 12) { + Image(systemName: "bolt.badge.clock") + .font(.system(size: 40)) + .foregroundStyle(.secondary) + Text("No actions yet") + .font(.title3) + .foregroundStyle(.secondary) + Text("Create reusable actions here, then assign them to profiles.") + .font(.callout) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + Button("New Action") { showingCreateAction = true } + .buttonStyle(.borderedProminent) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + // MARK: - Table + + private var actionTable: some View { + Table(store.actions, selection: $selectedActionIDs) { + TableColumn("Type") { action in + if let typeInfo = store.actionType(for: action.actionPluginID) { + Text(typeInfo.displayName) + .lineLimit(1) + } else { + Text(action.actionPluginID) + .font(.system(.body, design: .monospaced)) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + .width(min: 120, ideal: 150) + + TableColumn("Name") { action in + Text(action.name) + .lineLimit(1) + .fontWeight(action.enabled ? .regular : .light) + .foregroundStyle(action.enabled ? .primary : .secondary) + } + + TableColumn("Used By") { action in + let names = store.profileNames(linkedTo: action) + Text(names.isEmpty ? "—" : names) + .foregroundStyle(names.isEmpty ? .secondary : .primary) + .lineLimit(1) + } + } + .contextMenu(forSelectionType: UUID.self) { ids in + if ids.count == 1, let action = store.actions.first(where: { ids.contains($0.id) }) { + Button("Edit \"\(action.name)\"") { editingAction = action } + Divider() + Button("Delete \"\(action.name)\"", role: .destructive) { deletingAction = action } + } else if ids.count > 1 { + Button("Delete \(ids.count) Actions", role: .destructive) { + let toDelete = store.actions.filter { ids.contains($0.id) } + Task { for a in toDelete { await store.deleteAction(a) } } + selectedActionIDs.removeAll() + } + } + } + } + + // MARK: - Toolbar + + private var toolbar: some View { + HStack(spacing: 0) { + Button { showingCreateAction = true } label: { + Image(systemName: "plus").frame(width: 28, height: 24) + } + .buttonStyle(.borderless) + .help("New action") + + Button { + if let a = singleSelection { deletingAction = a } + } label: { + Image(systemName: "minus").frame(width: 28, height: 24) + } + .buttonStyle(.borderless) + .disabled(singleSelection == nil) + .help("Delete selected action") + + Button { + if let a = singleSelection { editingAction = a } + } label: { + Image(systemName: "pencil").frame(width: 28, height: 24) + } + .buttonStyle(.borderless) + .disabled(singleSelection == nil) + .help("Edit selected action") + + Spacer() + + Text("\(store.actions.count) action\(store.actions.count == 1 ? "" : "s")") + .font(.caption) + .foregroundStyle(.secondary) + .padding(.trailing, 8) + } + .padding(.horizontal, 2) + .padding(.vertical, 4) + } +} + +// MARK: - Create / Edit Action Sheet + +struct CreateOrEditActionView: View { + + @ObservedObject var store: ControlPlaneStore + var existingAction: Action? + + @Environment(\.dismiss) private var dismiss + + @State private var name: String + @State private var selectedPluginID: String + @State private var config: [String: String] + @State private var enabled: Bool + + init(store: ControlPlaneStore, existingAction: Action? = nil) { + self.store = store + self.existingAction = existingAction + _name = State(initialValue: existingAction?.name ?? "") + _selectedPluginID = State(initialValue: existingAction?.actionPluginID ?? "") + _config = State(initialValue: existingAction?.config ?? [:]) + _enabled = State(initialValue: existingAction?.enabled ?? true) + } + + private var isEditing: Bool { existingAction != nil } + private var isValid: Bool { + !name.trimmingCharacters(in: .whitespaces).isEmpty && !selectedPluginID.isEmpty + } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + // Title + Text(isEditing ? "Edit Action" : "New Action") + .font(.headline) + .padding() + + Divider() + + Form { + Section { + TextField("Name", text: $name) + .textFieldStyle(.roundedBorder) + + Picker("Type", selection: $selectedPluginID) { + Text("Choose…").tag("").disabled(true) + ForEach(store.actionTypes) { type in + Text(type.displayName).tag(type.id) + } + } + .onChange(of: selectedPluginID) { _ in + // Clear config when type changes to avoid stale keys. + if existingAction == nil || selectedPluginID != existingAction?.actionPluginID { + config = [:] + } + } + + Toggle("Enabled", isOn: $enabled) + } header: { + Text("General") + } + + if !selectedPluginID.isEmpty { + ActionConfigForm(pluginID: selectedPluginID, config: $config) + } + } + .formStyle(.grouped) + + Divider() + + HStack { + Spacer() + Button("Cancel") { dismiss() } + .keyboardShortcut(.cancelAction) + Button(isEditing ? "Save" : "Create") { + let trimmedName = name.trimmingCharacters(in: .whitespaces) + if let existing = existingAction { + Task { + await store.updateAction(existing, name: trimmedName, + actionPluginID: selectedPluginID, + config: config, enabled: enabled) + } + } else { + Task { + await store.createAction(name: trimmedName, + actionPluginID: selectedPluginID, + config: config) + } + } + dismiss() + } + .keyboardShortcut(.defaultAction) + .disabled(!isValid) + } + .padding() + } + .frame(width: 460) + } +} diff --git a/Sources/ControlPlaneApp/AppDatabase.swift b/Sources/ControlPlaneApp/AppDatabase.swift index b704246c..b8cac2af 100644 --- a/Sources/ControlPlaneApp/AppDatabase.swift +++ b/Sources/ControlPlaneApp/AppDatabase.swift @@ -131,6 +131,34 @@ final class AppDatabase { } } + migrator.registerMigration("v7_global_actions") { db in + // Global action library — action definitions decoupled from profiles. + try db.create(table: "actions") { t in + t.column("id", .text).primaryKey() + t.column("name", .text).notNull() + t.column("actionPluginId", .text).notNull() + // JSON-encoded [String: String] config dictionary + t.column("config", .text).notNull().defaults(to: "{}") + t.column("enabled", .boolean).notNull().defaults(to: true) + t.column("createdAt", .text).notNull() + t.column("updatedAt", .text).notNull() + } + + // Profile ↔ action links — replaces profileActions. + try db.create(table: "profileActionLinks") { t in + t.column("id", .text).primaryKey() + t.column("profileId", .text).notNull() + .references("profiles", onDelete: .cascade) + t.column("actionId", .text).notNull() + .references("actions", onDelete: .cascade) + // "onActivate" | "onDeactivate" + t.column("trigger", .text).notNull() + t.column("enabled", .boolean).notNull().defaults(to: true) + t.column("createdAt", .text).notNull() + t.column("lastTriggeredAt", .text) + } + } + try migrator.migrate(dbQueue) } diff --git a/Sources/ControlPlaneApp/AppDelegate.swift b/Sources/ControlPlaneApp/AppDelegate.swift index 165646dc..bda9b8df 100644 --- a/Sources/ControlPlaneApp/AppDelegate.swift +++ b/Sources/ControlPlaneApp/AppDelegate.swift @@ -32,9 +32,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate { .sink { [weak self] active in self?.rebuildProfileSection(active) } .store(in: &cancellables) - // Rebuild the Run Actions submenu whenever actions, profiles, or action types change. - store.$profileActions - .combineLatest(store.$profiles, store.$actionTypes) + // Rebuild the Run Actions submenu whenever actions or action types change. + store.$actions + .combineLatest(store.$actionTypes) .receive(on: DispatchQueue.main) .sink { [weak self] _ in self?.rebuildRunActionsMenu() } .store(in: &cancellables) @@ -91,8 +91,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate { menu.addItem(.separator()) menu.addItem( - NSMenuItem(title: "Preferences…", - action: #selector(openPreferences), + NSMenuItem(title: "Settings…", + action: #selector(openSettings), keyEquivalent: ",") ) @@ -137,37 +137,19 @@ final class AppDelegate: NSObject, NSApplicationDelegate { guard let submenu = runActionsMenuItem?.submenu else { return } submenu.removeAllItems() - // Sort by profile name, then by creation date within a profile. - let actions = store.profileActions.sorted { a, b in - let nameA = store.profiles.first { $0.id == a.profileID }?.name ?? "" - let nameB = store.profiles.first { $0.id == b.profileID }?.name ?? "" - return nameA == nameB ? a.createdAt < b.createdAt : nameA < nameB - } - - if actions.isEmpty { + let allActions = store.actions.sorted { $0.name < $1.name } + guard !allActions.isEmpty else { let empty = NSMenuItem(title: "No actions configured", action: nil, keyEquivalent: "") empty.isEnabled = false submenu.addItem(empty) return } - // Group by profile with a section header for each. - var lastProfileID: UUID? = nil - for action in actions { - let profileName = store.profiles.first { $0.id == action.profileID }?.name ?? "Unknown Profile" - let typeName = store.actionType(for: action.actionPluginID)?.displayName ?? action.actionPluginID - let trigger = action.trigger == ActionTrigger.onActivate ? "activate" : "deactivate" - - if action.profileID != lastProfileID { - if lastProfileID != nil { submenu.addItem(.separator()) } - let header = NSMenuItem(title: profileName, action: nil, keyEquivalent: "") - header.isEnabled = false - submenu.addItem(header) - lastProfileID = action.profileID - } - - let title = " \(typeName) (\(trigger))" - let item = NSMenuItem(title: title, action: #selector(runActionItem(_:)), keyEquivalent: "") + for action in allActions { + let typeName = store.actionType(for: action.actionPluginID)?.displayName + ?? action.actionPluginID + let title = "\(action.name) (\(typeName))" + let item = NSMenuItem(title: title, action: #selector(runActionItem(_:)), keyEquivalent: "") item.representedObject = action item.target = self if !action.enabled { @@ -179,22 +161,19 @@ final class AppDelegate: NSObject, NSApplicationDelegate { } @objc private func runActionItem(_ sender: NSMenuItem) { - guard let action = sender.representedObject as? ProfileAction else { return } - // Access @MainActor-isolated store on the main actor, then hop to a plain - // Task for the async plugin execution. + guard let action = sender.representedObject as? Action else { return } Task { @MainActor [weak self] in guard let self else { return } - guard let profile = self.store.profiles.first(where: { $0.id == action.profileID }) else { - log("Run Action: profile \(action.profileID) not found", CPLogger.actions) - return - } guard let plugin = await self.backend.actionRegistry.plugin(for: action.actionPluginID) else { log("Run Action: plugin '\(action.actionPluginID)' not loaded", CPLogger.actions) return } + // No profile context for on-demand execution; use a placeholder. + // Most action plugins ignore the trigger and profile parameters. + let placeholder = Profile(name: "Manual", exclusive: false, confidenceThreshold: 1.0) do { - log("Run Action: executing \(action.actionPluginID) [\(action.trigger.rawValue)] for \"\(profile.name)\"", CPLogger.actions) - try await plugin.execute(trigger: action.trigger, profile: profile, config: action.config) + log("Run Action: executing \(action.name)", CPLogger.actions) + try await plugin.execute(trigger: .onActivate, profile: placeholder, config: action.config) log("Run Action: done", CPLogger.actions) } catch { logError("Run Action failed: \(error)", CPLogger.actions) @@ -231,9 +210,19 @@ final class AppDelegate: NSObject, NSApplicationDelegate { // MARK: - Actions - @objc private func openPreferences() { + @objc private func openSettings() { Task { @MainActor in - PreferencesWindowController.show(store: store) + SettingsWindowController.show( + store: store, + onOpen: { [weak self] in + guard let self else { return } + Task { await self.backend.applyRunPolicy(settingsOpen: true) } + }, + onClose: { [weak self] in + guard let self else { return } + Task { await self.backend.applyRunPolicy(settingsOpen: false) } + } + ) } } } diff --git a/Sources/ControlPlaneApp/Backend.swift b/Sources/ControlPlaneApp/Backend.swift index 42db5b21..2d56cf1e 100644 --- a/Sources/ControlPlaneApp/Backend.swift +++ b/Sources/ControlPlaneApp/Backend.swift @@ -1,4 +1,5 @@ import Foundation +import AppKit import ControlPlaneSDK import WiFiSensor import FilePresenceSensor @@ -52,14 +53,18 @@ final class Backend { profileStore: profileStore, evaluatorRegistry: evaluatorRegistry ) - lazy var profileActionStore = ProfileActionStore(db: appDatabase) + lazy var profileActionStore = ProfileActionStore(db: appDatabase) + lazy var actionStore = ActionStore(db: appDatabase) + lazy var profileActionLinkStore = ProfileActionLinkStore(db: appDatabase) lazy var profileActivationManager = ProfileActivationManager( - actionStore: profileActionStore, + linkStore: profileActionLinkStore, + actionStore: actionStore, actionRegistry: actionRegistry, profileStore: profileStore ) let startedAt = Date() private let locationAuthorizer = LocationAuthorizer() + private var sleepWakeObserver: NSObjectProtocol? private lazy var pluginLoader = PluginLoader(registry: pluginRegistry, sensors: sensorCoordinator) private var socketServer: SocketServer? @@ -81,6 +86,20 @@ final class Backend { Task { await self.sensorCoordinator.refreshAllSensors() } } locationAuthorizer.requestIfNeeded() + // Refresh all sensors when the system wakes from sleep. + // Network state (WiFi SSID, link status, IP addresses) may have changed + // while asleep; the sensor-level callbacks will also fire as interfaces + // come back up, but an explicit refresh eliminates the stale-data window + // between wake and the first hardware event. + sleepWakeObserver = NSWorkspace.shared.notificationCenter.addObserver( + forName: NSWorkspace.didWakeNotification, + object: nil as AnyObject?, + queue: nil as OperationQueue? + ) { [weak self] (_: Notification) in + guard let self else { return } + log("System woke from sleep — refreshing all sensors", CPLogger.sensors) + Task { await self.sensorCoordinator.refreshAllSensors() } + } setupSocketServer() registerStaticEvaluators() registerStaticActions() @@ -159,8 +178,10 @@ final class Backend { if LaptopLidSensor.isApplicable() { await registerSensor(LaptopLidSensor()) } - // After sensors are registered, push current rule keys to any dynamic sensors. + // After sensors are registered, push current rule keys to any dynamic sensors + // and apply the run policy (only start sensors that have rules). await refreshDynamicSensorKeys() + await applyRunPolicy(settingsOpen: false) } } @@ -189,12 +210,41 @@ final class Backend { } catch { logError("refreshDynamicSensorKeys error: \(error)", CPLogger.rules) } + // Re-apply the run policy — a rule might have been added to or removed + // from a sensor, changing which sensors need to be running. + await applyRunPolicy(settingsOpen: false) // Force an evaluation with current snapshots so that rule edits (negate // toggle, weight change, enable/disable) take effect immediately regardless // of whether any sensor happened to push a new snapshot on its own. await sensorCoordinator.triggerSnapshotCallback() } + /// Returns the set of sensor IDs that are referenced by at least one enabled rule. + func sensorIDsNeededForRules() async -> Set { + do { + let rules = try await ruleStore.list() + return Set(rules.filter(\.enabled).map(\.sensorID)) + } catch { + logError("sensorIDsNeededForRules error: \(error)", CPLogger.rules) + return [] + } + } + + /// Apply the sensor run policy. + /// + /// - `settingsOpen = true`: start all registered sensors so the user sees + /// live readings for every sensor while configuring rules. + /// - `settingsOpen = false`: stop sensors that have no enabled rules; only + /// sensors referenced by at least one enabled rule keep running. + func applyRunPolicy(settingsOpen: Bool) async { + if settingsOpen { + await sensorCoordinator.startAll() + } else { + let neededIDs = await sensorIDsNeededForRules() + await sensorCoordinator.applyRunPolicy(neededIDs: neededIDs) + } + } + private func registerSensor(_ sensor: any SensorPlugin) async { guard type(of: sensor).isApplicable() else { log("Sensor \(sensor.pluginIdentifier) is not applicable on this system — skipping", CPLogger.sensors) @@ -208,7 +258,7 @@ final class Backend { source: .bundled ) await pluginRegistry.register(info) - await sensorCoordinator.add(sensor) + await sensorCoordinator.register(sensor) } private func setupSocketServer() { diff --git a/Sources/ControlPlaneApp/ControlPlaneStore.swift b/Sources/ControlPlaneApp/ControlPlaneStore.swift index 20ea86fd..79e5a973 100644 --- a/Sources/ControlPlaneApp/ControlPlaneStore.swift +++ b/Sources/ControlPlaneApp/ControlPlaneStore.swift @@ -14,6 +14,10 @@ final class ControlPlaneStore: ObservableObject { @Published var profiles: [Profile] = [] @Published var rules: [Rule] = [] @Published var profileActions: [ProfileAction] = [] + /// Global reusable action definitions. + @Published var actions: [Action] = [] + /// Profile ↔ action assignments. + @Published var profileActionLinks: [ProfileActionLink] = [] @Published var snapshots: [SensorSnapshot] = [] @Published var actionTypes: [ActionTypeInfo] = [] @Published var operators: [OperatorDescriptor] = [] @@ -90,6 +94,8 @@ final class ControlPlaneStore: ObservableObject { await refreshSnapshots() await refreshProfileActions() + await refreshActions() + await refreshProfileActionLinks() } func refreshSnapshots() async { @@ -100,8 +106,8 @@ final class ControlPlaneStore: ObservableObject { do { var all: [ProfileAction] = [] for p in profiles { - let actions = try await backend.profileActionStore.list(forProfile: p.id) - all += actions + let acts = try await backend.profileActionStore.list(forProfile: p.id) + all += acts } profileActions = all } catch { @@ -109,6 +115,16 @@ final class ControlPlaneStore: ObservableObject { } } + func refreshActions() async { + do { actions = try await backend.actionStore.listAll() } + catch { errorMessage = error.localizedDescription } + } + + func refreshProfileActionLinks() async { + do { profileActionLinks = try await backend.profileActionLinkStore.listAll() } + catch { errorMessage = error.localizedDescription } + } + /// Poll sensor snapshots every 2 s so the Sensors tab stays live. private func startSnapshotRefresh() { snapshotRefreshTask?.cancel() @@ -161,6 +177,7 @@ final class ControlPlaneStore: ObservableObject { profiles.removeAll { $0.id == profile.id } rules.removeAll { $0.profileID == profile.id } profileActions.removeAll { $0.profileID == profile.id } + profileActionLinks.removeAll { $0.profileID == profile.id } } catch { errorMessage = error.localizedDescription } @@ -243,7 +260,79 @@ final class ControlPlaneStore: ObservableObject { } } - // MARK: - ProfileAction CRUD + // MARK: - Action CRUD (global library) + + func createAction(name: String, actionPluginID: String, config: [String: String]) async { + do { + let a = try await backend.actionStore.create(name: name, actionPluginID: actionPluginID, config: config) + actions.append(a) + } catch { + errorMessage = error.localizedDescription + } + } + + func updateAction(_ action: Action, name: String, actionPluginID: String, config: [String: String], enabled: Bool) async { + do { + let updated = try await backend.actionStore.update( + id: action.id, name: name, actionPluginID: actionPluginID, + config: config, enabled: enabled + ) + if let idx = actions.firstIndex(where: { $0.id == action.id }) { + actions[idx] = updated + } + } catch { + errorMessage = error.localizedDescription + } + } + + func deleteAction(_ action: Action) async { + do { + try await backend.actionStore.delete(action.id) + actions.removeAll { $0.id == action.id } + profileActionLinks.removeAll { $0.actionID == action.id } + } catch { + errorMessage = error.localizedDescription + } + } + + // MARK: - ProfileActionLink CRUD + + func linkAction(_ action: Action, to profile: Profile, trigger: ActionTrigger) async { + // Prevent duplicate links for same profile/action/trigger combination. + guard !profileActionLinks.contains(where: { + $0.profileID == profile.id && $0.actionID == action.id && $0.trigger == trigger + }) else { return } + do { + let link = try await backend.profileActionLinkStore.link( + profileID: profile.id, actionID: action.id, trigger: trigger + ) + profileActionLinks.append(link) + } catch { + errorMessage = error.localizedDescription + } + } + + func unlinkAction(_ link: ProfileActionLink) async { + do { + try await backend.profileActionLinkStore.unlink(link.id) + profileActionLinks.removeAll { $0.id == link.id } + } catch { + errorMessage = error.localizedDescription + } + } + + func setProfileActionLinkEnabled(_ link: ProfileActionLink, enabled: Bool) async { + do { + try await backend.profileActionLinkStore.setEnabled(link.id, enabled: enabled) + if let idx = profileActionLinks.firstIndex(where: { $0.id == link.id }) { + profileActionLinks[idx].enabled = enabled + } + } catch { + errorMessage = error.localizedDescription + } + } + + // MARK: - ProfileAction CRUD (legacy — used by old ActionsListView) func createProfileAction( profileID: UUID, @@ -291,10 +380,24 @@ final class ControlPlaneStore: ObservableObject { rules.filter { $0.profileID == profileID }.sorted { $0.createdAt < $1.createdAt } } - func actions(for profileID: UUID) -> [ProfileAction] { + func legacyActions(for profileID: UUID) -> [ProfileAction] { profileActions.filter { $0.profileID == profileID }.sorted { $0.createdAt < $1.createdAt } } + /// Returns the `ProfileActionLink` for a given profile/action/trigger, if it exists. + func link(profileID: UUID, actionID: UUID, trigger: ActionTrigger) -> ProfileActionLink? { + profileActionLinks.first { + $0.profileID == profileID && $0.actionID == actionID && $0.trigger == trigger + } + } + + /// All profiles that have at least one link to the given action. + func profileNames(linkedTo action: Action) -> String { + let linkedIDs = Set(profileActionLinks.filter { $0.actionID == action.id }.map(\.profileID)) + let names = profiles.filter { linkedIDs.contains($0.id) }.map(\.name).sorted() + return names.joined(separator: ", ") + } + func isActive(_ profileID: UUID) -> Bool { activeProfiles.contains { $0.profile.id == profileID } } diff --git a/Sources/ControlPlaneApp/CreateActionView.swift b/Sources/ControlPlaneApp/CreateActionView.swift index 6dbfb0f6..2faef459 100644 --- a/Sources/ControlPlaneApp/CreateActionView.swift +++ b/Sources/ControlPlaneApp/CreateActionView.swift @@ -241,12 +241,4 @@ struct CreateActionView: View { } } -// MARK: - Path panel configuration struct - -private struct PathPanelConfig { - let title: String - let canChooseFiles: Bool - let canChooseDirectories: Bool - let allowedTypes: [UTType]? - var directoryURL: URL? = nil -} +// PathPanelConfig is defined in ActionConfigForm.swift diff --git a/Sources/ControlPlaneApp/CreateRuleView.swift b/Sources/ControlPlaneApp/CreateRuleView.swift index eb5ec0ec..aca3f1a3 100644 --- a/Sources/ControlPlaneApp/CreateRuleView.swift +++ b/Sources/ControlPlaneApp/CreateRuleView.swift @@ -1,5 +1,6 @@ import SwiftUI import AppKit +import CoreWLAN import ControlPlaneSDK /// Sheet for creating or editing a rule on a profile. @@ -91,6 +92,40 @@ struct CreateRuleView: View { sensorID == "com.controlplane.sensors.hostavailability" } + private var isUSB: Bool { + sensorID == "com.controlplane.sensors.usb" + } + + /// True when the user has picked a specific USB device (readingKey is a + /// vendorID:productID hex key, not the "devices" summary key). + private var isUSBDeviceRule: Bool { + isUSB && !readingKey.isEmpty && readingKey != "devices" + } + + private var isWiFi: Bool { + sensorID == "com.controlplane.sensors.wifi" + } + + /// True when the user is building a "connected to network X" WiFi rule — + /// i.e. the reading key has been locked to "ssid" by the network picker. + private var isWiFiSSIDRule: Bool { + isWiFi && readingKey == "ssid" + } + + /// Binding that maps the `negate` flag to the user-facing + /// "Connected" / "Disconnected" selection. + private var wifiConnectionBinding: Binding { + Binding( + get: { negate ? "disconnected" : "connected" }, + set: { negate = ($0 == "disconnected") } + ) + } + + // MARK: - WiFi scan state + + @State private var scannedSSIDs: [String] = [] + @State private var isScanning: Bool = false + /// All device names currently discovered by HostAvailabilitySensor, sorted. private var discoveredBonjourDevices: [String] { guard isBonjourSensor else { return [] } @@ -145,10 +180,13 @@ struct CreateRuleView: View { } private var canSave: Bool { - !sensorID.isEmpty - && !readingKey.trimmingCharacters(in: .whitespaces).isEmpty - && !operatorID.isEmpty - && !comparandString.isEmpty + guard !sensorID.isEmpty, + !readingKey.trimmingCharacters(in: .whitespaces).isEmpty, + !comparandString.isEmpty else { return false } + // For WiFi SSID and USB device rules the operator is always "equals" and is + // seeded automatically — don't gate saving on the operatorID being set. + if isWiFiSSIDRule || isUSBDeviceRule { return true } + return !operatorID.isEmpty } // MARK: - Body @@ -162,7 +200,11 @@ struct CreateRuleView: View { sensorSection readingKeySection if !readingKey.trimmingCharacters(in: .whitespaces).isEmpty { - conditionSection + if isWiFiSSIDRule || isUSBDeviceRule { + wifiConditionSection + } else { + conditionSection + } weightSection } nameSection @@ -188,7 +230,17 @@ struct CreateRuleView: View { .padding(20) .frame(width: 500, height: isEditing ? 620 : 580) // Only seed sensor when creating; editing starts fully pre-populated. - .onAppear { if !isEditing { seedInitialSensor() } } + .onAppear { + if !isEditing { seedInitialSensor() } + // Scan for WiFi networks whenever this sheet opens for a WiFi rule. + if sensorID == "com.controlplane.sensors.wifi" { + Task { await scanForWiFiNetworks() } + } + // Refresh USB snapshot so the picker shows devices connected right now. + if sensorID == "com.controlplane.sensors.usb" { + Task { await store.refreshSnapshots() } + } + } } // MARK: - Sections @@ -207,7 +259,15 @@ struct CreateRuleView: View { .tag(snap.sensorID) } } - .onChange(of: sensorID) { _ in resetBelowSensor() } + .onChange(of: sensorID) { _ in + resetBelowSensor() + if sensorID == "com.controlplane.sensors.wifi" { + Task { await scanForWiFiNetworks() } + } + if sensorID == "com.controlplane.sensors.usb" { + Task { await store.refreshSnapshots() } + } + } } header: { Text("Sensor") } } @@ -217,10 +277,14 @@ struct CreateRuleView: View { Section { if isBluetooth { bluetoothDevicePicker + } else if isWiFi { + wifiNetworkPicker } else if isRunningApplication { runningApplicationPicker } else if isBonjourSensor { bonjourDevicePicker + } else if isUSB { + usbDevicePicker } else if isDynamic { dynamicKeyField } else if let snap = selectedSnapshot { @@ -351,6 +415,192 @@ struct CreateRuleView: View { .foregroundStyle(.secondary) } + // MARK: - USB device picker + + /// Per-device readings from the USB snapshot: all connected devices plus any + /// watched-but-disconnected devices emitted by the sensor. Excludes the "devices" + /// summary reading so the picker only shows individual device rows. + private var usbDeviceReadings: [SensorReading] { + guard isUSB, let snap = selectedSnapshot else { return [] } + return snap.readings + .filter { $0.key != "devices" } + .sorted { $0.label < $1.label } + } + + /// Picker showing every connected USB device by its human-readable name. + /// Selecting a device writes the vendorID:productID key into `readingKey`. + /// No manual text field — if nothing is connected the picker is shown + /// disabled with a "No devices connected" placeholder. + @ViewBuilder + private var usbDevicePicker: some View { + let readings = usbDeviceReadings + Picker("Device", selection: $readingKey) { + if readings.isEmpty { + Text("No devices connected").tag("").disabled(true) + } else { + Text("Choose…").tag("") + ForEach(readings, id: \.key) { reading in + let isConnected = reading.value == .boolean(true) + HStack(spacing: 6) { + Circle() + .fill(isConnected ? Color.green : Color.secondary.opacity(0.4)) + .frame(width: 8, height: 8) + Text(reading.label) + Spacer() + Text(isConnected ? "Connected" : "Not connected") + .font(.caption) + .foregroundStyle(isConnected ? .green : .secondary) + } + .tag(reading.key) + } + } + } + .disabled(readings.isEmpty) + .onChange(of: readingKey) { _ in + resetBelowKey() + if !readingKey.isEmpty { + comparandString = "true" + seedOperator() + } + } + .onAppear { + // When editing, seed operator so the form is valid immediately. + if !readingKey.isEmpty && operatorID.isEmpty { seedOperator() } + if !readingKey.isEmpty && comparandString.isEmpty { comparandString = "true" } + } + + if !readingKey.isEmpty { + LabeledContent("Device ID") { + Text(readingKey) + .font(.system(.body, design: .monospaced)) + .foregroundStyle(.secondary) + .textSelection(.enabled) + } + } + } + + // MARK: - WiFi network picker + + /// Picker for the Wi-Fi sensor. + /// + /// The rule's readingKey is always "ssid" and the comparand is the selected + /// network name (e.g. "MyHomeWiFi"). A one-shot CoreWLAN scan is run when + /// the sheet opens so the user sees nearby networks in a dropdown rather than + /// having to type an SSID by hand. + @ViewBuilder + private var wifiNetworkPicker: some View { + // SSID currently reported by the live Wi-Fi snapshot. + let liveSSID: String = { + guard let r = selectedSnapshot?.readings.first(where: { $0.key == "ssid" }), + case .string(let s) = r.value, !s.isEmpty else { return "" } + return s + }() + let isConnected = selectedSnapshot?.readings.first(where: { $0.key == "connected" })?.value + == .boolean(true) + + // Merge scan results with the live SSID and (when editing) the existing comparand + // so the picker always shows at least the relevant network even if it is not + // currently nearby. + let allNetworks: [String] = { + var seen = Set() + var list = scannedSSIDs + if !liveSSID.isEmpty { list.append(liveSSID) } + if !comparandString.isEmpty { list.append(comparandString) } + return list.filter { seen.insert($0).inserted }.sorted() + }() + + if isScanning { + LabeledContent("Network") { + HStack(spacing: 6) { + ProgressView().controlSize(.small) + Text("Scanning for networks…").foregroundStyle(.secondary) + } + } + } else if allNetworks.isEmpty { + Text("No Wi-Fi networks found. Make sure Wi-Fi is turned on.") + .foregroundStyle(.secondary) + .font(.callout) + } else { + Picker("Network", selection: $comparandString) { + Text("Choose…").tag("") + ForEach(allNetworks, id: \.self) { ssid in + HStack(spacing: 6) { + if ssid == liveSSID && isConnected { + Image(systemName: "wifi") + .imageScale(.small) + .foregroundStyle(.green) + } + Text(ssid) + if ssid == liveSSID && isConnected { + Text("(connected)") + .font(.caption) + .foregroundStyle(.green) + } + } + .tag(ssid) + } + } + .onChange(of: comparandString) { ssid in + guard !ssid.isEmpty else { return } + // Lock in the reading key and seed the equals operator so + // canSave and the preview both reflect the selection. + readingKey = "ssid" + if operatorID.isEmpty { + operatorID = store.operators(for: "string") + .first { $0.id == "equals" }?.id + ?? store.operators(for: "string").first?.id ?? "" + } + } + .onAppear { + // When editing, pre-seed readingKey + operator from the + // existing comparand so the form is valid immediately. + if !comparandString.isEmpty { + readingKey = "ssid" + if operatorID.isEmpty { + operatorID = store.operators(for: "string") + .first { $0.id == "equals" }?.id + ?? store.operators(for: "string").first?.id ?? "" + } + } + } + } + + Button(isScanning ? "Scanning…" : "Scan again") { + Task { await scanForWiFiNetworks() } + } + .controlSize(.small) + .disabled(isScanning) + + Text("Choose the network this rule triggers on. Select \"Connected\" or \"Disconnected\" in the Condition section below.") + .font(.caption) + .foregroundStyle(.secondary) + } + + /// Run a one-shot CoreWLAN scan and store the discovered SSIDs. + /// Must be called from an async context; the actual scan runs on a + /// detached task so it doesn't block the main actor. + private func scanForWiFiNetworks() async { + isScanning = true + let results: [String] = await Task.detached(priority: .userInitiated) { + // CWWiFiClient.shared() is safe to call off the main thread for + // the interface lookup; scanForNetworks runs synchronously here. + let iface = CWWiFiClient.shared().interface() + guard let iface else { return [] } + do { + let networks = try iface.scanForNetworks(withName: nil) + var seen = Set() + return networks + .compactMap { $0.ssid } + .filter { !$0.isEmpty && seen.insert($0).inserted } + .sorted() + } catch { + return [] + } + }.value + scannedSSIDs = results + isScanning = false + } + /// Free-text entry for dynamic sensors (the key IS the path / bundle ID / hostname). private var dynamicKeyField: some View { VStack(alignment: .leading, spacing: 6) { @@ -401,6 +651,20 @@ struct CreateRuleView: View { } } + /// Simplified condition section for Wi-Fi SSID rules. + /// Presents "Connected" / "Disconnected" instead of exposing the + /// negate flag and operator picker to the user. + @ViewBuilder + private var wifiConditionSection: some View { + Section { + Picker("State", selection: wifiConnectionBinding) { + Text("Connected").tag("connected") + Text("Disconnected").tag("disconnected") + } + .pickerStyle(.radioGroup) + } header: { Text("Condition") } + } + @ViewBuilder private var conditionSection: some View { Section { @@ -525,19 +789,32 @@ struct CreateRuleView: View { // MARK: - Rule preview + private var rulePreviewText: String { + if isWiFiSSIDRule { + let state = negate ? "Disconnected from" : "Connected to" + return "Wi-Fi: \(state) \"\(comparandString)\"" + + " (weight \(String(format: "%.1f", weight)))" + } + if isUSBDeviceRule { + let deviceLabel = selectedSnapshot?.readings.first { $0.key == readingKey }?.label ?? readingKey + let state = negate ? "Not connected" : "Connected" + return "USB Device › \(deviceLabel) \(state)" + + " (weight \(String(format: "%.1f", weight)))" + } + let neg = negate ? "NOT " : "" + let opLabel = store.operators.first { $0.id == operatorID }?.label ?? operatorID + let sensorName = store.snapshot(for: sensorID)?.displayName ?? sensorID + return "\(neg)\(sensorName) › \(readingKey.isEmpty ? "…" : readingKey)" + + " \(opLabel) \"\(comparandString)\"" + + " (weight \(String(format: "%.1f", weight)))" + } + private var rulePreview: some View { GroupBox("Preview") { - let neg = negate ? "NOT " : "" - let opLabel = store.operators.first { $0.id == operatorID }?.label ?? operatorID - let sensorName = store.snapshot(for: sensorID)?.displayName ?? sensorID - Text( - "\(neg)\(sensorName) › \(readingKey.isEmpty ? "…" : readingKey)" + - " \(opLabel) \"\(comparandString)\"" + - " (weight \(String(format: "%.1f", weight)))" - ) - .font(.system(.body, design: .monospaced)) - .foregroundStyle(.secondary) - .frame(maxWidth: .infinity, alignment: .leading) + Text(rulePreviewText) + .font(.system(.body, design: .monospaced)) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) } } @@ -551,8 +828,6 @@ struct CreateRuleView: View { return "com.apple.safari" case "com.controlplane.sensors.hostavailability": return "My NAS" - case "com.controlplane.sensors.usb": - return "vendorID:productID e.g. 05ac:12a8" default: return "key" } @@ -566,8 +841,6 @@ struct CreateRuleView: View { return "Bundle identifier of the application (e.g. com.apple.safari)." case "com.controlplane.sensors.hostavailability": return "Device name as shown in Finder's Network sidebar." - case "com.controlplane.sensors.usb": - return "USB vendor and product ID in hex, colon-separated." default: return "Reading key for this sensor." } diff --git a/Sources/ControlPlaneApp/PreferencesWindowController.swift b/Sources/ControlPlaneApp/PreferencesWindowController.swift deleted file mode 100644 index 07323c22..00000000 --- a/Sources/ControlPlaneApp/PreferencesWindowController.swift +++ /dev/null @@ -1,37 +0,0 @@ -import AppKit -import SwiftUI - -/// Manages the single Preferences window. Call `show(store:)` from any context — -/// it opens the window if not already visible, or brings it to front if it is. -@MainActor -final class PreferencesWindowController: NSWindowController, NSWindowDelegate { - - private static var shared: PreferencesWindowController? - - static func show(store: ControlPlaneStore) { - if shared == nil { - let hostingController = NSHostingController(rootView: PreferencesView(store: store)) - let window = NSWindow(contentViewController: hostingController) - window.title = "ControlPlane" - window.setContentSize(NSSize(width: 900, height: 620)) - window.minSize = NSSize(width: 700, height: 450) - window.styleMask = [.titled, .closable, .resizable, .miniaturizable] - window.center() - window.isReleasedWhenClosed = false - - let controller = PreferencesWindowController(window: window) - window.delegate = controller - shared = controller - } - - shared?.showWindow(nil) - shared?.window?.makeKeyAndOrderFront(nil) - NSApp.activate(ignoringOtherApps: true) - } - - // MARK: - NSWindowDelegate - - func windowWillClose(_ notification: Notification) { - PreferencesWindowController.shared = nil - } -} diff --git a/Sources/ControlPlaneApp/ProfileActivationManager.swift b/Sources/ControlPlaneApp/ProfileActivationManager.swift index 5398144c..57a4e27c 100644 --- a/Sources/ControlPlaneApp/ProfileActivationManager.swift +++ b/Sources/ControlPlaneApp/ProfileActivationManager.swift @@ -5,14 +5,21 @@ import ControlPlaneSDK /// actions attached to each profile when it transitions in or out. actor ProfileActivationManager { private var active: [UUID: ActiveProfile] = [:] - private let actionStore: ProfileActionStore + private let linkStore: ProfileActionLinkStore + private let actionStore: ActionStore private let actionRegistry: ActionRegistry private let profileStore: ProfileStore /// Called (from any thread) whenever the active profile set changes. var onActiveProfilesChanged: (@Sendable ([ActiveProfile]) -> Void)? - init(actionStore: ProfileActionStore, actionRegistry: ActionRegistry, profileStore: ProfileStore) { + init( + linkStore: ProfileActionLinkStore, + actionStore: ActionStore, + actionRegistry: ActionRegistry, + profileStore: ProfileStore + ) { + self.linkStore = linkStore self.actionStore = actionStore self.actionRegistry = actionRegistry self.profileStore = profileStore @@ -61,25 +68,33 @@ actor ProfileActivationManager { // MARK: - Private private func runActions(for profile: Profile, trigger: ActionTrigger) async { - let actions: [ProfileAction] + let links: [ProfileActionLink] do { - actions = try await actionStore.list(forProfile: profile.id) + links = try await linkStore.list(forProfile: profile.id) } catch { - logError("Failed to load actions for profile \(profile.id): \(error)", CPLogger.profiles) + logError("Failed to load action links for profile \(profile.id): \(error)", CPLogger.profiles) return } - for action in actions where action.enabled && action.trigger == trigger { + for link in links where link.enabled && link.trigger == trigger { + let action: Action + do { + action = try await actionStore.get(link.actionID) + } catch { + logError("Action \(link.actionID) not found for link \(link.id): \(error)", CPLogger.actions) + continue + } + guard action.enabled else { continue } guard let plugin = await actionRegistry.plugin(for: action.actionPluginID) else { - log("Action plugin '\(action.actionPluginID)' not loaded — skipping action \(action.id)", CPLogger.actions) + log("Action plugin '\(action.actionPluginID)' not loaded — skipping link \(link.id)", CPLogger.actions) continue } do { try await plugin.execute(trigger: trigger, profile: profile, config: action.config) - try? await actionStore.recordTriggered(action.id) - log("Action \(action.id) [\(action.actionPluginID)] executed for \"\(profile.name)\"", CPLogger.actions) + try? await linkStore.recordTriggered(link.id) + log("Action \(action.id) [\(action.actionPluginID)] '\(action.name)' executed for \"\(profile.name)\"", CPLogger.actions) } catch { - logError("Action \(action.id) failed: \(error)", CPLogger.actions) + logError("Action \(action.id) '\(action.name)' failed: \(error)", CPLogger.actions) } } } diff --git a/Sources/ControlPlaneApp/ProfileDetailView.swift b/Sources/ControlPlaneApp/ProfileDetailView.swift deleted file mode 100644 index bed5d089..00000000 --- a/Sources/ControlPlaneApp/ProfileDetailView.swift +++ /dev/null @@ -1,131 +0,0 @@ -import SwiftUI -import ControlPlaneSDK - -/// Detail panel for a single selected profile: metadata editor + Rules/Actions tabs. -struct ProfileDetailView: View { - - let profile: Profile - @ObservedObject var store: ControlPlaneStore - - @State private var editName: String - @State private var editThreshold: Double - @State private var editExclusive: Bool - @State private var detailTab = 0 - - init(profile: Profile, store: ControlPlaneStore) { - self.profile = profile - self.store = store - _editName = State(initialValue: profile.name) - _editThreshold = State(initialValue: profile.confidenceThreshold) - _editExclusive = State(initialValue: profile.exclusive) - } - - private var rules: [Rule] { store.rules(for: profile.id) } - private var actions: [ProfileAction] { store.actions(for: profile.id) } - private var isActive: Bool { store.isActive(profile.id) } - - var body: some View { - VStack(alignment: .leading, spacing: 0) { - header - Divider() - TabView(selection: $detailTab) { - RulesListView(profile: profile, store: store) - .tabItem { Text("Rules (\(rules.count))") } - .tag(0) - ActionsListView(profile: profile, store: store) - .tabItem { Text("Actions (\(actions.count))") } - .tag(1) - } - } - } - - // MARK: - Header - - private var header: some View { - HStack(alignment: .top, spacing: 20) { - // Editable fields - VStack(alignment: .leading, spacing: 10) { - HStack { - Text("Name:") - .foregroundStyle(.secondary) - .frame(width: 80, alignment: .trailing) - TextField("Profile name", text: $editName) - .textFieldStyle(.roundedBorder) - .frame(maxWidth: 220) - .onSubmit { saveIfChanged() } - } - - HStack { - Text("Threshold:") - .foregroundStyle(.secondary) - .frame(width: 80, alignment: .trailing) - Slider(value: $editThreshold, in: 0.1...5.0, step: 0.1) - .frame(maxWidth: 180) - Text(String(format: "%.1f", editThreshold)) - .monospacedDigit() - .frame(width: 36, alignment: .leading) - } - - HStack { - Text("") - .frame(width: 80, alignment: .trailing) - Toggle("Exclusive", isOn: $editExclusive) - .onChange(of: editExclusive) { _ in saveIfChanged() } - } - } - - Spacer() - - // Status badge - VStack(alignment: .trailing, spacing: 4) { - HStack(spacing: 6) { - Circle() - .fill(isActive ? Color.green : Color.secondary.opacity(0.4)) - .frame(width: 10, height: 10) - Text(isActive ? "Active" : "Inactive") - .font(.callout) - .foregroundStyle(isActive ? .primary : .secondary) - } - confidenceBadge - } - - if editName != profile.name || editThreshold != profile.confidenceThreshold { - Button("Save") { saveIfChanged() } - .buttonStyle(.borderedProminent) - .controlSize(.small) - } - } - .padding() - } - - /// Shows current combined confidence vs the activation threshold. - /// Visible at all times so the user can see how close a profile is to activating. - private var confidenceBadge: some View { - let current = store.currentConfidence(for: profile.id) - let threshold = profile.confidenceThreshold - let fraction = threshold > 0 ? current / threshold : 0 - - let color: Color = isActive ? .green - : fraction >= 0.5 ? .orange - : .secondary - - return Text(String(format: "%.2f / %.2f", current, threshold)) - .font(.caption) - .monospacedDigit() - .foregroundStyle(color) - .help("Current confidence / required threshold") - } - - private func saveIfChanged() { - let trimmed = editName.trimmingCharacters(in: .whitespaces) - guard !trimmed.isEmpty else { return } - Task { - await store.updateProfile( - profile, - name: trimmed, - confidenceThreshold: editThreshold, - exclusive: editExclusive - ) - } - } -} diff --git a/Sources/ControlPlaneApp/ProfilesTabView.swift b/Sources/ControlPlaneApp/ProfilesTabView.swift index c90dcf4b..171b0d02 100644 --- a/Sources/ControlPlaneApp/ProfilesTabView.swift +++ b/Sources/ControlPlaneApp/ProfilesTabView.swift @@ -1,7 +1,10 @@ import SwiftUI import ControlPlaneSDK -/// Top-level Profiles tab: profile list on the left, detail (rules + actions) on the right. +/// Three-panel Profiles tab: +/// Panel 1 — profile list (~1/3 width) +/// Panel 2 — profile detail: name, threshold, exclusive, confidence badge, rules list +/// Panel 3 — action assignment: all global actions grouped by trigger with checkboxes struct ProfilesTabView: View { @ObservedObject var store: ControlPlaneStore @@ -14,44 +17,56 @@ struct ProfilesTabView: View { var body: some View { HSplitView { + // Panel 1 — profile list profileList - .frame(minWidth: 180, maxWidth: 240) + .frame(minWidth: 200, maxWidth: 280) + // Panel 2 — profile detail + rules if let profile = selectedProfile { - ProfileDetailView(profile: profile, store: store) - .id(profile.id) // force fresh view (and fresh @State) on selection change + ProfileDetailPanel(profile: profile, store: store) + .id(profile.id) + .frame(minWidth: 320) } else { - VStack(spacing: 8) { - Image(systemName: "person.2") - .font(.system(size: 40)) - .foregroundStyle(.secondary) - Text(store.profiles.isEmpty - ? "Add a profile to get started" - : "Select a profile") - .foregroundStyle(.secondary) - if store.profiles.isEmpty { - Button("Add Profile") { showingCreateProfile = true } - } - } - .frame(maxWidth: .infinity, maxHeight: .infinity) + emptySelection + } + + // Panel 3 — action assignment checkboxes + if let profile = selectedProfile { + ProfileActionsPanel(profile: profile, store: store) + .id(profile.id) + .frame(minWidth: 220, maxWidth: 300) + } else { + Color.clear + .frame(minWidth: 220, maxWidth: 300) } } .sheet(isPresented: $showingCreateProfile) { CreateProfileView { name, threshold, exclusive in Task { - await store.createProfile( - name: name, - confidenceThreshold: threshold, - exclusive: exclusive - ) - // Select the new profile automatically + await store.createProfile(name: name, confidenceThreshold: threshold, exclusive: exclusive) selectedProfileID = store.profiles.last?.id } } } } - // MARK: - Profile list + // MARK: - Empty state + + private var emptySelection: some View { + VStack(spacing: 8) { + Image(systemName: "person.2") + .font(.system(size: 40)) + .foregroundStyle(.secondary) + Text(store.profiles.isEmpty ? "Add a profile to get started" : "Select a profile") + .foregroundStyle(.secondary) + if store.profiles.isEmpty { + Button("Add Profile") { showingCreateProfile = true } + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + // MARK: - Panel 1: Profile list private var profileList: some View { VStack(spacing: 0) { @@ -61,30 +76,26 @@ struct ProfilesTabView: View { Button("Delete Profile", role: .destructive) { Task { await store.deleteProfile(profile) - if selectedProfileID == profile.id { - selectedProfileID = nil - } + if selectedProfileID == profile.id { selectedProfileID = nil } } } } } Divider() HStack(spacing: 0) { - Button(action: { showingCreateProfile = true }) { - Image(systemName: "plus") - .frame(width: 28, height: 24) + Button { showingCreateProfile = true } label: { + Image(systemName: "plus").frame(width: 28, height: 24) } .buttonStyle(.borderless) - Button(action: { - guard let profile = selectedProfile else { return } + Button { + guard let p = selectedProfile else { return } Task { - await store.deleteProfile(profile) + await store.deleteProfile(p) selectedProfileID = nil } - }) { - Image(systemName: "minus") - .frame(width: 28, height: 24) + } label: { + Image(systemName: "minus").frame(width: 28, height: 24) } .buttonStyle(.borderless) .disabled(selectedProfile == nil) @@ -99,7 +110,8 @@ struct ProfilesTabView: View { @ViewBuilder private func profileRow(_ profile: Profile) -> some View { let active = store.isActive(profile.id) - let conf = store.confidence(for: profile.id) + let conf = store.currentConfidence(for: profile.id) + let threshold = profile.confidenceThreshold HStack(spacing: 6) { Circle() @@ -109,14 +121,331 @@ struct ProfilesTabView: View { Text(profile.name) .fontWeight(active ? .semibold : .regular) .lineLimit(1) - if let conf { - Text(String(format: "%.2f confidence", conf)) - .font(.caption2) + Text(String(format: "%.2f / %.2f", conf, threshold)) + .font(.caption2) + .monospacedDigit() + .foregroundStyle(.secondary) + } + } + .padding(.vertical, 1) + } +} + +// MARK: - Panel 2: Profile detail + rules + +/// Middle panel: editable profile settings at the top, live rules list below. +struct ProfileDetailPanel: View { + + let profile: Profile + @ObservedObject var store: ControlPlaneStore + + @State private var editName: String + @State private var editThreshold: Double + @State private var editExclusive: Bool + @State private var showingCreateRule = false + @State private var editingRule: Rule? = nil + @State private var selectedRuleIDs = Set() + + init(profile: Profile, store: ControlPlaneStore) { + self.profile = profile + self.store = store + _editName = State(initialValue: profile.name) + _editThreshold = State(initialValue: profile.confidenceThreshold) + _editExclusive = State(initialValue: profile.exclusive) + } + + private var rules: [Rule] { store.rules(for: profile.id) } + private var isActive: Bool { store.isActive(profile.id) } + private var singleSelection: Rule? { + guard selectedRuleIDs.count == 1, let id = selectedRuleIDs.first else { return nil } + return rules.first { $0.id == id } + } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + profileSettings + Divider() + rulesSection + } + .sheet(isPresented: $showingCreateRule) { + CreateRuleView(profile: profile, store: store) + } + .sheet(item: $editingRule) { rule in + CreateRuleView(profile: profile, store: store, existingRule: rule) + } + } + + // MARK: - Profile settings + + private var profileSettings: some View { + VStack(alignment: .leading, spacing: 10) { + // Name + save button + HStack { + Text("Name") + .foregroundStyle(.secondary) + .frame(width: 72, alignment: .trailing) + TextField("Profile name", text: $editName) + .textFieldStyle(.roundedBorder) + .onSubmit { saveIfChanged() } + if editName != profile.name || editThreshold != profile.confidenceThreshold { + Button("Save") { saveIfChanged() } + .buttonStyle(.borderedProminent) + .controlSize(.small) + } + } + + // Threshold slider + HStack { + Text("Threshold") + .foregroundStyle(.secondary) + .frame(width: 72, alignment: .trailing) + Slider(value: $editThreshold, in: 0.1...5.0, step: 0.1) + Text(String(format: "%.1f", editThreshold)) + .monospacedDigit() + .frame(width: 32) + } + + // Exclusive + confidence badge + HStack { + Text("") + .frame(width: 72) + Toggle("Exclusive", isOn: $editExclusive) + .onChange(of: editExclusive) { _ in saveIfChanged() } + Spacer() + confidenceBadge + } + } + .padding() + } + + private var confidenceBadge: some View { + let current = store.currentConfidence(for: profile.id) + let threshold = profile.confidenceThreshold + let fraction = threshold > 0 ? current / threshold : 0 + let color: Color = isActive ? .green : fraction >= 0.5 ? .orange : .secondary + + return HStack(spacing: 4) { + Circle() + .fill(isActive ? Color.green : Color.secondary.opacity(0.4)) + .frame(width: 8, height: 8) + Text(String(format: "%.2f / %.2f", current, threshold)) + .font(.caption) + .monospacedDigit() + .foregroundStyle(color) + } + .help("Current confidence / activation threshold") + } + + // MARK: - Rules + + private var rulesSection: some View { + VStack(spacing: 0) { + HStack { + Text("Rules") + .font(.headline) + .padding(.horizontal) + .padding(.top, 8) + .padding(.bottom, 4) + Spacer() + } + Divider() + if rules.isEmpty { + VStack(spacing: 8) { + Image(systemName: "text.badge.plus") + .font(.system(size: 28)) .foregroundStyle(.secondary) + Text("No rules for this profile") + .foregroundStyle(.secondary) + Button("Add Rule") { showingCreateRule = true } } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + ruleTable } + Divider() + ruleToolbar + } + } + + private var ruleTable: some View { + Table(rules, selection: $selectedRuleIDs) { + TableColumn("") { rule in + Toggle("", isOn: Binding( + get: { rule.enabled }, + set: { enabled in Task { await store.setRuleEnabled(rule, enabled: enabled) } } + )) + .labelsHidden() + .toggleStyle(.checkbox) + } + .width(24) + + TableColumn("") { rule in + let matched = store.ruleMatches[rule.id] + Image(systemName: matched == true ? "checkmark.circle.fill" + : matched == false ? "xmark.circle" : "circle.dotted") + .foregroundStyle(matched == true ? .green : matched == false ? .red : .secondary) + .help(matched == true ? "Matches" : matched == false ? "Does not match" : "Not yet evaluated") + } + .width(20) + + TableColumn("Rule") { rule in + Text(rule.name).lineLimit(1) + } + + TableColumn("Weight") { rule in + Text(String(format: "%.1f", rule.weight)) + .monospacedDigit() + .foregroundStyle(.secondary) + } + .width(52) + } + } + + private var ruleToolbar: some View { + HStack(spacing: 0) { + Button { showingCreateRule = true } label: { + Image(systemName: "plus").frame(width: 28, height: 24) + } + .buttonStyle(.borderless) + .help("Add rule") + + Button { + let toDelete = rules.filter { selectedRuleIDs.contains($0.id) } + Task { + for r in toDelete { await store.deleteRule(r) } + selectedRuleIDs.removeAll() + } + } label: { + Image(systemName: "minus").frame(width: 28, height: 24) + } + .buttonStyle(.borderless) + .disabled(selectedRuleIDs.isEmpty) + .help("Remove selected rules") + + Button { + if let rule = singleSelection { editingRule = rule } + } label: { + Image(systemName: "pencil").frame(width: 28, height: 24) + } + .buttonStyle(.borderless) + .disabled(singleSelection == nil) + .help("Edit rule") + + Spacer() + + Text("\(rules.count) rule\(rules.count == 1 ? "" : "s")") + .font(.caption) + .foregroundStyle(.secondary) + .padding(.trailing, 8) + } + .padding(.horizontal, 2) + .padding(.vertical, 4) + } + + private func saveIfChanged() { + let trimmed = editName.trimmingCharacters(in: .whitespaces) + guard !trimmed.isEmpty else { return } + Task { + await store.updateProfile( + profile, name: trimmed, + confidenceThreshold: editThreshold, + exclusive: editExclusive + ) + } + } +} + +// MARK: - Panel 3: Action assignment checkboxes + +/// Right panel: all global actions grouped by On Activate / On Deactivate. +/// Checking a row links that action to the profile for that trigger; unchecking unlinks it. +struct ProfileActionsPanel: View { + + let profile: Profile + @ObservedObject var store: ControlPlaneStore + + var body: some View { + VStack(spacing: 0) { + HStack { + Text("Actions") + .font(.headline) + Spacer() + } + .padding(.horizontal) + .padding(.vertical, 8) + Divider() + + if store.actions.isEmpty { + VStack(spacing: 8) { + Image(systemName: "bolt.slash") + .font(.system(size: 28)) + .foregroundStyle(.secondary) + Text("No actions defined") + .foregroundStyle(.secondary) + Text("Add actions on the Actions tab, then assign them here.") + .font(.caption) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + ScrollView { + VStack(alignment: .leading, spacing: 0) { + actionGroup(trigger: .onActivate, label: "On Activate") + Divider().padding(.vertical, 4) + actionGroup(trigger: .onDeactivate, label: "On Deactivate") + } + .padding(.vertical, 6) + } + } + } + } + + @ViewBuilder + private func actionGroup(trigger: ActionTrigger, label: String) -> some View { + Text(label) + .font(.subheadline) + .fontWeight(.medium) + .foregroundStyle(.secondary) + .padding(.horizontal) + .padding(.top, 4) + .padding(.bottom, 2) + + ForEach(store.actions) { action in + let linked = store.link(profileID: profile.id, actionID: action.id, trigger: trigger) != nil + HStack(spacing: 8) { + Toggle("", isOn: Binding( + get: { linked }, + set: { isOn in + Task { + if isOn { + await store.linkAction(action, to: profile, trigger: trigger) + } else if let existing = store.link(profileID: profile.id, actionID: action.id, trigger: trigger) { + await store.unlinkAction(existing) + } + } + } + )) + .labelsHidden() + .toggleStyle(.checkbox) + + VStack(alignment: .leading, spacing: 1) { + Text(action.name) + .lineLimit(1) + if let typeInfo = store.actionType(for: action.actionPluginID) { + Text(typeInfo.displayName) + .font(.caption2) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + Spacer() + } + .padding(.horizontal) + .padding(.vertical, 3) + .contentShape(Rectangle()) } - .padding(.vertical, 1) } } diff --git a/Sources/ControlPlaneApp/QuickCreateRuleView.swift b/Sources/ControlPlaneApp/QuickCreateRuleView.swift new file mode 100644 index 00000000..0ae28bf6 --- /dev/null +++ b/Sources/ControlPlaneApp/QuickCreateRuleView.swift @@ -0,0 +1,174 @@ +import SwiftUI +import ControlPlaneSDK + +/// Modal sheet for quickly creating a rule from a live sensor reading. +/// The sensor, key, operator and comparand are pre-filled and shown read-only. +/// The user only needs to choose a profile, optionally negate, and set weight. +struct QuickCreateRuleView: View { + + let snapshot: SensorSnapshot + let reading: SensorReading + @ObservedObject var store: ControlPlaneStore + + @Environment(\.dismiss) private var dismiss + + @State private var selectedProfileID: UUID? + @State private var weight: Double = 1.0 + @State private var negate: Bool = false + @State private var showingNewProfile = false + @State private var newProfileName = "" + + private var selectedProfile: Profile? { + store.profiles.first { $0.id == selectedProfileID } + } + + private var rulePreview: String { + let neg = negate ? "NOT " : "" + let label = snapshot.displayName + let key = reading.label.isEmpty ? reading.key : reading.label + let val = reading.value.description + return "\(label) → \(key) \(neg)equals \(val) (weight \(String(format: "%.1f", weight)))" + } + + private var isValid: Bool { selectedProfileID != nil } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + + // Header + VStack(alignment: .leading, spacing: 4) { + Text("Create Rule") + .font(.headline) + Text("\(snapshot.displayName) → \(reading.label.isEmpty ? reading.key : reading.label) equals \(reading.value.description)") + .font(.subheadline) + .foregroundStyle(.secondary) + .lineLimit(2) + } + .padding() + + Divider() + + Form { + // Profile picker + inline new-profile form + Section { + if store.profiles.isEmpty { + Text("No profiles yet — create one below.") + .foregroundStyle(.secondary) + } else { + Picker("Profile", selection: $selectedProfileID) { + Text("Choose…").tag(Optional.none) + ForEach(store.profiles) { profile in + Text(profile.name).tag(Optional(profile.id)) + } + } + } + + if showingNewProfile { + HStack { + TextField("Profile name", text: $newProfileName) + .textFieldStyle(.roundedBorder) + Button("Cancel") { + showingNewProfile = false + newProfileName = "" + } + .buttonStyle(.bordered) + Button("Create") { + let name = newProfileName.trimmingCharacters(in: .whitespaces) + guard !name.isEmpty else { return } + Task { + await store.createProfile(name: name) + selectedProfileID = store.profiles.last?.id + } + showingNewProfile = false + newProfileName = "" + } + .buttonStyle(.borderedProminent) + .disabled(newProfileName.trimmingCharacters(in: .whitespaces).isEmpty) + } + } else { + Button("+ New Profile…") { showingNewProfile = true } + .buttonStyle(.borderless) + .foregroundStyle(Color.accentColor) + } + } header: { + Text("Profile") + } + + Section { + Toggle(isOn: $negate) { + VStack(alignment: .leading, spacing: 2) { + Text("Negate") + Text("Rule matches when the value does NOT equal \(reading.value.description)") + .font(.caption) + .foregroundStyle(.secondary) + } + } + + LabeledContent("Confidence Weight") { + HStack { + Slider(value: $weight, in: 0.1...2.0, step: 0.1) + Text(String(format: "%.1f", weight)) + .monospacedDigit() + .frame(width: 32) + } + } + } header: { + Text("Options") + } + + Section { + Text(rulePreview) + .font(.system(.body, design: .monospaced)) + .foregroundStyle(.secondary) + } header: { + Text("Preview") + } + } + .formStyle(.grouped) + + Divider() + + HStack { + Spacer() + Button("Cancel") { dismiss() } + .keyboardShortcut(.cancelAction) + Button("Save Rule") { + guard let profile = selectedProfile else { return } + let key = reading.label.isEmpty ? reading.key : reading.key + let name = "\(snapshot.displayName) \(reading.label.isEmpty ? reading.key : reading.label)" + Task { + await store.createRule( + name: name, + profileID: profile.id, + sensorID: snapshot.sensorID, + readingKey: key, + operatorID: defaultOperatorID, + comparand: reading.value, + weight: weight, + negate: negate + ) + } + dismiss() + } + .keyboardShortcut(.defaultAction) + .disabled(!isValid) + } + .padding() + } + .frame(width: 460) + .onAppear { + // Pre-select first profile if only one exists. + if store.profiles.count == 1 { selectedProfileID = store.profiles.first?.id } + } + } + + /// Pick the most appropriate operator for the reading value type. + private var defaultOperatorID: String { + switch reading.value { + case .boolean: return "equals" + case .string: return "equals" + case .number: return "equals" + case .strings: return "contains" + } + } +} diff --git a/Sources/ControlPlaneApp/SensorCoordinator.swift b/Sources/ControlPlaneApp/SensorCoordinator.swift index 0682e941..c124f693 100644 --- a/Sources/ControlPlaneApp/SensorCoordinator.swift +++ b/Sources/ControlPlaneApp/SensorCoordinator.swift @@ -2,8 +2,26 @@ import Foundation import ControlPlaneSDK /// Manages the lifecycle of all loaded SensorPlugin instances and vends snapshots on demand. +/// +/// ## Run policy +/// +/// All registered sensors are always *known* to the coordinator, but only a +/// subset may be *running* at any given time. The policy is: +/// +/// - **Settings window open**: all sensors run (so the Sensors tab shows live +/// readings and the user can create rules for any sensor). +/// - **Settings window closed**: only sensors referenced by at least one enabled +/// rule run. Sensors with no rules are stopped to keep CPU usage near zero. +/// +/// `Backend` calls `applyRunPolicy(neededIDs:)` after rules change and after the +/// settings window closes. `SettingsWindowController` calls `startAll()` when +/// the window opens and `applyRunPolicy(neededIDs:)` when it closes. actor SensorCoordinator { + /// All registered sensor instances, whether running or not. private var sensors: [String: any SensorPlugin] = [:] + /// IDs of sensors that are currently started. + private var runningSensors: Set = [] + private let configStore: SensorConfigStore private var onSnapshotsUpdated: (@Sendable ([SensorSnapshot]) async -> Void)? @@ -16,8 +34,11 @@ actor SensorCoordinator { self.configStore = configStore } - /// Register a sensor, apply any persisted options, then start it. - func add(_ sensor: any SensorPlugin) async { + // MARK: - Registration + + /// Register a sensor and wire its push callback, but do NOT start it yet. + /// Call `applyRunPolicy(neededIDs:)` or `startAll()` afterwards. + func register(_ sensor: any SensorPlugin) async { let id = sensor.pluginIdentifier if let configurable = sensor as? any ConfigurableSensor { @@ -27,9 +48,6 @@ actor SensorCoordinator { } } - // If the sensor supports push notifications, inject a callback so - // changes it detects internally (kqueue events, CoreWLAN notifications, …) - // drive the rule engine in real time without polling. if let push = sensor as? any PushSensor { push.onSnapshotChanged = { [weak self] in Task { await self?.triggerSnapshotCallback() } @@ -37,11 +55,63 @@ actor SensorCoordinator { } sensors[id] = sensor + logDebug("Registered sensor: \(id)", CPLogger.sensors) + } + + // MARK: - Run policy + + /// Start all registered sensors. Used when the settings window opens so + /// the user sees live readings for every sensor. + func startAll() async { + for (id, sensor) in sensors where !runningSensors.contains(id) { + await sensor.start() + runningSensors.insert(id) + log("Started sensor (settings open): \(id)", CPLogger.sensors) + } + } + + /// Start sensors in `neededIDs`, stop all others. + /// Called after rules change or after the settings window closes. + func applyRunPolicy(neededIDs: Set) async { + // Stop sensors that are running but no longer needed. + for id in runningSensors where !neededIDs.contains(id) { + if let sensor = sensors[id] { + await sensor.stop() + log("Stopped idle sensor (no rules): \(id)", CPLogger.sensors) + } + runningSensors.remove(id) + } + // Start sensors that are needed but not yet running. + for id in neededIDs { + guard !runningSensors.contains(id), let sensor = sensors[id] else { continue } + await sensor.start() + runningSensors.insert(id) + log("Started sensor (has rules): \(id)", CPLogger.sensors) + } + } + + /// Legacy entry point kept for compatibility — registers and immediately starts. + func add(_ sensor: any SensorPlugin) async { + await register(sensor) + let id = sensor.pluginIdentifier await sensor.start() + runningSensors.insert(id) log("Started sensor: \(id)", CPLogger.sensors) } - /// Current snapshots from every sensor, sorted by sensor ID. + /// Returns the IDs of all registered sensors (running or not). + func allRegisteredIDs() -> Set { + Set(sensors.keys) + } + + /// Returns the IDs of currently running sensors. + func runningIDs() -> Set { + runningSensors + } + + /// Current snapshots from every *registered* sensor, sorted by sensor ID. + /// Stopped sensors are included with isActive = false so the Sensors tab + /// can still list them (they just show as inactive). func allSnapshots() async -> [SensorSnapshot] { var result: [SensorSnapshot] = [] for sensor in sensors.values { @@ -50,7 +120,7 @@ actor SensorCoordinator { return result.sorted { $0.sensorID < $1.sensorID } } - /// Snapshot for a single sensor, or nil if that ID is not loaded. + /// Snapshot for a single sensor, or nil if that ID is not registered. func snapshot(for id: String) async -> SensorSnapshot? { guard let sensor = sensors[id] else { return nil } return await sensor.currentSnapshot() @@ -84,6 +154,51 @@ actor SensorCoordinator { await triggerSnapshotCallback() } + /// Ensure every registered sensor has fresh data before an on-demand read + /// (e.g. `cpctl sensors readings`). + /// + /// - Running sensors: `refresh()` is called so sensors that override it + /// (WiFiSensor, FilePresenceSensor) re-read hardware state. + /// - Stopped sensors: `start()` is called so they populate their snapshot + /// via `refreshSnapshot()` (which most sensors call synchronously at the + /// top of `start()`). They are then stopped in a background task *after* + /// the caller collects the snapshot, so the response always contains their + /// current state rather than an empty inactive snapshot. + /// + /// Sensors with deferred initialisation (e.g. BluetoothSensor's 2-second + /// TCC delay) will still show as inactive — their `start()` returns + /// immediately but data is not ready yet. That is acceptable for a query. + func refreshForQuery() async { + var temporarilyStarted: [String] = [] + + for (id, sensor) in sensors { + if runningSensors.contains(id) { + // Already running — just request a fresh read. + await sensor.refresh() + } else { + // Stopped — start() will call refreshSnapshot() for most sensors. + await sensor.start() + temporarilyStarted.append(id) + logDebug("Temporarily started sensor for query: \(id)", CPLogger.sensors) + } + } + + // Stop the temporarily-started sensors after the caller returns the + // response. We use an unstructured Task so this doesn't block the + // snapshot collection that happens immediately after this function returns. + if !temporarilyStarted.isEmpty { + let sensorsCopy = sensors + Task { + for id in temporarilyStarted { + if let sensor = sensorsCopy[id] { + await sensor.stop() + logDebug("Stopped temporary query sensor: \(id)", CPLogger.sensors) + } + } + } + } + } + // MARK: - Configuration func getOptions(for id: String) throws -> [SensorOptionDescriptor] { diff --git a/Sources/ControlPlaneApp/SensorsTabView.swift b/Sources/ControlPlaneApp/SensorsTabView.swift index 932f6e1e..9c2d5138 100644 --- a/Sources/ControlPlaneApp/SensorsTabView.swift +++ b/Sources/ControlPlaneApp/SensorsTabView.swift @@ -1,19 +1,15 @@ import SwiftUI import ControlPlaneSDK -// MARK: - Protocol extensions for SwiftUI conformance - -extension SensorReading: Identifiable { - public var id: String { key } -} - // MARK: - View /// Displays all loaded sensors and the live readings for the selected one. +/// Each reading row has a [+] button to quickly create a rule from that value. struct SensorsTabView: View { @ObservedObject var store: ControlPlaneStore @State private var selectedSensorID: String? + @State private var quickCreateReading: SensorReading? = nil var body: some View { HSplitView { @@ -39,6 +35,11 @@ struct SensorsTabView: View { selectedSensorID = store.snapshots.first?.sensorID } } + .sheet(item: $quickCreateReading) { reading in + if let snapshot = store.snapshot(for: selectedSensorID ?? "") { + QuickCreateRuleView(snapshot: snapshot, reading: reading, store: store) + } + } } // MARK: - Sensor list @@ -112,6 +113,17 @@ struct SensorsTabView: View { Text(r.value.description) .font(.system(.body, design: .monospaced)) } + TableColumn("") { r in + Button { + quickCreateReading = r + } label: { + Image(systemName: "plus.circle") + .foregroundStyle(Color.accentColor) + } + .buttonStyle(.borderless) + .help("Create rule from this reading") + } + .width(28) } } diff --git a/Sources/ControlPlaneApp/PreferencesView.swift b/Sources/ControlPlaneApp/SettingsView.swift similarity index 75% rename from Sources/ControlPlaneApp/PreferencesView.swift rename to Sources/ControlPlaneApp/SettingsView.swift index 512c9471..c35bbeea 100644 --- a/Sources/ControlPlaneApp/PreferencesView.swift +++ b/Sources/ControlPlaneApp/SettingsView.swift @@ -1,8 +1,9 @@ import SwiftUI import ControlPlaneSDK -/// Root view for the Preferences window. Hosts a tab picker for Sensors and Profiles. -struct PreferencesView: View { +/// Root view for the Settings window. +/// Tab order follows the GUI plan: Profiles → Actions → Sensors → General. +struct SettingsView: View { @StateObject private var store: ControlPlaneStore @@ -12,16 +13,19 @@ struct PreferencesView: View { var body: some View { TabView { - GeneralSettingsView() - .tabItem { Label("General", systemImage: "gear") } + ProfilesTabView(store: store) + .tabItem { Label("Profiles", systemImage: "person.2") } + + ActionsTabView(store: store) + .tabItem { Label("Actions", systemImage: "bolt") } SensorsTabView(store: store) .tabItem { Label("Sensors", systemImage: "waveform") } - ProfilesTabView(store: store) - .tabItem { Label("Profiles", systemImage: "person.2") } + GeneralSettingsView() + .tabItem { Label("General", systemImage: "gear") } } - .frame(minWidth: 700, minHeight: 450) + .frame(minWidth: 860, minHeight: 520) .task { await store.refresh() } .alert("Error", isPresented: Binding( get: { store.errorMessage != nil }, diff --git a/Sources/ControlPlaneApp/SettingsWindowController.swift b/Sources/ControlPlaneApp/SettingsWindowController.swift new file mode 100644 index 00000000..5b8f2c7c --- /dev/null +++ b/Sources/ControlPlaneApp/SettingsWindowController.swift @@ -0,0 +1,67 @@ +import AppKit +import SwiftUI + +/// Manages the single Settings window. Call `show(store:onOpen:onClose:)` from +/// any context — it opens the window if not already visible, or brings it to +/// front if it is. `onOpen` fires when the window first becomes visible; +/// `onClose` fires when the user dismisses it. +/// +/// ## Dock icon / Cmd+Tab behaviour +/// +/// ControlPlane is a menu-bar-only app (`LSUIElement = true`), so it has no +/// Dock presence by default. While the Settings window is open the activation +/// policy is switched to `.regular` so the app appears in the Dock and the +/// Cmd+Tab switcher, making it easy to bring the window back to front. When +/// the window closes the policy reverts to `.accessory`. +@MainActor +final class SettingsWindowController: NSWindowController, NSWindowDelegate { + + private static var shared: SettingsWindowController? + + /// Called the first time the window is shown (not on subsequent bringToFront calls). + var onOpen: (() -> Void)? + /// Called when the window is closed. + var onClose: (() -> Void)? + + static func show( + store: ControlPlaneStore, + onOpen: (() -> Void)? = nil, + onClose: (() -> Void)? = nil + ) { + if shared == nil { + let hostingController = NSHostingController(rootView: SettingsView(store: store)) + let window = NSWindow(contentViewController: hostingController) + window.title = "ControlPlane Settings" + window.setContentSize(NSSize(width: 900, height: 620)) + window.minSize = NSSize(width: 700, height: 450) + window.styleMask = [.titled, .closable, .resizable, .miniaturizable] + window.center() + window.isReleasedWhenClosed = false + + let controller = SettingsWindowController(window: window) + controller.onOpen = onOpen + controller.onClose = onClose + window.delegate = controller + shared = controller + + // Fire onOpen the first time the window is created and shown. + onOpen?() + } + + // Switch to regular policy BEFORE making the window key so the Dock + // icon and Cmd+Tab entry are present from the first visible frame. + NSApp.setActivationPolicy(.regular) + shared?.showWindow(nil) + shared?.window?.makeKeyAndOrderFront(nil) + NSApp.activate(ignoringOtherApps: true) + } + + // MARK: - NSWindowDelegate + + func windowWillClose(_ notification: Notification) { + onClose?() + SettingsWindowController.shared = nil + // Revert to menu-bar-only once the Settings window is gone. + NSApp.setActivationPolicy(.accessory) + } +} diff --git a/Sources/ControlPlaneApp/XPCServiceHandler.swift b/Sources/ControlPlaneApp/XPCServiceHandler.swift index a9f69f4c..6b91d848 100644 --- a/Sources/ControlPlaneApp/XPCServiceHandler.swift +++ b/Sources/ControlPlaneApp/XPCServiceHandler.swift @@ -208,10 +208,12 @@ final class RequestHandler { // MARK: - Sensors private func sensorListReadings() async throws -> Data { - try encoder.encode(await sensors.allSnapshots()) + await sensors.refreshForQuery() + return try encoder.encode(await sensors.allSnapshots()) } private func sensorGetReadings(id: String) async throws -> Data { + await sensors.refreshForQuery() guard let snap = await sensors.snapshot(for: id) else { throw CPError.invalidData("No sensor loaded with identifier '\(id)'") } diff --git a/Sources/ControlPlaneSDK/ActionTypes.swift b/Sources/ControlPlaneSDK/ActionTypes.swift index a240f00d..123e6b33 100644 --- a/Sources/ControlPlaneSDK/ActionTypes.swift +++ b/Sources/ControlPlaneSDK/ActionTypes.swift @@ -53,7 +53,76 @@ public struct ActionTypeInfo: Codable, Sendable, Identifiable { } } -// MARK: - Stored action instance +// MARK: - Action (global reusable definition) + +/// A named, reusable action definition stored in the global library. +/// Not tied to any profile — profiles reference actions via `ProfileActionLink`. +public struct Action: Identifiable, Sendable, Equatable { + public let id: UUID + /// Human-readable name chosen by the user (e.g. "Connect VPN"). + public var name: String + /// Which action plugin executes this (e.g. "com.controlplane.action.shellscript"). + public var actionPluginID: String + /// Plugin-specific key/value configuration. + public var config: [String: String] + public var enabled: Bool + public let createdAt: Date + public var updatedAt: Date + + public init( + id: UUID = UUID(), + name: String, + actionPluginID: String, + config: [String: String] = [:], + enabled: Bool = true, + createdAt: Date = Date(), + updatedAt: Date = Date() + ) { + self.id = id + self.name = name + self.actionPluginID = actionPluginID + self.config = config + self.enabled = enabled + self.createdAt = createdAt + self.updatedAt = updatedAt + } +} + +// MARK: - ProfileActionLink (profile ↔ action assignment) + +/// Links a global `Action` to a `Profile` with a trigger and enabled flag. +/// Replaces the old `ProfileAction` which embedded the action definition inline. +public struct ProfileActionLink: Identifiable, Sendable, Equatable { + public let id: UUID + public var profileID: UUID + public var actionID: UUID + /// When this action fires relative to the profile. + public var trigger: ActionTrigger + public var enabled: Bool + public let createdAt: Date + /// When this link was most recently executed. Nil until first execution. + public var lastTriggeredAt: Date? + + public init( + id: UUID = UUID(), + profileID: UUID, + actionID: UUID, + trigger: ActionTrigger, + enabled: Bool = true, + createdAt: Date = Date(), + lastTriggeredAt: Date? = nil + ) { + self.id = id + self.profileID = profileID + self.actionID = actionID + self.trigger = trigger + self.enabled = enabled + self.createdAt = createdAt + self.lastTriggeredAt = lastTriggeredAt + } +} + +// MARK: - ProfileAction (legacy alias — kept for backward compat during transition) /// A specific action attached to a profile, stored in the database. /// When the profile transitions, the backend executes this action via the plugin diff --git a/Sources/ControlPlaneSDK/SensorTypes.swift b/Sources/ControlPlaneSDK/SensorTypes.swift index 4df0be9d..ddf5f1cd 100644 --- a/Sources/ControlPlaneSDK/SensorTypes.swift +++ b/Sources/ControlPlaneSDK/SensorTypes.swift @@ -9,7 +9,7 @@ import Foundation /// - boolean: connected, power on, lid open, adapter plugged in, … /// - number: RSSI, battery %, light level, … /// - strings: set of visible SSIDs, set of visible BSSIDs, USB device names, … -public enum ObservationValue: Sendable, Equatable { +public enum ObservationValue: Sendable, Equatable, Hashable { case string(String) case boolean(Bool) case number(Double) @@ -63,7 +63,9 @@ extension ObservationValue: CustomStringConvertible { // MARK: - Single reading /// One named observation emitted by a sensor. -public struct SensorReading: Codable, Sendable, Equatable { +public struct SensorReading: Codable, Sendable, Equatable, Hashable, Identifiable { + /// Unique identity for SwiftUI use — derived from the key. + public var id: String { key } /// Machine key used in rule matching, e.g. "ssid", "bssid", "connected". public let key: String /// Human-readable label for display, e.g. "Connected SSID". diff --git a/Sources/Sensors/NetworkLink/NetworkLinkSensor.swift b/Sources/Sensors/NetworkLink/NetworkLinkSensor.swift index 2636ff6d..f6b02a76 100644 --- a/Sources/Sensors/NetworkLink/NetworkLinkSensor.swift +++ b/Sources/Sensors/NetworkLink/NetworkLinkSensor.swift @@ -53,6 +53,13 @@ public final class NetworkLinkSensor: BaseSensor { publishInactive() } + /// Re-read current link state on demand (e.g. after system wake). + /// The SCDynamicStore callback fires when keys change, but an explicit + /// refresh ensures the snapshot is correct before the first callback arrives. + public override func refresh() async { + refreshSnapshot() + } + private func refreshSnapshot() { guard let store else { return } let pattern = "State:/Network/Interface/.*/Link" as CFString diff --git a/Sources/Sensors/USB/USBSensor.swift b/Sources/Sensors/USB/USBSensor.swift index 1b6fda2e..57b9ddf7 100644 --- a/Sources/Sensors/USB/USBSensor.swift +++ b/Sources/Sensors/USB/USBSensor.swift @@ -150,18 +150,42 @@ public final class USBSensor: BaseSensor, DynamicKeySensor { private func refreshSnapshot() { let devices = devLock.withLock { connectedDevices } + + // Summary "devices" reading lists all product names. let deviceNames = devices.map { $0.name } var readings: [SensorReading] = [ SensorReading(key: "devices", label: "Connected Devices", value: .strings(deviceNames)) ] - for key in watchedKeys { - let parts = key.split(separator: ":", maxSplits: 1).map(String.init) - guard parts.count == 2, - let vid = Int(parts[0]), - let pid = Int(parts[1]) else { continue } - let connected = devices.contains { $0.vendorID == vid && $0.productID == pid } - readings.append(SensorReading(key: key, label: key, value: .boolean(connected))) + + // Count occurrences of each name so duplicates can be disambiguated. + var nameCounts: [String: Int] = [:] + for d in devices { nameCounts[d.name, default: 0] += 1 } + + // One reading per unique vendorID:productID key, value = .boolean(true) for + // every device currently connected. Duplicate IDs (same VID/PID, multiple units) + // are collapsed into a single reading — the rule engine only needs to know + // whether at least one matching device is present. + var emittedKeys = Set() + for device in devices { + let key = deviceKey(device) + guard emittedKeys.insert(key).inserted else { continue } + let label = (nameCounts[device.name] ?? 0) > 1 + ? "\(device.name) (\(key))" + : device.name + readings.append(SensorReading(key: key, label: label, value: .boolean(true))) + } + + // Watched keys that are not currently connected → boolean(false). + // This ensures the rule engine always gets a value to evaluate. + for key in watchedKeys where !emittedKeys.contains(key) { + readings.append(SensorReading(key: key, label: key, value: .boolean(false))) } + publishSnapshot(readings: readings) } + + /// Formats a USBDevice's identifiers as lowercase hex: "05ac:12a8". + private func deviceKey(_ device: USBDevice) -> String { + String(format: "%04x:%04x", device.vendorID, device.productID) + } } diff --git a/Sources/Sensors/WiFi/WiFiSensor.swift b/Sources/Sensors/WiFi/WiFiSensor.swift index 3aff1ed8..e93f3471 100644 --- a/Sources/Sensors/WiFi/WiFiSensor.swift +++ b/Sources/Sensors/WiFi/WiFiSensor.swift @@ -1,6 +1,7 @@ import Foundation import CoreWLAN import CoreLocation +import Network import ControlPlaneSDK import os @@ -40,6 +41,7 @@ public final class WiFiSensor: NSObject, SensorPlugin, ConfigurableSensor, PushS private var interface: CWInterface? private var observers: [NSObjectProtocol] = [] private var scanTask: Task? + private var pathMonitor: NWPathMonitor? // CLLocationManager is required to trigger the macOS location permission // prompt and to unlock SSID/BSSID access from CoreWLAN. @@ -68,6 +70,7 @@ public final class WiFiSensor: NSObject, SensorPlugin, ConfigurableSensor, PushS requestLocationAuthorization() interface = client.interface() subscribeToEvents() + startPathMonitor() // Scan loop is NOT started here. It starts only when setMonitoredKeys(_:) // is called with a key set that includes "visible_networks". This avoids // unnecessary background wakeups when no rule uses that reading. @@ -77,6 +80,8 @@ public final class WiFiSensor: NSObject, SensorPlugin, ConfigurableSensor, PushS public func stop() async { scanTask?.cancel() scanTask = nil + pathMonitor?.cancel() + pathMonitor = nil try? client.stopMonitoringAllEvents() for observer in observers { NotificationCenter.default.removeObserver(observer) } observers.removeAll() @@ -215,15 +220,17 @@ public final class WiFiSensor: NSObject, SensorPlugin, ConfigurableSensor, PushS // The notification names are deprecated as API entry points but remain the only // Swift-accessible notification bridge for CWWiFiClient on macOS 14. + // + // IMPORTANT: use object: nil (not object: client). On macOS 12+, CoreWLAN + // posts these notifications with the CWInterface as the object — not the + // CWWiFiClient — so filtering by client silently drops every notification. let notificationNames: [Notification.Name] = [ .CWSSIDDidChange, .CWBSSIDDidChange, .CWLinkDidChange, .CWModeDidChange, ] for name in notificationNames { let observer = NotificationCenter.default.addObserver( - forName: name, object: client, queue: nil + forName: name, object: nil, queue: nil ) { [weak self] _ in - // On link-state change, trigger a scan cycle immediately so visible_networks - // reflects reality (e.g. just disconnected → start scanning right away). guard let self else { return } self.refreshSnapshot() Task { await self.runScanIfNeeded() } @@ -232,6 +239,27 @@ public final class WiFiSensor: NSObject, SensorPlugin, ConfigurableSensor, PushS } } + /// Start an NWPathMonitor watching the WiFi interface type. + /// + /// NWPathMonitor is the modern, reliable API for detecting network-path + /// changes. It fires immediately with the current state and again whenever + /// the WiFi path changes (connect, disconnect, interface goes down). + /// This supplements the CoreWLAN notifications, which are unreliable on + /// macOS 12+ because they are posted with the CWInterface as the object + /// rather than the CWWiFiClient. + private func startPathMonitor() { + let monitor = NWPathMonitor(requiredInterfaceType: .wifi) + monitor.pathUpdateHandler = { [weak self] _ in + // pathUpdateHandler fires on the monitor queue; dispatch to main + // so refreshSnapshot() can safely access the CWInterface property. + DispatchQueue.main.async { [weak self] in + self?.refreshSnapshot() + } + } + monitor.start(queue: DispatchQueue.global(qos: .utility)) + pathMonitor = monitor + } + private func refreshSnapshot() { guard let iface = interface else { return } diff --git a/docs/gui-plan.md b/docs/gui-plan.md index 6141b5b4..b2b6d08f 100644 --- a/docs/gui-plan.md +++ b/docs/gui-plan.md @@ -89,48 +89,63 @@ Rename "Preferences" → "Settings" (follows Apple HIG; tracked in Issue #540). Three-panel horizontal split view: ``` -┌──────────────┬────────────────────────┬──────────────────────┐ -│ Profile list │ Profile detail │ Rules for profile │ -│ │ (name, threshold, │ │ -│ ○ Home │ confidence badge) │ [match] Rule name │ -│ ● Work ←sel │ │ [match] Rule name │ -│ ○ Weekend │ │ [match] Rule name │ -│ │ │ … │ -│ [+] [−] │ Actions assigned: │ │ -│ │ • Open Safari (act.) │ │ -│ │ • Run Script (deact) │ [+] [−] [✏] │ -└──────────────┴────────────────────────┴──────────────────────┘ +┌──────────────────┬──────────────────────────────┬──────────────────────────┐ +│ Profiles │ Work │ Actions │ +├──────────────────┼──────────────────────────────┼──────────────────────────┤ +│ │ │ │ +│ ○ Home 0.12 │ Name │ On Activate │ +│ ● Work 0.95 │ ┌──────────────────────┐ │ ☑ Connect VPN │ +│ ○ Weekend 0.00 │ │ Work │ │ ☑ Open Finder │ +│ 🔒 Travel — │ └──────────────────────┘ │ ☐ Set Default Printer │ +│ │ │ ☐ Say Good Morning │ +│ │ Confidence Threshold │ ☐ Start Time Machine │ +│ │ ├──────────●────┤ 0.75 │ │ +│ │ │ On Deactivate │ +│ │ ☐ Exclusive profile │ ☑ Disconnect VPN │ +│ │ │ ☐ Speak Goodbye │ +│ │ Confidence │ ☐ Lock Keychain │ +│ │ ┌──────────────────────┐ │ ☐ Unmount NAS │ +│ │ │ 0.95 / 1.00 ████▓ │ │ │ +│ │ └──────────────────────┘ │ │ +│ │ │ │ +│ │ Rules │ │ +│ │ ✓ WiFi = CorpNet 1.00 │ │ +│ │ ✓ IP starts 10. 0.75 │ │ +│ │ ✗ VPN connected 0.50 │ │ +│ │ ✓ Mon–Fri 9–18 0.25 │ │ +│ │ │ │ +├──────────────────┼──────────────────────────────┼──────────────────────────┤ +│ [+] [−] │ [+] [−] [✏] │ │ +└──────────────────┴──────────────────────────────┴──────────────────────────┘ ``` -**Panel 1 — Profile list (left, ~200 px)** -- Each row: active/inactive dot + profile name + current confidence score - (shown as `0.84 / 1.00` using the live `profileConfidences` data — same as - the current sidebar). -- Locked profiles get an additional 🔒 indicator. +**Panel 1 — Profile list (left, ~1/3 of window width)** +- Each row: active/inactive dot + profile name + current confidence score. +- Locked profiles get an additional 🔒 indicator (confidence shown as `—`). - `[+]` / `[−]` toolbar buttons at the bottom. - Right-click context menu: Rename, Delete, Duplicate **[OPEN]**. -**Panel 2 — Profile detail (centre, fixed ~280 px)** +**Panel 2 — Profile detail (centre, fills remaining space with panel 3)** - Editable name field (save on Return / focus-out). -- Confidence threshold slider (same as current). +- Confidence threshold slider. - Exclusive toggle. -- Live confidence badge: `0.84 / 1.00` coloured green/orange/grey. -- **Assigned actions list** — a compact, read-only list of actions linked to - this profile, showing action display name + trigger (on activate / - on deactivate). Each row has a small `×` to remove the link. - An `[+ Add Action]` button opens a sheet to link an action from the global - library (see §4). +- Live confidence badge: `0.95 / 1.00` coloured green/orange/grey. +- **Rules list** — live match-state column (`✓`/`✗`), rule name, weight. + `[+]` / `[−]` / `[✏]` toolbar at the bottom to manage rules for this profile. -**Panel 3 — Rules (right, fills remaining space)** -- Identical to the current `RulesListView` including the live match-state - column and the `[+]` / `[−]` / `[✏]` toolbar. -- No inner tabs — rules live directly in this panel. +**Panel 3 — Actions (right, fixed width)** +- Lists every action in the global library, grouped into two sections: + **On Activate** and **On Deactivate**. +- Each action has a checkbox. Checking it links that action to the selected + profile for that trigger; unchecking removes the link. +- Actions are defined and managed exclusively on the **Actions tab** — there + is no add/edit/delete affordance here. -**[OPEN]** Should the three-panel split be resizable or should panel 2 have a -fixed width? Suggested: panel 1 and 2 fixed, panel 3 fills. +**[OPEN]** Should the three-panel split be resizable or should panels 1 and 3 +have fixed widths with panel 2 filling the rest? -**[OPEN]** Where does the "Duplicate profile" feature live? Useful for creating -a variant of an existing profile. Right-click context menu is the natural place. +**[OPEN]** Where does the "Duplicate profile" feature live? Right-click context +menu on the profile list row is the natural place. --- @@ -226,9 +241,81 @@ ships publicly, a proper migration path can be added at that time. ## 5. Sensors Tab -Unchanged from current implementation. Displays all loaded sensors, their current -snapshot readings, and per-sensor configuration (where applicable). Remains the -third tab. +Two-panel layout: sensor list on the left, live readings on the right. The right +panel gains a **Create Rule** affordance on every reading row. + +``` +┌──────────────────────┬────────────────────────────────────────────────────┐ +│ │ WiFi ● Active │ +│ ● Active Applicat. ├────────────────────────────────────────────────────┤ +│ ● Audio Output │ Key Label Value │ +│ ● Bluetooth │ ───────────── ─────────── ──────────────── │ +│ ● DNS │ ssid SSID "CorpNet" [+] │ +│ ● File Presence │ bssid BSSID "a4:3e:51…" [+] │ +│ ● Host Availabil. │ connected Connected true [+] │ +│ ● IP Address │ security Security "WPA2 Pers…" [+] │ +│ ● Laptop Lid │ channel Channel 6 [+] │ +│ ● Mounted Volume │ rssi Signal (dBm) -52 [+] │ +│ ● Monitor │ country Country "US" [+] │ +│ ● Network Link │ │ +│ ● Power │ │ +│ ● Running Appl. │ │ +│ ● Screen Lock │ │ +│ ● Time of Day │ │ +│ ○ USB │ │ +│ ● WiFi ← │ │ +├──────────────────────┼────────────────────────────────────────────────────┤ +│ 17 sensors [↺] │ Captured: 20:14:32 │ +└──────────────────────┴────────────────────────────────────────────────────┘ +``` + +### Create Rule from reading + +Each row in the readings table has a `[+]` button (shown on hover; always visible +on the selected row). Clicking it opens a modal sheet pre-filled with the sensor, +key, operator, and current value — the user only needs to choose a profile, set +weight, and optionally negate. + +``` +┌─────────────────────────────────────────────────────┐ +│ Create Rule │ +│ WiFi → SSID equals "CorpNet" │ +├─────────────────────────────────────────────────────┤ +│ │ +│ Profile │ +│ [ Work ▾ ] │ +│ [ + New Profile… ] │ +│ │ +│ ────────────────────────────────────────────── │ +│ │ +│ ☐ Negate (rule matches when WiFi is NOT │ +│ "CorpNet") │ +│ │ +│ Confidence Weight │ +│ ├────────────────●──────────┤ 1.0 │ +│ 0.1 2.0 │ +│ │ +│ ────────────────────────────────────────────── │ +│ │ +│ Preview │ +│ ┌─────────────────────────────────────────────┐ │ +│ │ WiFi SSID equals "CorpNet" (weight 1.0) │ │ +│ └─────────────────────────────────────────────┘ │ +│ │ +│ [ Cancel ] [ Save Rule ] │ +└─────────────────────────────────────────────────────┘ +``` + +**New Profile inline expansion** — clicking `[ + New Profile… ]` expands an +inline form (name field + Cancel / Create buttons) directly inside the sheet. +On Create the new profile is saved and auto-selected in the picker. + +**Implementation notes:** +- `[+]` button is a new `TableColumn` (trailing, fixed narrow width). +- Sheet is a new `QuickCreateRuleView` that takes a pre-filled `SensorReading` + and `SensorSnapshot` (for sensor ID and display name). It does not re-expose + the sensor/key/operator/comparand pickers — those are shown read-only. +- On save, calls the same `RuleStore.insert` path as `CreateRuleView`. ---