Skip to content

feat: add global and per-profile pre/post apply script hooks#577

Merged
stonerl merged 2 commits into
developmentfrom
feat/automation-profile-hooks
May 13, 2026
Merged

feat: add global and per-profile pre/post apply script hooks#577
stonerl merged 2 commits into
developmentfrom
feat/automation-profile-hooks

Conversation

@nightah

@nightah nightah commented May 13, 2026

Copy link
Copy Markdown
Collaborator

What does this PR do?

Adds pre/post profile-apply hooks that run user-supplied shell scripts (any executable with a shebang) or AppleScript files (.scpt, .applescript, .scptd) around every Thaw profile switch, in two scopes: Global (every apply) and Per-Profile (only when its owning profile is applied). All apply paths funnel through ProfileManager.applyProfile, so the hook stages cover the manual Apply button, the profile hotkey, the menu bar and control item context menus, the display auto-switch, and macOS Focus Filter activation.

PR Type

  • Bugfix
  • CI/CD related changes
  • Code style update (formatting, renaming)
  • Documentation
  • Feature
  • Refactor
  • Performance improvement
  • Test addition or update
  • Other (please describe):

Does this PR introduce a breaking change?

  • Yes
  • No

If yes, please describe the impact and migration path:

What is the current behavior?

There is no way to run a script when Thaw switches profiles. Users with sister menu bar apps that don't expose a profile-switch API (the issue cites iStat Menus, which responds to defaults write and AppleScript but has no native sync mechanism) have no programmatic hook to keep those apps' configurations in step with Thaw's display-driven or focus-filter-driven transitions.

Issue Number: #567

What is the new behavior?

A new Hooks section appears in the Automation settings pane, with two subsections. Global Hooks holds pre and post rows that fire on every apply. Per-Profile Hooks holds a profile picker plus pre and post rows for the selected profile; switching the picker swaps the rows to that profile's hooks. Each hook row uses NSOpenPanel to pick a script file, a stepper to set a wall-clock timeout (1-300 seconds, default 5), and an enable toggle. Hooks can be paused without losing the picked path by toggling enable off. Validation warnings surface in the row when the chosen file is missing or, for non-AppleScript files, lacks the executable bit.

Hooks run in symmetric nested order around the apply: Global pre -> Profile pre -> [Thaw applies the snapshot, spacing offset, and layout] -> Profile post -> Global post. With only one scope configured, the missing stages collapse out (Global pre -> [apply] -> Global post, or Profile pre -> [apply] -> Profile post). Profile hooks belong to the incoming profile, not the outgoing one; teardown for the outgoing profile is best expressed as a Global post that branches on THAW_PREVIOUS_PROFILE_NAME. Hooks are blocking: the layout task awaits each stage before proceeding, capped at the configured timeout. Failures and timeouts log to DiagLog under category HookRunner but never abort the apply, so a broken script can't lock the user out of switching profiles. Cancelled applies (rapid back-to-back profile switches) propagate Task.isCancelled through the hook stages.

Every hook process inherits Thaw's environment merged with THAW_HOOK_PHASE (Pre or Post), THAW_HOOK_SCOPE (Global or Profile), THAW_PROFILE_ID (UUID of the incoming profile), THAW_PROFILE_NAME (user-visible name), THAW_PREVIOUS_PROFILE_ID (UUID of the previously active profile, empty if none), and THAW_PREVIOUS_PROFILE_NAME (name or empty). For example, a one-line bash pre-hook that mirrors the switch into iStat Menus: defaults write com.bjango.istatmenus5 ActiveProfile -string "$THAW_PROFILE_NAME". An AppleScript post-hook reads the same values via system attribute "THAW_PROFILE_NAME" (osascript does not expose Unix env vars through the standard AppleScript environment accessor). A shell hook can branch on phase with case "$THAW_HOOK_PHASE" in Pre) ...; Post) ...; esac.

