Skip to content

GUI buildout: three-panel Profiles, global Actions tab, sensor quick-create rule (issue #525)#550

Draft
dustinrue wants to merge 21 commits into
mainfrom
feature/525-gui-buildout
Draft

GUI buildout: three-panel Profiles, global Actions tab, sensor quick-create rule (issue #525)#550
dustinrue wants to merge 21 commits into
mainfrom
feature/525-gui-buildout

Conversation

@dustinrue

Copy link
Copy Markdown
Owner

Summary

This is the initial implementation sprint for the new ControlPlane settings window, based on the design in docs/gui-plan.md.

  • New tab order: Profiles → Actions → Sensors → General (was General → Sensors → Profiles)
  • Profiles tab rebuilt as a three-panel split: profile list | profile detail + rules | action assignment checkboxes
  • Actions tab (new): global reusable action library decoupled from profiles
  • Sensors tab: adds a [+] button on every reading row to quickly create a rule from a live value
  • Database: v7_global_actions migration adds actions and profileActionLinks tables; ProfileActivationManager updated to use the new model

What changed

Data layer

  • Action and ProfileActionLink SDK types added to ActionTypes.swift
  • SensorReading / ObservationValue gain Hashable + Identifiable
  • DB migration v7: actions table (global definitions) + profileActionLinks table (profile ↔ action join)
  • ActionStore and ProfileActionLinkStore for CRUD
  • ProfileActivationManager resolves link → action → plugin (was flat ProfileAction)
  • ControlPlaneStore publishes actions and profileActionLinks with full CRUD helpers

UI

  • PreferencesView: new tab order, minimum window size 860×520
  • ProfilesTabView: three-panel HSplitView — list / ProfileDetailPanel / ProfileActionsPanel
  • ProfileDetailPanel: name field, threshold slider, exclusive toggle, live confidence badge, rules table with toolbar
  • ProfileActionsPanel: all global actions grouped by On Activate / On Deactivate; checkbox per row links/unlinks to profile
  • ActionsTabView (new): type, name, Used By columns; CreateOrEditActionView sheet for create/edit; delete with warning if linked
  • QuickCreateRuleView (new): pre-filled from a live sensor reading; inline new-profile creation
  • SensorsTabView: [+] button column on every reading row opens QuickCreateRuleView
  • ProfileDetailView removed (absorbed into ProfilesTabView)

Test plan

  • App launches and opens Settings window
  • Profiles tab shows three panels; selecting a profile populates detail and rules
  • Rules can be added, edited, deleted from the middle panel
  • Actions tab: create, edit, delete a global action; Used By column updates correctly
  • Profiles tab right panel: checking an action links it; unchecking unlinks it; profile activation executes linked actions
  • Sensors tab: clicking [+] on a reading row opens QuickCreateRuleView pre-filled with sensor/key/value
  • QuickCreateRuleView: inline new profile creation works; rule saves and appears in profile rules list
  • Database wipes cleanly on schema change in DEBUG builds (no crash on first launch after update)

🤖 Generated with Claude Code

Dustin Rue and others added 20 commits May 17, 2026 20:55
Three-panel layout revised:
- Panel 1 (left, ~1/3): profile list with dot, name, confidence score
- Panel 2 (centre): profile name, threshold slider, exclusive toggle,
  live confidence badge, then rules list with match state and toolbar
- Panel 3 (right): all global actions grouped by On Activate / On Deactivate,
  each with a checkbox to link/unlink to the selected profile — no add/edit here

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Each reading row gets a [+] button that opens a QuickCreateRuleView sheet
pre-filled with the sensor/key/value. User picks profile, sets weight,
optionally negates, and saves. New Profile can be created inline.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…reate rule

- ActionTypes.swift: add Action and ProfileActionLink SDK types
- SensorTypes.swift: add Hashable + Identifiable to SensorReading/ObservationValue
- AppDatabase.swift: v7 migration adds actions + profileActionLinks tables
- ActionStore.swift: CRUD for Action and ProfileActionLinkStore
- Backend.swift: wire actionStore + profileActionLinkStore
- ProfileActivationManager.swift: execute via profileActionLinks -> actions
- ControlPlaneStore.swift: publish actions/profileActionLinks, add CRUD methods
- PreferencesView.swift: new tab order (Profiles, Actions, Sensors, General), min 860x520
- ProfilesTabView.swift: three-panel HSplitView with ProfileDetailPanel + ProfileActionsPanel
- ProfileDetailView.swift: removed (absorbed into ProfilesTabView)
- ActionsTabView.swift: global action library with CreateOrEditActionView sheet
- QuickCreateRuleView.swift: pre-filled rule creation from a live sensor reading
- SensorsTabView.swift: add [+] button column triggering QuickCreateRuleView sheet

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
ActionConfigForm.swift handles every action type with purpose-built controls:
- Shell Script: Browse button scoped to scripts, optional arguments field
- Open File/App: Browse button (any file or .app)
- Open and Hide: Browse button (.app bundles only)
- Open URL: text field with scheme hint
- Quit Application: picker from installed apps (/Applications + ~/Applications +
  /System/Applications), force-quit toggle
- Speak Text: text field + voice picker populated from NSSpeechSynthesizer,
  grouped by locale, system default option
- Mount Volume: URL field with smb/afp/nfs hint
- Unmount Volume: picker of currently mounted volumes (non-root)
- Desktop Background: image Browse button + All/Main display segmented control
- Toggle WiFi / Prevent Sleep: Enable/Disable segmented control
- Switch Network Location: picker loaded from networksetup -listlocations
- Set Default Printer: picker from NSPrinter.printerNames
- Run Shortcut: picker populated from shortcuts list --show-identifiers,
  auto-fills shortcutName display field on selection
- Set Time Machine Destination: Browse button (directories)
- Lock Keychain / Start Screen Saver / Start Time Machine: no-config message

PathPanelConfig moved to ActionConfigForm.swift (was duplicated in CreateActionView).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Sensors that have no enabled rules are now stopped when the settings
window is closed, eliminating background polling from unused sensors.
When settings opens, all sensors start so the user sees live readings
for every sensor while creating rules. Run policy re-applies whenever
rules change so newly-ruleless sensors stop promptly.

Key changes:
- SensorCoordinator: separate register() (no start) from add();
  add startAll() and applyRunPolicy(neededIDs:) methods
- Backend: registerSensor() now calls register() instead of add(),
  adds sensorIDsNeededForRules() and applyRunPolicy(settingsOpen:),
  applies policy after startup and after every rule refresh
- PreferencesWindowController: accepts onOpen/onClose callbacks
- AppDelegate: wires open to startAll, close to applyRunPolicy on backend

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
When cpctl runs 'sensors readings', every registered sensor now calls
refresh() before snapshots are returned, ensuring callers get current
data rather than stale cached values.

A new SensorCoordinator.refreshForQuery() method refreshes all
registered sensors (running or stopped) without firing the snapshot
callback, since this is a read-only inspection path.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The previous implementation only called refresh() on sensors, which is a
no-op on BaseSensor (the default). Stopped sensors also had publishInactive()
called on them, leaving their snapshots empty.

New approach:
- Running sensors: refresh() is called (effective for WiFi, FilePresence)
- Stopped sensors: start() is called so most sensors call refreshSnapshot()
  synchronously and immediately have data; they are then stopped in a
  background task after the caller has collected the snapshot

Sensors with deferred init (BluetoothSensor 2s TCC delay) still show as
inactive on a query, which is acceptable.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Instead of having the user pick a reading key from the static list and
then type an SSID by hand, the Wi-Fi sensor now gets its own network
picker in CreateRuleView. When the modal opens (or the Wi-Fi sensor is
selected), a one-shot CoreWLAN scan runs in the background and populates
a dropdown with nearby SSIDs.

Key behaviour:
- readingKey is always forced to "ssid"; comparand = selected SSID name
- The currently connected network is highlighted with a wifi icon and
  "(connected)" label so it is easy to identify
- The scan result also includes the live SSID from the sensor snapshot
  and (when editing) the existing comparand, so the picker always has a
  sensible selection even when a network is not currently nearby
- "Scan again" button lets the user re-scan without reopening the sheet
- Negate hint explains that the flag inverts to "not connected to X"
- operatorID is auto-seeded to "equals" when a network is chosen

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replace the generic Negate toggle + operator picker + comparand field
with a simple two-option radio picker for WiFi SSID rules. The user
sees only 'Connected' or 'Disconnected'; the negate flag is wired
behind the scenes so the data model is unchanged.

- wifiConditionSection: radio picker bound to negate via a computed
  Binding (connected = negate false, disconnected = negate true)
- canSave: WiFi SSID rules skip the operatorID check since equals is
  always seeded automatically when a network is chosen
- rulePreview: shows 'Wi-Fi: Connected to "X"' or 'Disconnected from'
  instead of the raw 'NOT ssid equals' form
- wifiNetworkPicker caption updated to reference the Condition section

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…k refresh()

Verification findings:

WiFiSensor - correctly subscribed:
  - CWEventType: ssidDidChange, bssidDidChange, linkDidChange, modeDidChange
  - NotificationCenter mirrors for all four (belt-and-suspenders on macOS 14)
  - refresh() overridden: re-fetches CWInterface and calls refreshSnapshot()

NetworkLinkSensor - correctly subscribed:
  - SCDynamicStore watches State:/Network/Interface/.*/Link (regex pattern)
  - CFRunLoopSource on main run loop fires refreshSnapshot() on any link change
  - refresh() was missing (inherited no-op) -- now overrides to call refreshSnapshot()

Gap found in both: no sleep/wake handling. After sleep, interfaces
re-establish and the SCDynamicStore / CWWiFiClient callbacks will fire
eventually, but there is a stale-data window between wake and the first
hardware event. Fixed by:

  - Backend: subscribes to NSWorkspace.didWakeNotification and calls
    sensorCoordinator.refreshAllSensors() so all sensors re-read current
    hardware state immediately on wake
  - NetworkLinkSensor: override refresh() to call refreshSnapshot() so
    the wake-triggered refreshAllSensors() actually re-reads link state

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Two bugs found:

1. object: client filter silently drops all CoreWLAN notifications.
   On macOS 12+, CWWiFiClient posts notifications with the CWInterface
   as the object, not the CWWiFiClient itself. Using object: client in
   the NotificationCenter observers meant every SSID/link change was
   silently filtered out. Fixed by changing to object: nil.

2. CWWiFiClient notifications are unreliable on modern macOS.
   Added NWPathMonitor (Network.framework) as a reliable backbone.
   NWPathMonitor fires immediately on start with current state and
   again whenever the WiFi path changes (connect, disconnect, interface
   going down). pathUpdateHandler dispatches to main queue and calls
   refreshSnapshot() so the sensor always reflects reality.

The two mechanisms are complementary:
- NWPathMonitor: reliable for connect/disconnect path changes
- CWWiFiClient notifications (now unfiltered): for SSID roaming and
  other fine-grained events that don't change the network path

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…while open

Rename:
- PreferencesView -> SettingsView (file + type)
- PreferencesWindowController -> SettingsWindowController (file + type)
- Window title: 'ControlPlane' -> 'ControlPlane Settings'
- Menu item: 'Preferences...' -> 'Settings...' (Cmd+, shortcut unchanged)
- Selector: openPreferences -> openSettings

Dock icon / Cmd+Tab:
- SettingsWindowController.show() calls NSApp.setActivationPolicy(.regular)
  before makeKeyAndOrderFront so the Dock icon and Cmd+Tab entry are
  present from the very first visible frame
- windowWillClose() reverts to NSApp.setActivationPolicy(.accessory) so
  the app disappears from the Dock and switcher once Settings is closed
- LSUIElement in Info.plist is unchanged; the policy is toggled at runtime

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The flyout was referencing the old ProfileAction model; rewrote
rebuildRunActionsMenu() to join ProfileActionLinks with Actions and
Profiles, and runActionItem(_:) to execute via ProfileActionLink.
Also updated the Combine subscription to observe actions,
profileActionLinks, profiles, and actionTypes together.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Actions without a profile link appear under a Standalone section and run
immediately with a placeholder profile context. Linked actions retain their
profile/trigger grouping. Renames runActionItem to runLinkedActionItem and
adds runStandaloneActionItem.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
One entry per action, sorted by name, with type in parentheses.
No profile/trigger grouping — any action can be run on demand.
Subscription narrowed to store.$actions + store.$actionTypes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
USBSensor now emits one boolean reading per connected device using
the product name as the label and lowercase hex vid:pid as the key.
Duplicate names are disambiguated with the ID in parentheses.
Watched-but-disconnected keys still emit boolean(false) for the rule
engine.

CreateRuleView adds a usbDevicePicker that mirrors the Bluetooth
picker: a live dropdown of connected devices with a green/grey
indicator, a Device ID confirmation label, and a manual fallback
text field for devices not currently plugged in. The rule preview
now shows the human-readable device name instead of the hex key.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…oggle

isUSBDeviceRule routes to the same wifiConditionSection so the user
picks Connected or Disconnected; the negate flag is set behind the
scenes. canSave and rulePreviewText updated to match.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The USB sensor starts when Settings opens but store.snapshots may not
have been updated yet when CreateRuleView appears. Call refreshSnapshots()
when the USB sensor is selected (onAppear or onChange) so the picker
has current device data, mirroring how WiFi triggers a scan on open.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Two changes:
- build: one swift build invocation per product so SPM cannot silently
  skip ControlPlane when multiple --product flags are passed
- clean: use rm -rf .build instead of swift package clean, which can
  leave stale metadata that causes the next build to produce only
  some products

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
When no USB devices are connected the picker is now shown disabled with
a "No devices connected" placeholder instead of falling back to a raw
vendor:product text field. Users can only select from devices that are
or were connected — no manual hex ID entry.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds a CI workflow that builds universal binaries and assembles an
ad-hoc-signed .app bundle on every PR, uploading it as a downloadable
artifact named ControlPlane-PR<number> with 14-day retention.

Also adds a `make bundle` target — same as `make run` but without the
pkill/open steps so it is safe to run in CI environments.

Closes #575.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant