Skip to content
Open
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
301 changes: 301 additions & 0 deletions ios/PPGMobile/PPGMobile/State/AppState.swift
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 {
Comment on lines +1 to +46
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

# First, check the actual AppState.swift file to see current imports and `@Observable` usage
find . -name "AppState.swift" -type f | head -5

Repository: 2witstudios/ppg-cli

Length of output: 109


🏁 Script executed:

# Search for `@Observable` usage across the codebase to see import patterns
rg "@Observable" --type swift -B 2 | head -50

Repository: 2witstudios/ppg-cli

Length of output: 539


🌐 Web query:

Swift Observation framework @observable macro requires import statement iOS

💡 Result:

@Observable lives in the Observation module, so the explicit import is:

import Observation

However, 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, @Observable typically resolves without import Observation. [1], [2]

Use import Observation when 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.swift

Repository: 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.swift

Repository: 2witstudios/ppg-cli

Length of output: 691


🏁 Script executed:

# Search for any Observation imports in the codebase
rg "import Observation" --type swift

Repository: 2witstudios/ppg-cli

Length of output: 45


🏁 Script executed:

# Check if SwiftUI is imported anywhere that might affect module resolution
rg "import SwiftUI" --type swift | head -20

Repository: 2witstudios/ppg-cli

Length of output: 45


🏁 Script executed:

# Check the project's Swift/iOS deployment target to understand the context
find . -name "*.pbxproj" -o -name "project.yml" -o -name "Package.swift" | head -5

Repository: 2witstudios/ppg-cli

Length of output: 106


🏁 Script executed:

# Look for any Info.plist or build configuration that might show Swift version/iOS target
find . -name "Info.plist" -o -name "*.xcconfig" | head -10

Repository: 2witstudios/ppg-cli

Length of output: 91


🏁 Script executed:

# Check if there are any other files importing Observation in different ways
rg "Observation" --type swift -i

Repository: 2witstudios/ppg-cli

Length of output: 45


🏁 Script executed:

# Check project deployment target and Swift version in pbxproj
cat "./PPG CLI/PPG CLI.xcodeproj/project.pbxproj" | grep -i "IPHONEOS_DEPLOYMENT_TARGET\|SWIFT_VERSION" | head -10

Repository: 2witstudios/ppg-cli

Length of output: 162


🏁 Script executed:

# Check if any swift files compile or build configuration details
cat "./PPG CLI/PPG CLI/Info.plist" | head -30

Repository: 2witstudios/ppg-cli

Length of output: 563


🏁 Script executed:

# Look for any build phase or configuration that might show minimum deployment
rg "IPHONEOS_DEPLOYMENT_TARGET|swift" "./PPG CLI/PPG CLI.xcodeproj" | head -20

Repository: 2witstudios/ppg-cli

Length of output: 465


🏁 Script executed:

# Find iOS deployment target in the pbxproj file
grep -i "IPHONEOS_DEPLOYMENT_TARGET" "./PPG CLI/PPG CLI.xcodeproj/project.pbxproj" | head -5

Repository: 2witstudios/ppg-cli

Length of output: 45


🏁 Script executed:

# Check if there's a Podfile or any CocoaPods configuration
find . -name "Podfile" -o -name "Podfile.lock" | head -5

Repository: 2witstudios/ppg-cli

Length of output: 45


🏁 Script executed:

# Verify that neither AppState.swift nor ManifestStore.swift import SwiftUI
rg "import SwiftUI" ios/PPGMobile/PPGMobile/State/

Repository: 2witstudios/ppg-cli

Length of output: 45


🏁 Script executed:

# Check the actual structure - determine if this is iOS or macOS focused
ls -la ios/ PPG*/ 2>/dev/null | head -20

Repository: 2witstudios/ppg-cli

Length of output: 546


🏁 Script executed:

# Look at the actual project structure to understand the context
find . -maxdepth 3 -name "*.swift" -path "*/PPGMobile/*" | wc -l
find . -maxdepth 3 -name "*.swift" -path "*/PPG CLI/*" | wc -l

Repository: 2witstudios/ppg-cli

Length of output: 67


🏁 Script executed:

# Check what the main project targets are
grep -E "productName|productType|name.*=" "./PPG CLI/PPG CLI.xcodeproj/project.pbxproj" | head -20

Repository: 2witstudios/ppg-cli

Length of output: 467


🏁 Script executed:

# Verify if Observation is actually available or if there's a deployment target mismatch
grep -i "IPHONEOS\|MACOSX\|DEPLOYMENT" "./PPG CLI/PPG CLI.xcodeproj/project.pbxproj" | grep -v ".pbxproj" | head -20

Repository: 2witstudios/ppg-cli

Length of output: 692


🏁 Script executed:

# Check if there are separate iOS targets or if PPGMobile is standalone
find ios/PPGMobile -name "*.pbxproj" -o -name "*.xcodeproj"

Repository: 2witstudios/ppg-cli

Length of output: 45


🏁 Script executed:

# Look for any build configurations or workspace files for iOS
find ios/ -name "*.xcodeproj" -o -name "*.xcworkspace"

Repository: 2witstudios/ppg-cli

Length of output: 45


🏁 Script executed:

# Verify Swift Observation availability in macOS 26.1 and check if Foundation re-exports it
rg "Foundation.*Observation|Observation.*Foundation" --type swift

Repository: 2witstudios/ppg-cli

Length of output: 45


🏁 Script executed:

# Check if the iOS/macOS project actually builds or if there's a test/CI configuration
find . -name "*.yml" -o -name ".github" -type d | head -10

Repository: 2witstudios/ppg-cli

Length of output: 159


🏁 Script executed:

# Look for any Xcode build logs or CI configuration that might show if this compiles
find . -name "*.log" -o -name "*.yml" -o -name ".github*" 2>/dev/null | head -10

Repository: 2witstudios/ppg-cli

Length of output: 159


🏁 Script executed:

# Definitively check: does Foundation.framework re-export Observation in current versions?
# Look for any documentation or comments about this in the codebase
rg "Observable|Observation" --type swift -B 2 -A 2

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.yml

Repository: 2witstudios/ppg-cli

Length of output: 519


🏁 Script executed:

# Check for any references to PPGMobile in build/test scripts
rg "PPGMobile" ./.github

Repository: 2witstudios/ppg-cli

Length of output: 45


🏁 Script executed:

# Determine the actual minimum iOS deployment target for PPGMobile if it has one
find ios/PPGMobile -name "*.swift" -type f | head -1 | xargs head -100 | rg "IPHONEOS"

Repository: 2witstudios/ppg-cli

Length of output: 45


🏁 Script executed:

# Check if there are any package manifests in the iOS directory
find ios/ -name "*.swift" -type f -exec grep -l "Package\|target\|dependencies" {} \; | head -5

Repository: 2witstudios/ppg-cli

Length of output: 45


Add explicit import Observation to use @Observable.

Line 45 uses @Observable but the file only imports Foundation. While Foundation may implicitly re-export Observation in some contexts, the explicit import is required and best practice. Without it, the code will fail to compile in contexts where Observation is not available.

Fix
 import Foundation
+import Observation
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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 {
import Foundation
import Observation
// 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 {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ios/PPGMobile/PPGMobile/State/AppState.swift` around lines 1 - 46, Add an
explicit import Observation at the top of the file alongside import Foundation
so the `@Observable` attribute on AppState is resolved; update the imports section
(before the AppState declaration and any use of `@Observable`) to include "import
Observation".


// 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
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Gate WebSocket callbacks to the active socket session.

Old socket callbacks can still fire after stopWebSocket() during connection switches, and currently they can mutate state for the wrong active connection.

🛡️ 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
Verify each finding against the current code and only fix it if needed.

In `@ios/PPGMobile/PPGMobile/State/AppState.swift` around lines 192 - 213,
startWebSocket's closures can run after stopWebSocket and mutate state for an
old session; fix by gating callbacks to the active session: when creating ws in
startWebSocket assign a unique session token/identifier (or capture the
WebSocketManager instance) and capture that token in the onStateChange and
onEvent closures, then inside those closures check that the captured token still
matches the current webSocket/session (e.g., compare against self?.webSocket or
a stored sessionId) before updating webSocketState or calling
handleWebSocketEvent; keep stopWebSocket setting webSocket = nil (or clearing
sessionId) so stale callbacks fail the gate.


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."
}
}
}
Loading