Implementation: HookScript and ProfileAutomation are new in Thaw/Settings/Models/HookScript.swift. Global hooks persist via two new Defaults keys (globalPreProfileHook / globalPostProfileHook) as JSON-encoded Data. Per-profile hooks live inside the profile's JSON via a new optional automation: ProfileAutomation? field on Profile and ProfileContent, decoded with decodeIfPresent so existing profile files on disk are unaffected; profile export and import already serialise the whole Profile, so per-profile hooks round-trip through exportProfile and importProfile automatically. HookRunner in Thaw/Utilities/HookRunner.swift wraps Process() with Pipes and races termination against a Task.sleep timeout via withThrowingTaskGroup; AppleScript files route through /usr/bin/osascript <path>, other paths launch directly and are checked for the executable bit at run time. ProfileManager.applyProfile's previously-synchronous snapshot push (general / advanced / hotkeys / display config / appearance / item names / new-items placement) now runs inside the layoutTask after the pre-hook stages so a pre-hook can complete before Thaw mutates state, and applyProfile gained a previousProfileID parameter that all seven call sites pass by reading activeProfileID before they overwrite it.

PR Checklist

  • I've built and run the app locally
  • I've checked for console errors or crashes
  • I've run relevant tests and they pass
  • I've added or updated tests (if applicable)
  • I've updated documentation as needed

Other information

Manually verified the following scenarios on a multi-display setup, against the local debug build with diagnostic logging enabled:

  • Profile post-hook on Profile A writes its log only after await profileManager.layoutTask?.value returns.
  • All four hooks (Global pre/post + Profile pre/post) appending to one file produce the lines globalPre, profilePre, profilePost, globalPost in that order.
  • sleep 60 hook with timeout 5 logs HookError.timedOut; Apply button re-enables.
  • .sh without +x logs HookError.notExecutable; apply still completes.
  • .scpt hook routes through osascript (visible in Activity Monitor); no +x required on the script.
  • env | grep THAW_ confirms all six env vars are set, with THAW_PROFILE_NAME and THAW_PREVIOUS_PROFILE_NAME reflecting the transition.
  • Export -> delete -> import round-trip preserves per-profile hooks.
  • No-hook applies short-circuit on hook == nil || !hook.isEnabled; latency matches the pre-PR baseline.
  • Back-to-back applies: the first layout task cancels and its post-hooks do not run.

Notes for reviewers

  • The hook execution model is "log and continue" by design (per the issue discussion). Script failures or timeouts never abort an apply, so a broken hook can't lock the user out of switching profiles. The failure path is centralised in HookRunner.runIfEnabled, so adding an abortOnFailure flag later is a small change.
  • previousProfileID is threaded through applyProfile rather than read from activeProfileID inside the function, because every call site overwrites activeProfileID before invoking applyProfile. Reading inside the function would always observe the new value.
  • Process() inherits Thaw's full environment by design and merges the THAW_* vars on top, so scripts see the user's PATH, locale, and so on without extra configuration.
  • The Per-Profile picker lives in the Automation pane rather than inside each profile row in the Profiles pane. The Profiles pane stays focused on layout / display association / apply controls; configuring scripted side-effects fits next to the Settings URI Scheme controls already in Automation.

Closes #567

Summary by CodeRabbit

  • New Features

    • Global and per-profile automation hooks (pre/post) with UI for selecting scripts, enable/disable, and timeout.
    • Hooks execution with environment variables exposing profile and phase information.
    • Localized UI strings and settings for hook management.
  • Bug Fixes / Behavior

    • Profile apply flow now preserves and provides the previous profile context to hooks so scripts receive prior-profile info.
  • Validation

    • File existence and executability warnings for selected scripts.

Review Change Stack

Profile switches in Thaw can now invoke user-supplied scripts before and after the apply, letting a sister app (the issue cites iStat Menus as the motivating use case) be kept in sync with Thaw's profile transitions on display auto-switch, hotkey, manual apply, menu bar context menu, and macOS Focus Filter activation. All five entry points funnel through `ProfileManager.applyProfile`, so wiring the hook stages into that single function covers every trigger.

Two scopes of hook are supported. Global pre/post hooks live in `UserDefaults` (new `globalPreProfileHook` / `globalPostProfileHook` keys) and fire on every apply. Per-profile pre/post hooks live inside the profile's JSON via a new optional `automation: ProfileAutomation?` field on `Profile` and `ProfileContent`. The field uses `decodeIfPresent`, so existing profiles on disk decode unchanged. Profile export/import already serialises the whole `Profile`, so per-profile hooks travel with `exportProfile` / `importProfile` automatically.

