-
Notifications
You must be signed in to change notification settings - Fork 1
feat: implement iOS state management (AppState + ManifestStore) #111
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
2witstudios
wants to merge
3
commits into
main
Choose a base branch
from
ppg/issue-82-ios-state
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,301 @@ | ||
| import Foundation | ||
|
|
||
| // MARK: - UserDefaults Keys | ||
|
|
||
| private enum DefaultsKey { | ||
| static let savedConnections = "ppg_saved_connections" | ||
| static let lastConnectionId = "ppg_last_connection_id" | ||
| } | ||
|
|
||
| /// Codable projection of ServerConnection without the token. | ||
| /// Tokens are stored separately in Keychain via TokenStorage. | ||
| private struct PersistedConnection: Codable { | ||
| let id: UUID | ||
| var host: String | ||
| var port: Int | ||
| var caCertificate: String? | ||
|
|
||
| init(from connection: ServerConnection) { | ||
| self.id = connection.id | ||
| self.host = connection.host | ||
| self.port = connection.port | ||
| self.caCertificate = connection.caCertificate | ||
| } | ||
|
|
||
| func toServerConnection(token: String) -> ServerConnection { | ||
| ServerConnection( | ||
| id: id, | ||
| host: host, | ||
| port: port, | ||
| caCertificate: caCertificate, | ||
| token: token | ||
| ) | ||
| } | ||
| } | ||
|
|
||
| // MARK: - AppState | ||
|
|
||
| /// Root application state managing server connections and the REST/WS lifecycle. | ||
| /// | ||
| /// `AppState` is the single entry point for connection management. It persists | ||
| /// connection metadata to `UserDefaults` and tokens to Keychain via `TokenStorage`. | ||
| /// Auto-connects to the last-used server on launch and coordinates `PPGClient` | ||
| /// (REST) and `WebSocketManager` (WS) through `ManifestStore`. | ||
| @MainActor | ||
| @Observable | ||
| final class AppState { | ||
|
|
||
| // MARK: - Connection State | ||
|
|
||
| /// All saved server connections. | ||
| private(set) var connections: [ServerConnection] = [] | ||
|
|
||
| /// The currently active connection, or `nil` if disconnected. | ||
| private(set) var activeConnection: ServerConnection? | ||
|
|
||
| /// Whether a connection attempt is in progress. | ||
| private(set) var isConnecting = false | ||
|
|
||
| /// User-facing error message, cleared on next connect attempt. | ||
| private(set) var errorMessage: String? | ||
|
|
||
| // MARK: - WebSocket State | ||
|
|
||
| /// Current WebSocket connection state. | ||
| private(set) var webSocketState: WebSocketConnectionState = .disconnected | ||
|
|
||
| // MARK: - Dependencies | ||
|
|
||
| let client = PPGClient() | ||
| let manifestStore: ManifestStore | ||
| private var webSocket: WebSocketManager? | ||
|
|
||
| // MARK: - Init | ||
|
|
||
| init() { | ||
| self.manifestStore = ManifestStore(client: client) | ||
| loadConnections() | ||
| } | ||
|
|
||
| // MARK: - Auto-Connect | ||
|
|
||
| /// Connects to the last-used server if one exists. | ||
| /// Call this from the app's `.task` modifier on launch. | ||
| func autoConnect() async { | ||
| guard let lastId = UserDefaults.standard.string(forKey: DefaultsKey.lastConnectionId), | ||
| let uuid = UUID(uuidString: lastId), | ||
| let connection = connections.first(where: { $0.id == uuid }) else { | ||
| return | ||
| } | ||
| await connect(to: connection) | ||
| } | ||
|
|
||
| // MARK: - Connect / Disconnect | ||
|
|
||
| /// Connects to the given server: configures REST client, tests reachability, | ||
| /// starts WebSocket, and fetches the initial manifest. | ||
| func connect(to connection: ServerConnection) async { | ||
| guard !isConnecting else { return } | ||
|
|
||
| // Disconnect current connection first | ||
| if activeConnection != nil { | ||
| disconnect() | ||
| } | ||
|
|
||
| isConnecting = true | ||
| errorMessage = nil | ||
|
|
||
| await client.configure(connection: connection) | ||
|
|
||
| do { | ||
| try await client.testConnection() | ||
| } catch { | ||
| isConnecting = false | ||
| errorMessage = "Cannot reach server: \(error.localizedDescription)" | ||
| return | ||
| } | ||
|
|
||
| activeConnection = connection | ||
| UserDefaults.standard.set(connection.id.uuidString, forKey: DefaultsKey.lastConnectionId) | ||
|
|
||
| startWebSocket(for: connection) | ||
| await manifestStore.refresh() | ||
|
|
||
| isConnecting = false | ||
| } | ||
|
|
||
| /// Disconnects from the current server, tearing down WS and clearing state. | ||
| func disconnect() { | ||
| stopWebSocket() | ||
| activeConnection = nil | ||
| manifestStore.clear() | ||
| webSocketState = .disconnected | ||
| } | ||
|
|
||
| // MARK: - Connection CRUD | ||
|
|
||
| /// Adds a new connection, persists it, and optionally connects to it. | ||
| func addConnection(_ connection: ServerConnection, connectImmediately: Bool = true) async { | ||
| // Clean up orphaned Keychain token if replacing a duplicate | ||
| if let existing = connections.first(where: { $0.host == connection.host && $0.port == connection.port }), | ||
| existing.id != connection.id { | ||
| do { | ||
| try TokenStorage.delete(for: existing.id) | ||
| } catch { | ||
| errorMessage = "Failed to remove stale credentials from Keychain." | ||
| } | ||
| } | ||
|
|
||
| if let index = connections.firstIndex(where: { $0.host == connection.host && $0.port == connection.port }) { | ||
| connections[index] = connection | ||
| } else { | ||
| connections.append(connection) | ||
| } | ||
| saveConnections() | ||
|
|
||
| if connectImmediately { | ||
| await connect(to: connection) | ||
| } | ||
| } | ||
|
|
||
| /// Removes a saved connection. Disconnects first if it's the active one. | ||
| func removeConnection(_ connection: ServerConnection) { | ||
| if activeConnection?.id == connection.id { | ||
| disconnect() | ||
| } | ||
| connections.removeAll { $0.id == connection.id } | ||
| do { | ||
| try TokenStorage.delete(for: connection.id) | ||
| } catch { | ||
| errorMessage = "Failed to remove connection credentials from Keychain." | ||
| } | ||
| saveConnections() | ||
|
|
||
| if let lastId = UserDefaults.standard.string(forKey: DefaultsKey.lastConnectionId), | ||
| lastId == connection.id.uuidString { | ||
| UserDefaults.standard.removeObject(forKey: DefaultsKey.lastConnectionId) | ||
| } | ||
| } | ||
|
|
||
| /// Updates an existing connection's properties and re-persists. | ||
| func updateConnection(_ connection: ServerConnection) async { | ||
| guard let index = connections.firstIndex(where: { $0.id == connection.id }) else { return } | ||
| connections[index] = connection | ||
| saveConnections() | ||
|
|
||
| if activeConnection?.id == connection.id { | ||
| await connect(to: connection) | ||
| } | ||
| } | ||
|
|
||
| // MARK: - Error Handling | ||
|
|
||
| /// Clears the current error message. | ||
| func clearError() { | ||
| errorMessage = nil | ||
| } | ||
|
|
||
| // MARK: - WebSocket Lifecycle | ||
|
|
||
| private func startWebSocket(for connection: ServerConnection) { | ||
| stopWebSocket() | ||
|
|
||
| let ws = WebSocketManager(url: connection.webSocketURL) | ||
| ws.onStateChange = { [weak self] state in | ||
| Task { @MainActor in | ||
| self?.webSocketState = state | ||
| } | ||
| } | ||
| ws.onEvent = { [weak self] event in | ||
| Task { @MainActor in | ||
| self?.handleWebSocketEvent(event) | ||
| } | ||
| } | ||
| webSocket = ws | ||
| ws.connect() | ||
| } | ||
|
|
||
| private func stopWebSocket() { | ||
| webSocket?.disconnect() | ||
| webSocket = nil | ||
| } | ||
|
Comment on lines
+200
to
+221
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Gate WebSocket callbacks to the active socket session. Old socket callbacks can still fire after 🛡️ Proposed fix (session token gating) let client = PPGClient()
let manifestStore: ManifestStore
private var webSocket: WebSocketManager?
+ private var webSocketSession = UUID()
@@
private func startWebSocket(for connection: ServerConnection) {
stopWebSocket()
+ let session = UUID()
+ webSocketSession = session
let ws = WebSocketManager(url: connection.webSocketURL)
ws.onStateChange = { [weak self] state in
Task { `@MainActor` in
- self?.webSocketState = state
+ guard let self, self.webSocketSession == session else { return }
+ self.webSocketState = state
}
}
ws.onEvent = { [weak self] event in
Task { `@MainActor` in
- self?.handleWebSocketEvent(event)
+ guard let self, self.webSocketSession == session else { return }
+ self.handleWebSocketEvent(event)
}
}
webSocket = ws
ws.connect()
}
private func stopWebSocket() {
+ webSocketSession = UUID()
webSocket?.disconnect()
webSocket = nil
}🤖 Prompt for AI Agents |
||
|
|
||
| private func handleWebSocketEvent(_ event: WebSocketEvent) { | ||
| switch event { | ||
| case .manifestUpdated(let manifest): | ||
| manifestStore.applyManifest(manifest) | ||
|
|
||
| case .agentStatusChanged(let agentId, let status): | ||
| manifestStore.updateAgentStatus(agentId: agentId, status: status) | ||
|
|
||
| case .worktreeStatusChanged(let worktreeId, let statusRaw): | ||
| if let status = WorktreeStatus(rawValue: statusRaw) { | ||
| manifestStore.updateWorktreeStatus(worktreeId: worktreeId, status: status) | ||
| } | ||
|
|
||
| case .pong: | ||
| break | ||
|
|
||
| case .unknown: | ||
| break | ||
| } | ||
| } | ||
|
|
||
| // MARK: - Persistence | ||
|
|
||
| private func loadConnections() { | ||
| guard let data = UserDefaults.standard.data(forKey: DefaultsKey.savedConnections) else { | ||
| return | ||
| } | ||
|
|
||
| let persisted: [PersistedConnection] | ||
| do { | ||
| persisted = try JSONDecoder().decode([PersistedConnection].self, from: data) | ||
| } catch { | ||
| errorMessage = "Failed to load saved connections." | ||
| return | ||
| } | ||
|
|
||
| var loaded: [ServerConnection] = [] | ||
| var failedTokenLoad = false | ||
| for entry in persisted { | ||
| do { | ||
| let token = try TokenStorage.load(for: entry.id) | ||
| loaded.append(entry.toServerConnection(token: token)) | ||
| } catch { | ||
| failedTokenLoad = true | ||
| } | ||
| } | ||
| connections = loaded | ||
|
|
||
| if failedTokenLoad { | ||
| errorMessage = "Some saved connection tokens could not be loaded." | ||
| } | ||
| } | ||
|
|
||
| private func saveConnections() { | ||
| // Persist metadata to UserDefaults (no tokens) | ||
| let persisted = connections.map { PersistedConnection(from: $0) } | ||
| do { | ||
| let data = try JSONEncoder().encode(persisted) | ||
| UserDefaults.standard.set(data, forKey: DefaultsKey.savedConnections) | ||
| } catch { | ||
| errorMessage = "Failed to save connections." | ||
| return | ||
| } | ||
|
|
||
| // Persist tokens to Keychain | ||
| var failedTokenSave = false | ||
| for connection in connections { | ||
| do { | ||
| try TokenStorage.save(token: connection.token, for: connection.id) | ||
| } catch { | ||
| failedTokenSave = true | ||
| } | ||
| } | ||
|
|
||
| if failedTokenSave { | ||
| errorMessage = "Some connection tokens could not be saved." | ||
| } | ||
| } | ||
| } | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
Repository: 2witstudios/ppg-cli
Length of output: 109
🏁 Script executed:
Repository: 2witstudios/ppg-cli
Length of output: 539
🌐 Web query:
Swift Observation framework@observablemacro requires import statement iOS💡 Result:
@Observablelives in the Observation module, so the explicit import is:import ObservationHowever, in many iOS app files you won’t need to add it because SwiftUI (and often Foundation) already pulls it in for you—e.g. if you
import SwiftUI,@Observabletypically resolves withoutimport Observation. [1], [2]Use
import Observationwhen you’re in a non-SwiftUI / non-Foundation context (e.g., a pure Swift package target or a file that doesn’t import those modules) and the compiler can’t find@Observable. [2]🏁 Script executed:
# Check the actual full imports in AppState.swift head -50 ios/PPGMobile/PPGMobile/State/AppState.swiftRepository: 2witstudios/ppg-cli
Length of output: 1507
🏁 Script executed:
# Check ManifestStore.swift imports to see the pattern for `@Observable` usage head -20 ios/PPGMobile/PPGMobile/State/ManifestStore.swiftRepository: 2witstudios/ppg-cli
Length of output: 691
🏁 Script executed:
Repository: 2witstudios/ppg-cli
Length of output: 45
🏁 Script executed:
Repository: 2witstudios/ppg-cli
Length of output: 45
🏁 Script executed:
Repository: 2witstudios/ppg-cli
Length of output: 106
🏁 Script executed:
Repository: 2witstudios/ppg-cli
Length of output: 91
🏁 Script executed:
Repository: 2witstudios/ppg-cli
Length of output: 45
🏁 Script executed:
Repository: 2witstudios/ppg-cli
Length of output: 162
🏁 Script executed:
Repository: 2witstudios/ppg-cli
Length of output: 563
🏁 Script executed:
Repository: 2witstudios/ppg-cli
Length of output: 465
🏁 Script executed:
Repository: 2witstudios/ppg-cli
Length of output: 45
🏁 Script executed:
Repository: 2witstudios/ppg-cli
Length of output: 45
🏁 Script executed:
Repository: 2witstudios/ppg-cli
Length of output: 45
🏁 Script executed:
Repository: 2witstudios/ppg-cli
Length of output: 546
🏁 Script executed:
Repository: 2witstudios/ppg-cli
Length of output: 67
🏁 Script executed:
Repository: 2witstudios/ppg-cli
Length of output: 467
🏁 Script executed:
Repository: 2witstudios/ppg-cli
Length of output: 692
🏁 Script executed:
Repository: 2witstudios/ppg-cli
Length of output: 45
🏁 Script executed:
Repository: 2witstudios/ppg-cli
Length of output: 45
🏁 Script executed:
Repository: 2witstudios/ppg-cli
Length of output: 45
🏁 Script executed:
Repository: 2witstudios/ppg-cli
Length of output: 159
🏁 Script executed:
Repository: 2witstudios/ppg-cli
Length of output: 159
🏁 Script executed:
Repository: 2witstudios/ppg-cli
Length of output: 782
🏁 Script executed:
# Check if PPGMobile is built as part of CI or if it's just source code without active builds cat ./.github/workflows/ci.ymlRepository: 2witstudios/ppg-cli
Length of output: 519
🏁 Script executed:
Repository: 2witstudios/ppg-cli
Length of output: 45
🏁 Script executed:
Repository: 2witstudios/ppg-cli
Length of output: 45
🏁 Script executed:
Repository: 2witstudios/ppg-cli
Length of output: 45
Add explicit
import Observationto use@Observable.Line 45 uses
@Observablebut the file only importsFoundation. WhileFoundationmay implicitly re-exportObservationin some contexts, the explicit import is required and best practice. Without it, the code will fail to compile in contexts whereObservationis not available.Fix
import Foundation +import Observation📝 Committable suggestion
🤖 Prompt for AI Agents