Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion Thaw/Hotkeys/Hotkey.swift
Original file line number Diff line number Diff line change
Expand Up @@ -91,8 +91,9 @@ extension Hotkey {
let profileManager = appState.profileManager
Task {
guard let profile = try? profileManager.loadProfile(id: profileID) else { return }
let previousID = profileManager.activeProfileID
profileManager.activeProfileID = profileID
profileManager.applyProfile(profile, to: appState)
profileManager.applyProfile(profile, to: appState, previousProfileID: previousID)
}
}
} else {
Expand Down
3 changes: 2 additions & 1 deletion Thaw/MenuBar/ControlItem/ControlItem.swift
Original file line number Diff line number Diff line change
Expand Up @@ -804,8 +804,9 @@ final class ControlItem {
let profileManager = appState.profileManager
Task {
guard let profile = try? profileManager.loadProfile(id: profileID) else { return }
let previousID = profileManager.activeProfileID
profileManager.activeProfileID = profileID
profileManager.applyProfile(profile, to: appState)
profileManager.applyProfile(profile, to: appState, previousProfileID: previousID)
}
}

Expand Down
3 changes: 2 additions & 1 deletion Thaw/MenuBar/MenuBarManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -689,8 +689,9 @@ final class MenuBarManager: ObservableObject {
Task { [weak self] in
do {
let profile = try appState.profileManager.loadProfile(id: profileID)
let previousID = appState.profileManager.activeProfileID
appState.profileManager.activeProfileID = profileID
appState.profileManager.applyProfile(profile, to: appState)
appState.profileManager.applyProfile(profile, to: appState, previousProfileID: previousID)
} catch {
self?.diagLog.error("Failed to apply profile \(profileID): \(error)")
}
Expand Down
76 changes: 76 additions & 0 deletions Thaw/Resources/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,10 @@
}
}
},
"(no script selected)" : {
"comment" : "A placeholder displayed in the \"Path\" column of a hook row when no script is selected.",
"isCommentAutoGenerated" : true
},
")" : {
"comment" : "A closing parenthesis.",
"localizations" : {
Expand Down Expand Up @@ -11366,6 +11370,10 @@
}
}
},
"Choose Script..." : {
"comment" : "A button that lets the user choose a script.",
"isCommentAutoGenerated" : true
},
"Clear" : {
"comment" : "A glass style that is clear.",
"localizations" : {
Expand Down Expand Up @@ -11485,6 +11493,10 @@
}
}
},
"Clear hook" : {
"comment" : "A button that removes a hook.",
"isCommentAutoGenerated" : true
},
"Click an empty area of the menu bar to show hidden menu bar items." : {
"comment" : "Toggle that allows the user to enable or disable the feature of showing hidden menu bar items by clicking on an empty area of the menu bar.",
"localizations" : {
Expand Down Expand Up @@ -17554,6 +17566,14 @@
}
}
},
"Enabled" : {
"comment" : "A checkbox that enables or disables a hook.",
"isCommentAutoGenerated" : true
},
"Environment variables passed to scripts" : {
"comment" : "A label describing the environment variables that can be passed to scripts.",
"isCommentAutoGenerated" : true
},
"Error" : {
"comment" : "An alert title.",
"localizations" : {
Expand Down Expand Up @@ -17792,6 +17812,10 @@
}
}
},
"Example: a bash pre-hook could `defaults write com.bjango.istatmenus5 ActiveProfile -string \"$THAW_PROFILE_NAME\"` to keep iStat Menus in sync with Thaw." : {
"comment" : "A description of how to use environment variables in a pre-hook.",
"isCommentAutoGenerated" : true
},
"Export" : {
"comment" : "A menu item that exports a profile to a file.",
"localizations" : {
Expand Down Expand Up @@ -18268,6 +18292,10 @@
}
}
},
"File does not exist." : {
"comment" : "Error message when the file specified in the hook does not exist.",
"isCommentAutoGenerated" : true
},
"Focus" : {
"comment" : "Localized string key for the \"Focus\" case in the `RehideStrategy` enum.",
"localizations" : {
Expand Down Expand Up @@ -19101,6 +19129,10 @@
}
}
},
"Global Hooks" : {
"comment" : "A heading for the global hooks section.",
"isCommentAutoGenerated" : true
},
"Go to Advanced Settings" : {
"comment" : "A link that takes the user to the advanced settings screen.",
"localizations" : {
Expand Down Expand Up @@ -20172,6 +20204,10 @@
}
}
},
"Hooks" : {
"comment" : "A heading for the hooks section.",
"isCommentAutoGenerated" : true
},
"Horizontal" : {
"comment" : "Localized string key for the horizontal layout mode.",
"localizations" : {
Expand Down Expand Up @@ -27080,6 +27116,10 @@
}
}
},
"No profiles saved yet. Create one in the Profiles tab to attach per-profile hooks." : {
"comment" : "A message displayed when there are no profiles saved yet.",
"isCommentAutoGenerated" : true
},
"No profiles saved. Save your current configuration to create one." : {
"comment" : "A message displayed when there are no profiles saved.",
"localizations" : {
Expand Down Expand Up @@ -27913,6 +27953,10 @@
}
}
},
"Not executable. Run \"chmod +x\" on the file." : {
"comment" : "Warning message displayed in the UI when the script path is invalid.",
"isCommentAutoGenerated" : true
},
"Notch" : {
"comment" : "A label describing a display with a notch.",
"localizations" : {
Expand Down Expand Up @@ -28984,6 +29028,10 @@
}
}
},
"Per-Profile Hooks" : {
"comment" : "A heading for the hooks that apply only to a specific profile.",
"isCommentAutoGenerated" : true
},
"Permission Granted" : {
"comment" : "A label indicating that a user has granted a permission.",
"localizations" : {
Expand Down Expand Up @@ -29222,6 +29270,14 @@
}
}
},
"Post-apply" : {
"comment" : "Label for the \"Post-apply\" hook.",
"isCommentAutoGenerated" : true
},
"Pre-apply" : {
"comment" : "Label for the pre-apply hook.",
"isCommentAutoGenerated" : true
},
"Profile" : {
"comment" : "Title of the parameter in the \"Set Menu Bar Profile\" focus filter.",
"localizations" : {
Expand Down Expand Up @@ -33030,6 +33086,14 @@
}
}
},
"Run a shell or AppleScript file before or after a profile switch. Hooks fire on every apply path: manual button, hotkey, display auto-switch, and Focus Filter." : {
"comment" : "A description of the purpose of the hooks.",
"isCommentAutoGenerated" : true
},
"s" : {
"comment" : "A unit of time.",
"isCommentAutoGenerated" : true
},
"Save Current" : {
"comment" : "A button that saves the current profile when pressed.",
"localizations" : {
Expand Down Expand Up @@ -38034,6 +38098,10 @@
}
}
},
"THAW_HOOK_PHASE, THAW_HOOK_SCOPE, THAW_PROFILE_ID, THAW_PROFILE_NAME, THAW_PREVIOUS_PROFILE_ID, THAW_PREVIOUS_PROFILE_NAME" : {
"comment" : "A list of environment variables that can be passed to scripts run by Thaw.",
"isCommentAutoGenerated" : true
},
"The %@ Bar is aligned to the left edge of the display." : {
"comment" : "A label that describes the location of the \\(Constants.displayName) Bar on a display.",
"localizations" : {
Expand Down Expand Up @@ -39105,6 +39173,10 @@
}
}
},
"These hooks run only when this profile is applied, after the global pre-hook and before the global post-hook." : {
"comment" : "A description of the hooks that run only when a profile is applied.",
"isCommentAutoGenerated" : true
},
"This action cannot be undone." : {
"comment" : "A message presented when the user confirms they want to reset the menu bar appearance.",
"localizations" : {
Expand Down Expand Up @@ -39462,6 +39534,10 @@
}
}
},
"Timeout" : {
"comment" : "A label displayed next to a text field for the timeout value.",
"isCommentAutoGenerated" : true
},
"Tint" : {
"comment" : "A section for adjusting the tint of the menu bar.",
"localizations" : {
Expand Down
43 changes: 43 additions & 0 deletions Thaw/Settings/Models/AutomationHookSettings.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
//
// AutomationHookSettings.swift
// Project: Thaw
//
// Copyright (Ice) © 2023–2025 Jordan Baird
// Copyright (Thaw) © 2026 Toni Förster
// Licensed under the GNU GPLv3

import Combine
import Foundation

/// Manages the two global profile hooks shown in AutomationSettingsPane.
///
/// Per-profile hooks live inside each Profile JSON; the pane reads and
/// writes them directly through ProfileManager and does not need to be
/// mirrored here.
@MainActor
final class AutomationHookSettings: ObservableObject {
@Published var globalPreHook: HookScript? {
didSet {
guard !suppressPersist else { return }
HookScript.saveGlobal(globalPreHook, phase: .pre)
}
}

@Published var globalPostHook: HookScript? {
didSet {
guard !suppressPersist else { return }
HookScript.saveGlobal(globalPostHook, phase: .post)
}
}

/// True while loading from defaults; suppresses writeback in the
/// @Published didSet so we do not echo the initial load back to disk.
private var suppressPersist = false

init() {
suppressPersist = true
globalPreHook = HookScript.loadGlobal(.pre)
globalPostHook = HookScript.loadGlobal(.post)
suppressPersist = false
}
}
89 changes: 89 additions & 0 deletions Thaw/Settings/Models/HookScript.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
//
// HookScript.swift
// Project: Thaw
//
// Copyright (Ice) © 2023–2025 Jordan Baird
// Copyright (Thaw) © 2026 Toni Förster
// Licensed under the GNU GPLv3

import Foundation

/// A single user-supplied script run as a profile-apply hook.
///
/// The path is stored verbatim. Whether the file is invoked directly or
/// routed through osascript is decided at run time by the extension, so
/// the user can replace the file behind the scenes without re-picking it.
struct HookScript: Codable, Hashable {
/// Absolute path to the script file on disk.
var path: String

/// Maximum wall-clock seconds the hook may run before Thaw terminates it.
/// Clamped to [1, 300] at run time; storing the raw value keeps the
/// Stepper binding straightforward.
var timeoutSeconds: Double

/// When false, the hook is skipped without removing the path. Lets
/// users park a configured script without losing the path.
var isEnabled: Bool

init(path: String, timeoutSeconds: Double = 5, isEnabled: Bool = true) {
self.path = path
self.timeoutSeconds = timeoutSeconds
self.isEnabled = isEnabled
}
}

// MARK: - ProfileAutomation

/// Per-profile hook configuration. Optional inside Profile for forward
/// compatibility: profiles on disk written before this field existed
/// decode with automation = nil.
struct ProfileAutomation: Codable, Hashable {
var preHook: HookScript?
var postHook: HookScript?

init(preHook: HookScript? = nil, postHook: HookScript? = nil) {
self.preHook = preHook
self.postHook = postHook
}

/// True when neither hook is set. Used by the manager to elide writing
/// an empty container into the profile JSON.
var isEmpty: Bool { preHook == nil && postHook == nil }
}

// MARK: - HookPhase / HookScope

enum HookPhase: String {
case pre
case post
}

enum HookScope: String {
case global
case profile
}

// MARK: - Global hook persistence

extension HookScript {
/// Loads the global hook for the given phase from UserDefaults, or
/// returns nil if none configured / decode fails.
static func loadGlobal(_ phase: HookPhase) -> HookScript? {
let key: Defaults.Key = (phase == .pre) ? .globalPreProfileHook : .globalPostProfileHook
guard let data = Defaults.data(forKey: key) else { return nil }
return try? JSONDecoder().decode(HookScript.self, from: data)
}

/// Persists the global hook for the given phase, or clears it when nil.
static func saveGlobal(_ hook: HookScript?, phase: HookPhase) {
let key: Defaults.Key = (phase == .pre) ? .globalPreProfileHook : .globalPostProfileHook
guard let hook else {
Defaults.removeObject(forKey: key)
return
}
if let data = try? JSONEncoder().encode(hook) {
Defaults.set(data, forKey: key)
}
}
}
Loading
Loading