Hooks execute in nested order around the apply: `Global pre -> Profile pre -> [Thaw applies the snapshot, spacing offset, and layout] -> Profile post -> Global post`. Each scope's pre runs before its counterpart's post, mirroring a typical setup/teardown pairing. Hooks are blocking: the layout task awaits each one before proceeding, with a per-hook timeout (configurable 1-300 seconds, default 5). Failures and timeouts log to `DiagLog` but do not abort the apply, so a broken script can never lock the user out of switching profiles. Cancelled applies (e.g. a second profile switch fired before the first finishes) propagate `Task.isCancelled` through the hook stages.

The new `HookRunner` (Thaw/Utilities/HookRunner.swift) wraps `Process()` and races process termination against a `Task.sleep` timeout via `withThrowingTaskGroup`. AppleScript files (`.scpt`, `.applescript`, `.scptd`) route through `/usr/bin/osascript <path>`; other files are launched directly and must carry the executable bit (`chmod +x`), enforced at run time so users can swap the file behind the scenes without re-picking it. Stdout and stderr are piped to `DiagLog` under category `HookRunner`. Scripts receive the following environment variables: `THAW_HOOK_PHASE` (`Pre` or `Post`), `THAW_HOOK_SCOPE` (`Global` or `Profile`), `THAW_PROFILE_ID`, `THAW_PROFILE_NAME`, `THAW_PREVIOUS_PROFILE_ID` (empty when starting from no active profile), `THAW_PREVIOUS_PROFILE_NAME`. `applyProfile` gained a `previousProfileID` parameter that callers populate by reading `activeProfileID` before they overwrite it; all seven call sites (manual Apply button, profile hotkey, menu bar context menu, control item context menu, Focus Filter activation, display auto-switch, and `reapplyActiveProfile`) were updated.

The apply pipeline was restructured: `applyProfile`'s previously-synchronous snapshot push (general / advanced / hotkeys / display config / appearance / item names / new-items placement) now runs inside the `layoutTask` after the pre-hook stages, so a pre-hook can complete before Thaw mutates any state. Callers that previously awaited `layoutTask?.value` continue to do so and observe the same final state. With no hooks configured, the new code paths short-circuit and the apply timing matches today.

The Automation settings pane (which previously housed only the Settings URI Scheme whitelist) gains a Hooks section with two subsections: Global Hooks (pre and post rows) and Per-Profile Hooks (a profile picker plus pre and post rows). Each hook row uses `NSOpenPanel` for script selection, a stepper for timeout, an enabled toggle, and surfaces validation warnings for missing or non-executable files. The section closes with the list of injected environment variables and an example showing how a bash pre-hook could push the new profile name into iStat Menus via `defaults write`.

Closes #567

Signed-off-by: Amir Zarrinkafsh <3339418+nightah@users.noreply.github.com>
@coderabbitai

coderabbitai Bot commented May 13, 2026

Copy link
Copy Markdown
Contributor

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 6d46fa84-05c5-45a2-ac66-5e75c2ac6974

📥 Commits

Reviewing files that changed from the base of the PR and between 7b1bc7a and eb182b0.

📒 Files selected for processing (3)
  • Thaw/Settings/Models/ProfileManager.swift
  • Thaw/Settings/SettingsPanes/AutomationSettingsPane.swift
  • Thaw/Utilities/HookRunner.swift
🚧 Files skipped from review as they are similar to previous changes (3)
  • Thaw/Settings/Models/ProfileManager.swift
  • Thaw/Utilities/HookRunner.swift
  • Thaw/Settings/SettingsPanes/AutomationSettingsPane.swift

📝 Walkthrough

Walkthrough

Adds global and per-profile pre/post hook configuration and UI, a HookRunner to execute scripts with timeout and env vars, stores hooks in profiles/user defaults, and threads previousProfileID through apply flows so hooks receive prior-profile context.

Changes

Profile Automation Hooks

