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
30 changes: 27 additions & 3 deletions Packages/CrowUI/Sources/CrowUI/SettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -266,17 +275,17 @@ 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
panel.canChooseDirectories = false
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() }
Expand Down Expand Up @@ -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
Expand Down
46 changes: 46 additions & 0 deletions Sources/Crow/App/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,16 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
/// replaced.
private var reviewKickoffTail: Task<Void, Never>?

/// 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<Void, Never>?

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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
)

Expand Down
7 changes: 6 additions & 1 deletion Sources/Crow/App/Scaffolder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading