diff --git a/Packages/CrowUI/Sources/CrowUI/SettingsView.swift b/Packages/CrowUI/Sources/CrowUI/SettingsView.swift index 7696930..370f320 100644 --- a/Packages/CrowUI/Sources/CrowUI/SettingsView.swift +++ b/Packages/CrowUI/Sources/CrowUI/SettingsView.swift @@ -27,15 +27,24 @@ public struct SettingsView: View { public var onSave: ((String, AppConfig) -> Void)? public var onRescaffold: ((String) -> Void)? + /// Fired when the user commits a new value into the corveil picker + /// (Browse confirm or Enter on the TextField), so AppDelegate can + /// re-run just `Scaffolder.installCorveilSkill` instead of waiting for + /// the next app restart (CROW-490). `nil` argument means "the user + /// cleared the field" — the install is a no-op then but the caller + /// still gets the signal to clear any stale warning banner. + public var onCorveilReinstall: ((String?) -> Void)? public init(appState: AppState, devRoot: String, config: AppConfig, onSave: ((String, AppConfig) -> Void)? = nil, - onRescaffold: ((String) -> Void)? = nil) { + onRescaffold: ((String) -> Void)? = nil, + onCorveilReinstall: ((String?) -> Void)? = nil) { self.appState = appState self._devRoot = State(initialValue: devRoot) self._config = State(initialValue: config) self.onSave = onSave self.onRescaffold = onRescaffold + self.onCorveilReinstall = onCorveilReinstall } /// Names of all workspaces except the one currently being edited. @@ -266,7 +275,7 @@ public struct SettingsView: View { HStack { TextField("Path to corveil binary", text: corveilBinding) .textFieldStyle(.roundedBorder) - .onSubmit { save() } + .onSubmit { commitCorveilPath() } Button("Browse...") { let panel = NSOpenPanel() panel.canChooseFiles = true @@ -274,9 +283,9 @@ public struct SettingsView: View { panel.allowsMultipleSelection = false if panel.runModal() == .OK, let url = panel.url { corveilBinding.wrappedValue = url.path - save() // Clear stale verify result — it's about a previous binary. corveilVerifyResult = nil + commitCorveilPath() } } Button(corveilVerifying ? "Verifying…" : "Verify") { verifyCorveil() } @@ -594,6 +603,21 @@ public struct SettingsView: View { onSave?(devRoot, config) } + /// Commit a corveil picker change: persist the config and hot-trigger + /// the `/query-corveil` install for the new path (CROW-490). Both + /// commit sites (Browse confirm and TextField `onSubmit`) funnel + /// through here so the "persist → reinstall" pair stays atomic and + /// the `nil`-on-empty rule has a single source of truth. We read the + /// path through `corveilBinding.wrappedValue` rather than the raw + /// input so the binding's whitespace-trim normalization wins — the + /// install closure sees the same string that the next launch's + /// scaffolder would see. + private func commitCorveilPath() { + save() + let path = corveilBinding.wrappedValue + onCorveilReinstall?(path.isEmpty ? nil : path) + } + /// Two-way binding into `config.defaults.binaries["corveil"]` that treats /// an empty string as "unset" (so the map doesn't accumulate stale empty /// entries when the user clears the field). Trimming happens on commit so diff --git a/Sources/Crow/App/AppDelegate.swift b/Sources/Crow/App/AppDelegate.swift index 4783a8a..e5f2907 100644 --- a/Sources/Crow/App/AppDelegate.swift +++ b/Sources/Crow/App/AppDelegate.swift @@ -53,6 +53,16 @@ final class AppDelegate: NSObject, NSApplicationDelegate { /// replaced. private var reviewKickoffTail: Task? + /// Tail of the serial corveil-skill-install queue. Settings picker + /// commits (`SettingsView.onCorveilReinstall`) chain new installs onto + /// this tail so two rapid picks don't race on `query-corveil.md` + /// (concurrent `corveil skill install` subprocesses writing the same + /// `--path`) or on `corveilSkillInstallWarning` (out-of-order + /// completion clobbering a fresher banner with a stale one). The last + /// committed path is also the last to write the banner. See the + /// CROW-490 review for the race this replaced. + private var corveilInstallTail: Task? + func applicationDidFinishLaunching(_ notification: Notification) { // Must be the very first call so the next exit (graceful or not) // lands somewhere readable. Also redirects stderr so Swift runtime @@ -187,6 +197,30 @@ final class AppDelegate: NSObject, NSApplicationDelegate { } } + /// Hot-trigger a single `corveil skill install` run for a path the user + /// just committed in Settings (CROW-490). Mirrors the + /// `reviewKickoffTail` pattern: writes to `corveilInstallTail` happen + /// on the main actor, and each task awaits its predecessor before + /// running, so the last-committed path is also the last to update the + /// banner. The blocking subprocess runs in a nested `Task.detached` + /// (bounded by `Scaffolder.corveilInstallTimeout`) so it can't freeze + /// the Settings window, then the result is assigned back on main. + /// `nil` path is a deliberate no-op for the subprocess but still + /// clears any stale warning, matching the launch-time scaffolder's + /// "always assign" semantics at the `onRescaffold` call site. + @MainActor + private func enqueueCorveilInstall(path: String?, devRoot: String) { + let previous = corveilInstallTail + corveilInstallTail = Task { @MainActor [weak self] in + await previous?.value + let warning = await Task.detached { + let scaffolder = Scaffolder(devRoot: devRoot) + return scaffolder.installCorveilSkill(path) + }.value + self?.appState.corveilSkillInstallWarning = warning + } + } + // MARK: - tmux watchdog alert /// Suppress repeated alerts while one is already on screen. Each alert @@ -1099,6 +1133,18 @@ final class AppDelegate: NSObject, NSApplicationDelegate { self?.appState.corveilSkillInstallWarning = "Re-scaffold failed: \(error.localizedDescription)" } + }, + onCorveilReinstall: { [weak self] newPath in + // CROW-490: when the user commits a new corveil binary path in + // Settings, hot-trigger just the `corveil skill install` step — + // not the whole Scaffolder pass — so `/query-corveil` reflects + // the new binary without requiring an app restart. + // + // `enqueueCorveilInstall` serializes back-to-back picks + // through `corveilInstallTail` (mirrors the `reviewKickoffTail` + // pattern) so two rapid commits can't race on + // `query-corveil.md` or on the warning banner. + self?.enqueueCorveilInstall(path: newPath, devRoot: devRoot) } ) diff --git a/Sources/Crow/App/Scaffolder.swift b/Sources/Crow/App/Scaffolder.swift index fa4272a..b2cb46a 100644 --- a/Sources/Crow/App/Scaffolder.swift +++ b/Sources/Crow/App/Scaffolder.swift @@ -227,7 +227,12 @@ struct Scaffolder { /// window drawn yet. The hard wall-clock timeout bounds the worst case: /// after `corveilInstallTimeout` seconds the process is sent SIGTERM and /// the install reports a warning rather than blocking forever. - private func installCorveilSkill(_ corveilBinaryPath: String?) -> String? { + /// + /// Settings can also call this directly (CROW-490) when the user picks a + /// new corveil binary, to avoid the "must restart" gap. Those callers + /// dispatch the call off the main thread (`Task.detached`) so the 5s + /// worst-case doesn't freeze the Settings window. + func installCorveilSkill(_ corveilBinaryPath: String?) -> String? { guard let path = corveilBinaryPath?.trimmingCharacters(in: .whitespacesAndNewlines), !path.isEmpty else { return nil