Layer / File(s) Summary
Hook models & persistence
Thaw/Settings/Models/HookScript.swift, Thaw/Settings/Models/AutomationHookSettings.swift, Thaw/Utilities/Defaults.swift
Defines HookScript, ProfileAutomation, HookPhase/HookScope, global persistence helpers, AutomationHookSettings, and new Defaults keys.
Hook execution engine
Thaw/Utilities/HookRunner.swift
Implements async hook execution with file validation, timeout racing (1–300s), environment variables, stdout/stderr capture, and typed errors/outcomes.
Profile automation field
Thaw/Settings/Models/Profile.swift
Adds optional automation to Profile/ProfileContent with Codable support and forward-compatible decoding.
ProfileManager apply flow & hook APIs
Thaw/Settings/Models/ProfileManager.swift
Refactors applyProfile to accept previousProfileID, preloads/runs pre-hooks inside the layout task, extracts applySnapshot, runs post-hooks with cancellation guards, and adds hooks(forProfileID:) and setHook(_:phase:forProfileID:).
Automation settings UI & HookRow
Thaw/Settings/SettingsPanes/AutomationSettingsPane.swift
Adds global and per-profile hook UI, HookRow for script selection/validation/timeout, bindings that persist via ProfileManager, and env var help text.
Profile apply call-site updates
Thaw/Hotkeys/Hotkey.swift, Thaw/MenuBar/ControlItem/ControlItem.swift, Thaw/MenuBar/MenuBarManager.swift, Thaw/Settings/SettingsPanes/ProfileSettingsPane.swift, Thaw/Settings/Models/ProfileManager.swift
Call sites now capture the prior activeProfileID and pass it to applyProfile so hooks receive previous-profile context; focus-filter/reapply/display auto-switch flows updated similarly.
Localization strings
Thaw/Resources/Localizable.xcstrings
Adds localized keys for hook UI labels, placeholders, errors, phase/section headings, env var docs, and timeout/unit labels.

Sequence Diagram

sequenceDiagram
  participant User as User (Profile Switch)
  participant Manager as ProfileManager
  participant HookRunner as HookRunner
  participant Hook as Shell/AppleScript
  User->>Manager: applyProfile(newProfile, previousProfileID)
  Manager->>Manager: Load pre/post hooks
  Manager->>HookRunner: runIfEnabled(pre-hook, context)
  HookRunner->>Hook: Execute with env vars
  Hook-->>HookRunner: Exit status + stdout/stderr
  Manager->>Manager: Apply profile snapshot
  Manager->>Manager: Execute layout
  Manager->>HookRunner: runIfEnabled(profile post-hook)
  HookRunner->>Hook: Execute with env vars
  Hook-->>HookRunner: Outcome
  Manager->>HookRunner: runIfEnabled(global post-hook)
  HookRunner->>Hook: Execute with env vars
  Hook-->>HookRunner: Outcome
  Manager-->>User: Profile applied with hooks completed
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

  • stonerl/Thaw#331: Updates to profile-apply flow in hotkey and menu bar handlers; related integration work.

Suggested labels

feature, enhancement

Suggested reviewers

  • diazdesandi

Poem

🐇 I hop and tap where profiles meet,

Pre and post, my scripts repeat,
With env and timeouts snug and tight,
I run at dusk and run at light,
A rabbit’s cheer for hooks in flight!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 75.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately describes the main feature added: global and per-profile pre/post apply script hooks, which is the primary change across all modified and new files.
Description check ✅ Passed The description is comprehensive and well-structured, covering what the PR does, PR type, breaking changes, current/new behavior, comprehensive checklist completion, and detailed manual testing notes with clear reviewer guidance.
Linked Issues check ✅ Passed The PR fully addresses issue #567's request to run user-supplied shell/AppleScript scripts on profile switches. It implements global and per-profile hooks, environment variable passing (THAW_PROFILE_NAME for iStat Menus use case), timeout handling, and integrates across all apply entry points.
Out of Scope Changes check ✅ Passed All changes are directly scoped to the hooks feature: new hook models, runner, UI in AutomationSettingsPane, profile manager integration, hotkey/menu/control-item path updates to pass previousProfileID, and localization strings. No unrelated refactors or cleanup detected.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/automation-profile-hooks

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
Thaw/Settings/SettingsPanes/AutomationSettingsPane.swift (1)

