Skip to content
15 changes: 9 additions & 6 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ details, release history over commit history.
app-server provider and deliver text-only Chat through models available to an eligible signed-in
ChatGPT account. Native Settings keeps this under Accounts, separate from OpenAI Platform keys,
and receives only normalized account/model state—never tokens. The provider must be explicitly
added as `codex-app-server`; signing in does not change Automatic routing, make it a preferred
added as `codex-app-server`; the native Connections screen can add that route with one click before
sign-in. Signing in does not change Automatic routing, make it a preferred
hosted destination, or expand the credential broker. Offline mode excludes it, and a pinned ChatGPT
destination fails visibly instead of falling back. Development builds can use an explicitly
selected helper; release builds reject unverified sibling executables, and the ChatGPT-app
Expand All @@ -26,11 +27,13 @@ details, release history over commit history.
macOS app now includes a dedicated, thread-first Chat window that streams replies through the
bundled arm64 Rust gateway. This first desktop release supports Apple Silicon on macOS 14 or
later; Intel and a universal artifact are deferred. The complete conversation stays central while
the authoritative provider, mode, score, explanation, and routing signals live in a persistent,
collapsible inspector on the right.
Navigator filters never remove messages from the transcript. Conversations remain in memory for
this release; Stop, contextual Retry, New Chat, bounded request history, and actionable delivery
failures are included. The app uses its own SemVer release line (`0.1.0`) while the bundled router
the authoritative provider, mode, score, explanation, and routing signals remain available from
an information popover on each response instead of consuming permanent thread width.
Navigator filters never remove messages from the transcript. Conversation history persists
locally across New Chat and relaunch; Stop, contextual Retry, New Chat, bounded request history,
personal destination names, and actionable delivery failures are included. Settings consolidates
ChatGPT accounts and API-key providers under Connections while keeping their authentication paths
visibly distinct. The app uses its own SemVer release line (`0.1.0`) while the bundled router
retains its existing CalVer identity when built and distributed independently; the router
embedded in the desktop bundle reports the desktop product version.

Expand Down
12 changes: 7 additions & 5 deletions docs/releases/desktop-v0.1.0.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,16 @@

Wayfinder's first native desktop release is a focused Apple Silicon menu-bar app with its Rust
gateway built in. It keeps routing visible without turning the popover into a dashboard, and adds a
dedicated thread-first Chat window with detailed routing information in a collapsible right-hand
inspector.
dedicated thread-first Chat window where routing details stay secondary in a per-response popover.

## Included

- Native menu-bar status, setup, Settings, endpoint readiness, routing controls, Offline mode, and
Keychain-backed provider configuration.
- Focused Chat with streaming, Stop, Retry, New Chat, bounded in-memory history, explicit destination
selection, and truthful failure/recovery states.
- Focused Chat with streaming, Stop, Retry, New Chat, locally persisted conversation history,
explicit destination selection, personal destination names, and truthful failure/recovery states.
- A consolidated Connections screen for ChatGPT account access and API-key providers, including
one-click ChatGPT route configuration before the native sign-in flow.
- The arm64 Rust gateway and authenticated Credential Broker and Foundation Model Broker XPC services
inside the desktop app, all on the same `0.1.0` product version.
- Apple Foundation Models as an on-device provider on eligible macOS 26+ Apple Silicon systems where
Expand Down Expand Up @@ -46,7 +47,8 @@ keys remain a separate provider path, and Wayfinder never imports ChatGPT tokens

## Current boundaries

- Conversations are kept in memory for this release.
- Conversation history is stored locally on this Mac. It is not synced or sent anywhere merely by
being retained.
- Chat remains a thin client over the bundled gateway; it is not an agent, tool runner, filesystem
client, or credential owner.
- The ChatGPT provider depends on verified `/Applications/ChatGPT.app` and is not self-contained.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import Foundation

public struct ChatConversation: Identifiable, Codable, Equatable, Sendable {
public let id: UUID
public var messages: [ChatMessage]
public let createdAt: Date
public var updatedAt: Date

public init(
id: UUID = UUID(),
messages: [ChatMessage] = [],
createdAt: Date = Date(),
updatedAt: Date = Date()
) {
self.id = id
self.messages = messages
self.createdAt = createdAt
self.updatedAt = updatedAt
}

public var title: String {
messages.first(where: { $0.role == .user })?.text
.trimmingCharacters(in: .whitespacesAndNewlines)
.nonEmpty ?? "New chat"
}

public var turnCount: Int {
messages.filter { $0.role == .user }.count
}
}

