Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
93fa955
chore: ignore local worktrees
Reedtrullz Sep 5, 2026
94ae958
docs: design Vifty audit remediation program
Reedtrullz Sep 6, 2026
daeaf10
docs: plan staged Vifty audit remediation
Reedtrullz Sep 6, 2026
b1f92a3
feat: expose agent persistence health
Reedtrullz Sep 6, 2026
eae9e28
fix: bound policy persistence messages
Reedtrullz Sep 6, 2026
5194839
fix: fail closed on agent policy persistence errors
Reedtrullz Sep 6, 2026
fb419a8
fix: make app preferences recoverable and truthful
Reedtrullz Sep 6, 2026
d112cbb
fix: fail closed on preference backup recovery
Reedtrullz Sep 6, 2026
da081c5
fix: drain helper lifecycle output concurrently
Reedtrullz Sep 6, 2026
9077e5b
fix: bound lifecycle cleanup after stdin failure
Reedtrullz Sep 6, 2026
49a8a8a
Fix Stage 1 persistence safety reporting
Reedtrullz Sep 6, 2026
71ec76b
fix: report unavailable hardware truthfully
Reedtrullz Sep 6, 2026
355b8d4
fix: expose agent audit persistence failures
Reedtrullz Sep 6, 2026
9f74794
feat: add viftyctl help
Reedtrullz Sep 6, 2026
f94d846
test: reject Swift test warnings
Reedtrullz Sep 6, 2026
96f2f77
test: bind warning flag to test recipes
Reedtrullz Sep 6, 2026
b8981cd
test: cover hosted UI observation lifecycle
Reedtrullz Sep 6, 2026
a8771bd
test: clarify hosted fixture lifecycle comment
Reedtrullz Sep 6, 2026
ea769b2
fix: keep UI review readiness deadlines in one clock domain
Reedtrullz Sep 7, 2026
78de149
fix: tolerate missing AX descriptions
Reedtrullz Sep 7, 2026
e5b7576
Fix update toggle accessibility label
Reedtrullz Sep 7, 2026
a5f9c80
docs: refresh current Vifty UI evidence checkpoint
Reedtrullz Sep 7, 2026
3b86933
test: make verification host and worktree independent
Reedtrullz Sep 7, 2026
196e09b
docs: refresh current Vifty UI evidence checkpoint
Reedtrullz Sep 7, 2026
c68b685
test: make validation fixture warning-clean on Xcode 16
Reedtrullz Sep 7, 2026
f9532c7
test: wait for complete process fixture output
Reedtrullz Sep 7, 2026
b946750
fix: scope diagnose probes for Swift 6.1
Reedtrullz Sep 7, 2026
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ Package.resolved

# Local agent workflow scratch
.superpowers/
.worktrees/

# Machine-local native UI capture ledger (portable checkpoint is tracked separately)
docs/ui-review/evidence-manifest.local.json
5 changes: 3 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ RELEASE_SWIFT_PLATFORM_DIR ?= $(RELEASE_ARCHITECTURE)-apple-macosx
SWIFT_TRIPLE_ARGS = $(if $(filter release,$(CONFIGURATION)),--triple "$(RELEASE_SWIFT_TRIPLE)",)
SWIFT_PROVENANCE_ARGS = $(if $(SWIFT_BUILD_PROVENANCE_FILE),-Xlinker -sectcreate -Xlinker __TEXT -Xlinker __vifty_src -Xlinker "$(SWIFT_BUILD_PROVENANCE_FILE)",)
SWIFT_BUILD_ARGS = $(if $(SWIFT_BUILD_PATH),--build-path "$(SWIFT_BUILD_PATH)",) $(SWIFT_TRIPLE_ARGS) $(SWIFT_PROVENANCE_ARGS) $(SWIFT_BUILD_EXTRA_ARGS)
SWIFT_TEST_WARNING_ARGS = -Xswiftc -warnings-as-errors
# SwiftPM's product layout is toolchain-dependent (for example, Xcode 26
# places release products under .build/out/Products/Release). Ask SwiftPM for
# the path it actually selected, while preserving explicit caller overrides.
Expand Down Expand Up @@ -264,10 +265,10 @@ source-first-readiness: ## Check published source-first release readiness
test: test-full ## Run the full XCTest suite