571-574: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Preview will crash — AppState environment object is missing.

AutomationSettingsPane declares @EnvironmentObject var appState: AppState (line 14), and the body unconditionally reads appState.profileManager.profiles / activeProfileID in both onAppear and profileHooksGroup. The #Preview instantiates the pane with no .environmentObject(...), so SwiftUI previews and any developer running this preview will hit a runtime trap before rendering.

🔧 Inject AppState into the preview
 `#Preview` {
     AutomationSettingsPane()
         .frame(width: 600, height: 500)
+        .environmentObject(AppState())
 }

If AppState() has side effects that make it unsuitable for previews, wrap it in a #if DEBUG preview helper or expose a .preview factory.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Thaw/Settings/SettingsPanes/AutomationSettingsPane.swift` around lines 571 -
574, The SwiftUI preview for AutomationSettingsPane will crash because
AutomationSettingsPane declares `@EnvironmentObject` var appState: AppState and
its body reads appState.profileManager.profiles and activeProfileID, so update
the `#Preview` that instantiates AutomationSettingsPane to inject a suitable
AppState via .environmentObject(...), e.g. provide a default or debug AppState
instance (or a preview factory like AppState.preview) so the preview has a valid
environment object; ensure you reference AutomationSettingsPane and AppState
when adding the .environmentObject to the preview.
🧹 Nitpick comments (3)
Thaw/Settings/SettingsPanes/AutomationSettingsPane.swift (3)

463-486: ⚡ Quick win

Timeout controls lack accessible names.

Both the timeout TextField (line 472) and Stepper (line 477) use EmptyView() as their label and rely on the adjacent "Timeout" / "s" Text for sighted users. VoiceOver users get no announced name for either control. The Toggle on line 467 is fine because "Enabled" is its label.

♿ Suggested accessibility labels
                     HStack(spacing: 4) {
                         Text("Timeout")
                         TextField(value: timeoutBinding, format: .number) {
                             EmptyView()
                         }
                         .textFieldStyle(.roundedBorder)
                         .frame(width: 48)
+                        .accessibilityLabel("Hook timeout in seconds")
                         Stepper(value: timeoutBinding, in: 1...300) {
                             EmptyView()
                         }
                         .labelsHidden()
+                        .accessibilityLabel("Hook timeout in seconds")
                         Text("s").foregroundStyle(.secondary)
                     }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Thaw/Settings/SettingsPanes/AutomationSettingsPane.swift` around lines 463 -
486, The timeout TextField and Stepper inside the hook != nil block lack
accessible labels; update the TextField and Stepper that use timeoutBinding to
supply an explicit accessibility label (or a non-empty label view) such as
.accessibilityLabel("Timeout in seconds") or replace EmptyView() with a
Label("Timeout", systemImage: "")/Text("Timeout") so VoiceOver announces them;
ensure the Stepper and TextField labels match and keep the visible "Timeout"
text for sighted users while using .accessibilityValue or .accessibilityLabel on
the controls to expose the units ("seconds") if needed.

520-529: 💤 Low value

TextField accepts out-of-range values until commit.

Stepper is bounded by 1...300, but the TextField is bound only through timeoutBinding, which clamps inside the setter. Until the user commits, the field can display 9999 or 0, and the value only snaps back after focus loss. Consider using a bounded format style so the field rejects out-of-range entries inline, or at least display the value through a derived binding that mirrors the clamped result.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Thaw/Settings/SettingsPanes/AutomationSettingsPane.swift` around lines 520 -
529, The TextField shows out-of-range numbers until commit because
timeoutBinding only clamps in its setter; change timeoutBinding so its getter
also returns the clamped value (mirror the clamped timeoutSeconds) so the
displayed value always reflects the bounded 1...300 range, and keep the setter
clamping as-is; locate the timeoutBinding computed property and update its get
to use hook?.timeoutSeconds clamped with max(1, min(...,300)) (or alternatively
replace the TextField with a formatted/bounded NumberFormatter/FormatStyle tied
to timeoutBinding) so the UI rejects or immediately corrects out-of-range
entries.