private extension String {
var nonEmpty: String? { isEmpty ? nil : self }
}
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,9 @@ public enum CredentialStatus: Equatable, Sendable {
case .unknown:
return "Checking"
case .keyMissing:
return "Key missing"
return "Not connected"
case .keyPresent:
return "Key present"
return "Connected"
case .local:
return "Local"
case .comingSoon:
Expand Down Expand Up @@ -87,8 +87,8 @@ public extension ProviderKind {
)
case .openAI:
return ProviderCredentialDetail(
displayName: "OpenAI",
providerName: "openai",
displayName: "OpenAI Platform API",
providerName: "OpenAI API",
baseURL: "https://api.openai.com/v1",
models: [
"gpt-4o-mini",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import Foundation

public final class ChatConversationStore: @unchecked Sendable {
private static let maximumStoredConversations = 100
private static let maximumFileBytes = 8 * 1_024 * 1_024

private let fileURL: URL?
private let fileManager: FileManager

public init(fileURL: URL? = nil, fileManager: FileManager = .default) {
self.fileURL = fileURL
self.fileManager = fileManager
}

public static func applicationSupport(fileManager: FileManager = .default) -> ChatConversationStore {
let root = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first
return ChatConversationStore(
fileURL: root?
.appendingPathComponent("Wayfinder", isDirectory: true)
.appendingPathComponent("chat-history.json", isDirectory: false),
fileManager: fileManager
)
}

public func load() -> [ChatConversation] {
guard let fileURL,
let attributes = try? fileManager.attributesOfItem(atPath: fileURL.path),
let byteCount = attributes[.size] as? NSNumber,
byteCount.intValue <= Self.maximumFileBytes,
let data = try? Data(contentsOf: fileURL, options: .mappedIfSafe),
let conversations = try? JSONDecoder().decode([ChatConversation].self, from: data) else {
return []
}
return Array(
conversations
.filter { !$0.messages.isEmpty }
.sorted { $0.updatedAt > $1.updatedAt }
.prefix(Self.maximumStoredConversations)
)
}

public func save(_ conversations: [ChatConversation]) {
guard let fileURL else { return }
let bounded = Array(
conversations
.filter { !$0.messages.isEmpty }
.sorted { $0.updatedAt > $1.updatedAt }
.prefix(Self.maximumStoredConversations)
)
guard let data = try? JSONEncoder().encode(bounded),
data.count <= Self.maximumFileBytes else {
return
}
do {
try fileManager.createDirectory(
at: fileURL.deletingLastPathComponent(),
withIntermediateDirectories: true
)
try data.write(to: fileURL, options: [.atomic, .completeFileProtection])
} catch {
// Chat remains usable in memory if local history cannot be written.
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import Foundation

public protocol ChatGPTProviderConfigurator: Sendable {
func configure() async throws
}

public struct LocalChatGPTProviderConfigurator: ChatGPTProviderConfigurator {
private let resolver: GatewayToolResolver
private let service: GatewayServiceController

public init(
resolver: GatewayToolResolver = GatewayToolResolver(),
service: GatewayServiceController = GatewayServiceController()
) {
self.resolver = resolver
self.service = service
}

public func configure() async throws {
let tool: URL
do {
tool = try await resolver.resolveGateway()
} catch {
throw ChatGPTProviderSetupError.gatewayMissing
}
let path = GatewayServiceController.defaultConfigPath()
let result = await Self.run(tool, ["app-configure-chatgpt", "--path", path])
guard result.exitCode == 0 else {
throw ChatGPTProviderSetupError.configurationFailed(result.stderr)
}
try await service.restart()
for _ in 0..<20 {
if (await service.status()).health != nil { return }
try await Task.sleep(nanoseconds: 250_000_000)
}
throw ChatGPTProviderSetupError.gatewayDidNotRestart
}

private static func run(_ executable: URL, _ arguments: [String]) async -> ChatGPTSetupProcessResult {
await Task.detached {
let process = Process()
process.executableURL = executable
process.arguments = arguments
let stderr = Pipe()
process.standardOutput = FileHandle(forWritingAtPath: "/dev/null")
process.standardError = stderr
do { try process.run() } catch {
return ChatGPTSetupProcessResult(exitCode: 1, stderr: error.localizedDescription)
}
process.waitUntilExit()
let data = stderr.fileHandleForReading.readDataToEndOfFile()
return ChatGPTSetupProcessResult(
exitCode: process.terminationStatus,
stderr: String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
)
}.value
}
}

private struct ChatGPTSetupProcessResult: Sendable {
let exitCode: Int32
let stderr: String
}

public enum ChatGPTProviderSetupError: LocalizedError, Sendable {
case gatewayMissing
case configurationFailed(String)
case gatewayDidNotRestart

public var errorDescription: String? {
switch self {
case .gatewayMissing:
return "Wayfinder could not find its bundled gateway. Reinstall the app and try again."
case .configurationFailed(let detail):
return detail.isEmpty ? "Wayfinder could not add the ChatGPT destination." : detail
case .gatewayDidNotRestart:
return "ChatGPT was added, but the local gateway did not restart. Open Gateway settings and try again."
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import Foundation

public enum GatewayReplacementReconciliation: Equatable, Sendable {
case unchanged
case restarted
case deferred
}

/// Restarts a loaded bundled gateway once after its containing app is replaced.
///
/// launchd can keep the old executable image alive when an app is replaced at the same path. That
/// stale process can no longer authenticate newly signed nested XPC services. The helper is located
/// only at the fixed bundle-relative path, and the LaunchAgent must name that exact path before it
/// can be restarted. Full helper authentication still happens at every execution boundary; this
/// marker only avoids an unnecessary restart on every ordinary app launch.
public struct GatewayReplacementReconciler: Sendable {
public typealias GatewayLocator = @Sendable () -> URL?
public typealias StatusQuery = @Sendable (_ expectedGateway: URL) async -> GatewayServiceStatus
public typealias Restarter = @Sendable (_ expectedGateway: URL) async throws -> Void
public typealias FingerprintProvider = @Sendable (_ gateway: URL) -> String?
public typealias MarkerReader = @Sendable () -> String?
public typealias MarkerWriter = @Sendable (_ fingerprint: String) async -> Void

private static let markerKey = "Wayfinder.GatewayInstalledFingerprint"

private let locateGateway: GatewayLocator
private let statusQuery: StatusQuery
private let restart: Restarter
private let fingerprintProvider: FingerprintProvider
private let markerReader: MarkerReader
private let markerWriter: MarkerWriter

public init(
appBundleURL: URL = Bundle.main.bundleURL,
service: GatewayServiceController = GatewayServiceController()
) {
self.locateGateway = {
let gateway = GatewayToolResolver.bundledHelperURL(in: appBundleURL)
return FileManager.default.isExecutableFile(atPath: gateway.path) ? gateway : nil
}
self.statusQuery = { await service.status(expectedGateway: $0) }
self.restart = { try await service.restart(expectedGateway: $0) }
self.fingerprintProvider = { Self.installationFingerprint(gateway: $0) }
self.markerReader = { UserDefaults.standard.string(forKey: Self.markerKey) }
self.markerWriter = { UserDefaults.standard.set($0, forKey: Self.markerKey) }
}

init(
locateGateway: @escaping GatewayLocator,
statusQuery: @escaping StatusQuery,
restart: @escaping Restarter,
fingerprintProvider: @escaping FingerprintProvider,
markerReader: @escaping MarkerReader,
markerWriter: @escaping MarkerWriter
) {
self.locateGateway = locateGateway
self.statusQuery = statusQuery
self.restart = restart
self.fingerprintProvider = fingerprintProvider
self.markerReader = markerReader
self.markerWriter = markerWriter
}

public func reconcile() async -> GatewayReplacementReconciliation {
guard let gateway = locateGateway(),
let fingerprint = fingerprintProvider(gateway) else {
return .deferred
}
if markerReader() == fingerprint {
return .unchanged
}

let status = await statusQuery(gateway)
guard status.installed,
status.loaded,
status.launchConfiguration.usesGateway(at: gateway) else {
return .deferred
}

do {
try await restart(gateway)
await markerWriter(fingerprint)
return .restarted
} catch {
return .deferred
}
}

static func installationFingerprint(
appBundleURL: URL = Bundle.main.bundleURL,
gateway: URL
) -> String? {
guard let values = try? gateway.resourceValues(forKeys: [.fileSizeKey, .contentModificationDateKey]),
let fileSize = values.fileSize,
let modified = values.contentModificationDate else {
return nil
}
let info = Bundle(url: appBundleURL)?.infoDictionary ?? Bundle.main.infoDictionary ?? [:]
let version = info["CFBundleShortVersionString"] as? String ?? "unknown"
let build = info["CFBundleVersion"] as? String ?? "unknown"
let signing: String
switch AppleFoundationModelsProductReadiness.current(appBundleURL: appBundleURL) {
case .ready(let teamIdentifier):
signing = teamIdentifier
case .incompleteOrInvalid:
signing = "untrusted"
}
let modifiedNanoseconds = Int64(modified.timeIntervalSince1970 * 1_000_000_000)
return [version, build, signing, String(fileSize), String(modifiedNanoseconds)]
.joined(separator: "|")
}
}
Loading
Loading