test-fast: check-toolchain ## Run the fast local XCTest suite
swift test $(SWIFT_BUILD_ARGS) $(SLOW_TEST_SKIP_ARGS)
swift test $(SWIFT_BUILD_ARGS) $(SLOW_TEST_SKIP_ARGS) $(SWIFT_TEST_WARNING_ARGS)

test-full: check-toolchain ## Run the full XCTest suite, including slow evidence/release script tests
swift test $(SWIFT_BUILD_ARGS)
swift test $(SWIFT_BUILD_ARGS) $(SWIFT_TEST_WARNING_ARGS)

verify: check-toolchain ## Run fast local trust gates without installing
/bin/bash -n scripts/*.sh scripts/lib/*.sh examples/viftyctl/*.sh
Expand Down
10 changes: 10 additions & 0 deletions Sources/Vifty/AppModel+Control.swift
Original file line number Diff line number Diff line change
Expand Up @@ -461,6 +461,16 @@ extension AppModel {
}
}

var agentAuditPersistenceMessage: String? {
guard let health = agentControlStatus?.persistenceHealth,
!health.auditStatusAvailable else { return nil }
let detail = health.auditError?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
guard !detail.isEmpty else {
return "Agent audit history unavailable; cooling control is unchanged."
}
return "Agent audit history unavailable; cooling control is unchanged. " + detail
}

func setAgentCoolingEnabled(_ enabled: Bool) async {
do {
guard let status = try await agentPolicySetter(enabled) else { return }
Expand Down
14 changes: 12 additions & 2 deletions Sources/Vifty/AppModel+MenuBar.swift
Original file line number Diff line number Diff line change
Expand Up @@ -175,7 +175,7 @@ extension AppModel {
}

func persistAppPreferences() {
preferencesStore.save(AppPreferences(
let preferences = AppPreferences(
menuBarDisplayMode: menuBarDisplayMode,
menuBarCustomFields: menuBarCustomFields,
startupMode: startupMode,
Expand All @@ -184,7 +184,17 @@ extension AppModel {
usePerFanFixedRPM: usePerFanFixedRPM,
fixedFanTargets: fixedFanTargets,
codexUsageDisplayPreferences: codexUsageDisplayPreferences
))
)
do {
try preferencesStore.saveThrowing(preferences)
appPreferencesPersistenceMessage = nil
} catch {
appPreferencesPersistenceMessage = "Settings were not saved: \(error.localizedDescription)"
}
}

func retryAppPreferencesSave() {
persistAppPreferences()
}

var codexUsageDisplayPreferences: CodexUsageDisplayPreferences {
Expand Down
12 changes: 11 additions & 1 deletion Sources/Vifty/AppModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ final class AppModel: ObservableObject {
}
@Published var lastError: String?
@Published var curveProfilePersistenceError: String?
@Published var appPreferencesPersistenceMessage: String?
@Published var appPreferencesRecoveryMessage: String?
@Published var fanAccessMessage: String?
@Published var daemonResponding = false
@Published var daemonReachable = false
Expand Down Expand Up @@ -318,7 +320,15 @@ final class AppModel: ObservableObject {
self.agentRestore = agentRestore
self.profileStore = profileStore
self.preferencesStore = preferencesStore
let appPreferences = self.preferencesStore.load()
let appPreferences: AppPreferences
do {
let result = try self.preferencesStore.loadResult()
appPreferences = result.preferences
appPreferencesRecoveryMessage = result.recoveryMessage
} catch {
appPreferences = self.preferencesStore.load()
appPreferencesPersistenceMessage = "Settings were not saved: \(error.localizedDescription)"
}
menuBarDisplayMode = appPreferences.menuBarDisplayMode
menuBarCustomFields = MenuBarField.normalized(appPreferences.menuBarCustomFields)
startupMode = appPreferences.startupMode
Expand Down
135 changes: 117 additions & 18 deletions Sources/Vifty/AppPreferencesStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,11 @@ struct AppPreferences: Codable, Equatable {
}
}

struct AppPreferencesLoadResult: Equatable {
var preferences: AppPreferences
var recoveryMessage: String?
}

final class AppPreferencesStore: @unchecked Sendable {
static let legacyMenuBarDisplayModeDefaultsKey = "menuBarDisplayMode"
static let legacyNotificationHelperFailureDefaultsKey = "notification.helperFailure"
Expand All @@ -76,38 +81,132 @@ final class AppPreferencesStore: @unchecked Sendable {
}

func load() -> AppPreferences {
if let data = try? Data(contentsOf: url),
let preferences = try? JSONDecoder().decode(AppPreferences.self, from: data) {
return preferences
}
(try? loadResult().preferences) ?? migratedPreferences()
}

// Preserve the unreadable original before any overwrite so a decode
// failure never silently destroys the last recoverable copy.
if FileManager.default.fileExists(atPath: url.path) {
let backup = url.appendingPathExtension("bak")
try? FileManager.default.removeItem(at: backup)
try? FileManager.default.copyItem(at: url, to: backup)
func loadResult() throws -> AppPreferencesLoadResult {
let primary = decodePreferences(at: url)
switch primary {
case .success(let preferences):
try restrictDirectoryPermissions()
try restrictFilePermissions(at: url)
if case .success = decodePreferences(at: backupURL) {
try restrictFilePermissions(at: backupURL)
}
return AppPreferencesLoadResult(preferences: preferences, recoveryMessage: nil)
case .missing, .failure:
break
}

let migrated = migratedPreferences()
if migrated != .defaults {
try? saveThrowing(migrated)
let backup = decodePreferences(at: backupURL)
switch backup {
case .success(let preferences):
try restrictDirectoryPermissions()
try restrictFilePermissions(at: backupURL)
if case .failure = primary {
quarantinePrimaryIfPossible()
}
return AppPreferencesLoadResult(
preferences: preferences,
recoveryMessage: "Vifty loaded its private app-preferences backup."
)
case .missing, .failure:
let migrated = migratedPreferences()
if migrated != .defaults {
try saveThrowing(migrated)
}
let recoveryMessage: String?
switch (primary, backup) {
case (.failure, _), (_, .failure):
recoveryMessage = "Vifty could not recover its private app preferences; defaults are in use."
default:
recoveryMessage = nil
}
return AppPreferencesLoadResult(preferences: migrated, recoveryMessage: recoveryMessage)
}
return migrated
}

func save(_ preferences: AppPreferences) {
try? saveThrowing(preferences)
}

func saveThrowing(_ preferences: AppPreferences) throws {
let directory = url.deletingLastPathComponent()
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
try FileManager.default.setAttributes([.posixPermissions: NSNumber(value: 0o700)], ofItemAtPath: directory.path)

if case .success = decodePreferences(at: url) {
let primaryData = try Data(contentsOf: url)
try replaceBackup(with: primaryData)
}

let data = try JSONEncoder().encode(preferences)
try data.write(to: url, options: .atomic)
try restrictFilePermissions(at: url)

if case .success = decodePreferences(at: backupURL) {
try restrictFilePermissions(at: backupURL)
} else {
try replaceBackup(with: data)
}
}

private var backupURL: URL {
url.appendingPathExtension("bak")
}

private enum DecodeResult {
case success(AppPreferences)
case missing
case failure
}

private func decodePreferences(at fileURL: URL) -> DecodeResult {
guard FileManager.default.fileExists(atPath: fileURL.path) else { return .missing }
do {
return .success(try JSONDecoder().decode(AppPreferences.self, from: Data(contentsOf: fileURL)))
} catch {
return .failure
}
}

private func replaceBackup(with data: Data) throws {
let temporaryURL = backupURL.deletingLastPathComponent().appendingPathComponent(
".\(backupURL.lastPathComponent).\(UUID().uuidString).tmp"
)
do {
try data.write(to: temporaryURL, options: .withoutOverwriting)
try restrictFilePermissions(at: temporaryURL)
if FileManager.default.fileExists(atPath: backupURL.path) {
_ = try FileManager.default.replaceItemAt(
backupURL,
withItemAt: temporaryURL,
backupItemName: nil,
options: []
)
} else {
try FileManager.default.moveItem(at: temporaryURL, to: backupURL)
}
try restrictFilePermissions(at: backupURL)
} catch {
try? FileManager.default.removeItem(at: temporaryURL)
throw error
}
}

private func quarantinePrimaryIfPossible() {
let quarantineURL = url.deletingLastPathComponent().appendingPathComponent(
".\(url.lastPathComponent).corrupt.\(UUID().uuidString)"
)
do {
try FileManager.default.moveItem(at: url, to: quarantineURL)
try restrictFilePermissions(at: quarantineURL)
} catch {
// Recovery from a valid backup must not depend on quarantine I/O.
}
}

private func restrictDirectoryPermissions() throws {
try FileManager.default.setAttributes(
[.posixPermissions: NSNumber(value: 0o700)],
ofItemAtPath: url.deletingLastPathComponent().path
)
}

private func migratedPreferences() -> AppPreferences {
Expand Down
64 changes: 60 additions & 4 deletions Sources/Vifty/DaemonInstallService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,20 @@ struct DaemonInstallProcessOutput: Equatable, Sendable {
var standardError: String
}

private actor BoundedProcessOutput {
static let maximumBytesPerStream = 64 * 1_024
private var data = Data()

func append(_ chunk: Data) {
guard data.count < Self.maximumBytesPerStream else { return }
data.append(chunk.prefix(Self.maximumBytesPerStream - data.count))
}

func snapshot() -> Data {
data
}
}

struct DaemonInstallProcessRunner: Sendable {
let run: @Sendable (URL, [String], Data) async throws -> DaemonInstallProcessOutput

Expand All @@ -45,26 +59,68 @@ struct DaemonInstallProcessRunner: Sendable {
let inputPipe = Pipe()
let outputPipe = Pipe()
let errorPipe = Pipe()
let inputHandle = inputPipe.fileHandleForWriting
let outputHandle = outputPipe.fileHandleForReading
let errorHandle = errorPipe.fileHandleForReading
process.standardInput = inputPipe
process.standardOutput = outputPipe
process.standardError = errorPipe
defer {
try? inputHandle.close()
try? outputHandle.close()
try? errorHandle.close()
}
try process.run()
let output = BoundedProcessOutput()
let error = BoundedProcessOutput()
let outputReader = Task.detached(priority: .userInitiated) {
do {
while let chunk = try outputHandle.read(upToCount: 64 * 1_024), !chunk.isEmpty {
await output.append(chunk)
}
} catch {
return
}
}
let errorReader = Task.detached(priority: .userInitiated) {
do {
while let chunk = try errorHandle.read(upToCount: 64 * 1_024), !chunk.isEmpty {
await error.append(chunk)
}
} catch {
return
}
}
do {
try inputPipe.fileHandleForWriting.write(contentsOf: standardInput)
try inputPipe.fileHandleForWriting.close()
try inputHandle.write(contentsOf: standardInput)
try inputHandle.close()
} catch {
process.terminate()
try? inputHandle.close()
try? outputHandle.close()
try? errorHandle.close()
let deadline = Date().addingTimeInterval(0.25)
while process.isRunning && Date() < deadline {
usleep(10_000)
}
if process.isRunning {
kill(process.processIdentifier, SIGKILL)
}
outputReader.cancel()
errorReader.cancel()
throw error
}
process.waitUntilExit()
_ = await outputReader.value
_ = await errorReader.value
return DaemonInstallProcessOutput(
terminationStatus: process.terminationStatus,
standardOutput: String(
decoding: outputPipe.fileHandleForReading.readDataToEndOfFile(),
decoding: await output.snapshot(),
as: UTF8.self
),
standardError: String(
decoding: errorPipe.fileHandleForReading.readDataToEndOfFile(),
decoding: await error.snapshot(),
as: UTF8.self
)
)
Expand Down
11 changes: 11 additions & 0 deletions Sources/Vifty/SettingsAgentWorkflowView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,17 @@ struct SettingsAgentWorkflowView: View {
)
.disabled(model.agentCoolingEnabled == nil)
.accessibilityIdentifier(ViftyAccessibilityIdentifier.agentCoolingEnabled)

if let message = model.agentAuditPersistenceMessage {
Label {
Text(message)
} icon: {
Image(systemName: "exclamationmark.triangle")
}
.foregroundStyle(.orange)
.fixedSize(horizontal: false, vertical: true)
.accessibilityLabel("Agent audit persistence needs attention")
}
}

Section("Commands") {
Expand Down
Loading