550-555: 💤 Low value

Remove UTType.item or clarify the panel's intended filtering behavior.

UTType.item is the root of the UTI hierarchy. When included in allowedContentTypes, it renders the more specific types (shellScript, appleScript, executable) ineffective at filtering—the panel will allow the same broad set regardless. Either remove .item if the intent is to prefer specific script types, or remove the explicit types if the intent is to accept any file.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Thaw/Settings/SettingsPanes/AutomationSettingsPane.swift` around lines 550 -
555, The allowedContentTypes array on the panel is including UTType.item, which
overrides the specific filters (UTType.shellScript, UTType.appleScript,
UTType.executable) and makes them ineffective; in AutomationSettingsPane.swift
update the panel.allowedContentTypes assignment used when configuring the open
panel (the line assigning to panel.allowedContentTypes) to either remove
UTType.item so the panel actually filters to the specific script/executable
types, or—if the intent is to accept any file—remove the explicit
UTType.shellScript/UTType.appleScript/UTType.executable entries and keep only
UTType.item to express a broad accept-all behavior, then adjust any related
comments to reflect the chosen behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@Thaw/Settings/Models/ProfileManager.swift`:
- Around line 382-397: After awaiting HookRunner.runIfEnabled(profilePost, ...)
add an explicit cancellation check and bail out before calling the global
post-hook: check Task.isCancelled (or call try Task.checkCancellation()) and
return/throw CancellationError if cancelled so that
HookRunner.runIfEnabled(globalPost, ...) is not invoked when the task was
cancelled; update the code immediately after the profile-post await (around
HookRunner.runIfEnabled(profilePost, ...) and before the
HookRunner.runIfEnabled(globalPost, ...)) to perform this guard using the
existing baseContext/HookRunner symbols.

In `@Thaw/Settings/SettingsPanes/AutomationSettingsPane.swift`:
- Around line 36-41: The Picker's selectedHookProfileID is only set in onAppear
and can become stale when profiles change; add an onChange(of:
appState.profileManager.profiles) handler that validates the current
selectedHookProfileID and, if it's not present in the updated profiles list, set
selectedHookProfileID = appState.profileManager.activeProfileID ??
appState.profileManager.profiles.first?.id so the Picker and HookRow references
always point to an existing profile; implement this adjacent to the existing
onAppear block and reference selectedHookProfileID,
appState.profileManager.profiles and activeProfileID in the handler.

In `@Thaw/Utilities/HookRunner.swift`:
- Around line 145-151: The wait for process exit is registered after calling
process.run(), which can miss very short-lived hooks; before calling
process.run() (in the same context where you use withThrowingTaskGroup and
compute exitStatus), spawn a background task that calls process.waitUntilExit()
(or set process.terminationHandler) so the wait is registered prior to launch —
update the code around process.run(), process.terminationHandler and the
withThrowingTaskGroup handling (the block returning process.terminationStatus)
to start the wait via process.waitUntilExit() before invoking process.run() to
avoid timeout false-positives.
- Around line 145-175: Wrap the existing withThrowingTaskGroup block in a
withTaskCancellationHandler so that when the parent task is cancelled you
explicitly terminate the spawned Process; in the onCancel closure call
process.terminate() and, after a short delay if needed, process.interrupt()
(mirroring your timeout cleanup), and ensure any child tasks are cancelled
(e.g., group.cancelAll()) — apply this around the withThrowingTaskGroup usage
that currently sets process.terminationHandler and returns
process.terminationStatus so that external Task cancellation also cleans up the
subprocess.

---

Outside diff comments:
In `@Thaw/Settings/SettingsPanes/AutomationSettingsPane.swift`:
- Around line 571-574: The SwiftUI preview for AutomationSettingsPane will crash
because AutomationSettingsPane declares `@EnvironmentObject` var appState:
AppState and its body reads appState.profileManager.profiles and
activeProfileID, so update the `#Preview` that instantiates AutomationSettingsPane
to inject a suitable AppState via .environmentObject(...), e.g. provide a
default or debug AppState instance (or a preview factory like AppState.preview)
so the preview has a valid environment object; ensure you reference
AutomationSettingsPane and AppState when adding the .environmentObject to the
preview.

---

Nitpick comments:
In `@Thaw/Settings/SettingsPanes/AutomationSettingsPane.swift`:
- Around line 463-486: The timeout TextField and Stepper inside the hook != nil
block lack accessible labels; update the TextField and Stepper that use
timeoutBinding to supply an explicit accessibility label (or a non-empty label
view) such as .accessibilityLabel("Timeout in seconds") or replace EmptyView()
with a Label("Timeout", systemImage: "")/Text("Timeout") so VoiceOver announces
them; ensure the Stepper and TextField labels match and keep the visible
"Timeout" text for sighted users while using .accessibilityValue or
.accessibilityLabel on the controls to expose the units ("seconds") if needed.
- Around line 520-529: The TextField shows out-of-range numbers until commit
because timeoutBinding only clamps in its setter; change timeoutBinding so its
getter also returns the clamped value (mirror the clamped timeoutSeconds) so the
displayed value always reflects the bounded 1...300 range, and keep the setter
clamping as-is; locate the timeoutBinding computed property and update its get
to use hook?.timeoutSeconds clamped with max(1, min(...,300)) (or alternatively
replace the TextField with a formatted/bounded NumberFormatter/FormatStyle tied
to timeoutBinding) so the UI rejects or immediately corrects out-of-range
entries.
- Around line 550-555: The allowedContentTypes array on the panel is including
UTType.item, which overrides the specific filters (UTType.shellScript,
UTType.appleScript, UTType.executable) and makes them ineffective; in
AutomationSettingsPane.swift update the panel.allowedContentTypes assignment
used when configuring the open panel (the line assigning to
panel.allowedContentTypes) to either remove UTType.item so the panel actually
filters to the specific script/executable types, or—if the intent is to accept
any file—remove the explicit
UTType.shellScript/UTType.appleScript/UTType.executable entries and keep only
UTType.item to express a broad accept-all behavior, then adjust any related
comments to reflect the chosen behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 1dae0967-81f3-4d5e-92df-896d41d33612

📥 Commits

Reviewing files that changed from the base of the PR and between 7de93ad and 7b1bc7a.

📒 Files selected for processing (12)
  • Thaw/Hotkeys/Hotkey.swift
  • Thaw/MenuBar/ControlItem/ControlItem.swift
  • Thaw/MenuBar/MenuBarManager.swift
  • Thaw/Resources/Localizable.xcstrings
  • Thaw/Settings/Models/AutomationHookSettings.swift
  • Thaw/Settings/Models/HookScript.swift
  • Thaw/Settings/Models/Profile.swift
  • Thaw/Settings/Models/ProfileManager.swift
  • Thaw/Settings/SettingsPanes/AutomationSettingsPane.swift
  • Thaw/Settings/SettingsPanes/ProfileSettingsPane.swift
  • Thaw/Utilities/Defaults.swift
  • Thaw/Utilities/HookRunner.swift

Comment thread Thaw/Settings/Models/ProfileManager.swift
Comment thread Thaw/Settings/SettingsPanes/AutomationSettingsPane.swift
Comment thread Thaw/Utilities/HookRunner.swift Outdated
Comment thread Thaw/Utilities/HookRunner.swift Outdated
@nightah

nightah commented May 13, 2026

Copy link
Copy Markdown
Collaborator Author

Here's a short video illustrating the change. This shows how you can have both global and profile hooks set.

Cap.2026-05-13.at.15.26.18.mp4

Signed-off-by: Amir Zarrinkafsh <3339418+nightah@users.noreply.github.com>
@sonarqubecloud

Copy link
Copy Markdown

@stonerl stonerl merged commit e2b6675 into development May 13, 2026
8 checks passed
@stonerl stonerl mentioned this pull request May 13, 2026
1 task
@stonerl stonerl deleted the feat/automation-profile-hooks branch May 14, 2026 08:17
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.

[Feature Request]: Ability to call bash scripts as hook on profile switch

2 participants