nvrsion: Add: Fix Xcode build error when running ‘make ios-release-canary’, update ios/VERSION, and refactor session cache implementation for iOS.
Nucleic-Promote: 1 Co-authored-by: Nucleic <[email protected]>
This commit is contained in:
@@ -44,6 +44,13 @@ final class RemoteStore: ObservableObject {
|
||||
/// from `hostName(forSession:)` instead.
|
||||
@Published private(set) var hostName: String = ""
|
||||
@Published private(set) var sessions: [WireSessionSummary] = []
|
||||
/// The last session list seen from a live host, persisted to disk (`SessionCache`) and reloaded
|
||||
/// at launch. It backs `sessions` whenever nothing is connected, so the list shows a read-only
|
||||
/// history while offline instead of an empty "waiting to connect" screen.
|
||||
private var cachedSummaries: [WireSessionSummary] = SessionCache.loadSummaries()
|
||||
/// Debounced disk-write tasks for the offline cache (coalesce a burst of updates into one write).
|
||||
private var summaryPersistTask: Task<Void, Never>?
|
||||
private var transcriptPersistTask: Task<Void, Never>?
|
||||
@Published private(set) var capabilities = WireCapabilities(canModifyToolInput: false, allowAlwaysScopes: [])
|
||||
@Published private(set) var grantedScope: DeviceScope = .approve
|
||||
/// The host's model/effort catalog (SYNC §5.2), driving the composer + session-header pickers.
|
||||
@@ -296,7 +303,11 @@ final class RemoteStore: ObservableObject {
|
||||
pathMonitor.onChange = { [weak self] in self?.handlePathChange() }
|
||||
pathMonitor.start()
|
||||
discovery.start()
|
||||
if isPaired { reconnect() }
|
||||
if isPaired {
|
||||
// Show the saved chat history immediately, before any host connects.
|
||||
if sessions.isEmpty { sessions = cachedSummaries }
|
||||
reconnect()
|
||||
}
|
||||
}
|
||||
|
||||
/// A network-path change: tell each host connection to re-plan its transport now.
|
||||
@@ -510,16 +521,19 @@ final class RemoteStore: ObservableObject {
|
||||
// instead of truncating it to the host's 200-event tail.
|
||||
self.openEvents = Self.mergedEvents(self.openEvents, snap.recentEvents)
|
||||
self.openApprovals = snap.pendingApprovals
|
||||
self.persistOpenTranscript()
|
||||
}
|
||||
cb.openEvents = { [weak self] batch in
|
||||
guard let self, hostID == self.openSessionHostID, batch.sessionID == self.openSessionID else { return }
|
||||
self.openEvents.append(contentsOf: batch.events)
|
||||
self.persistOpenTranscript()
|
||||
}
|
||||
cb.openBackfill = { [weak self] batch in
|
||||
guard let self, hostID == self.openSessionHostID, batch.sessionID == self.openSessionID else { return }
|
||||
// Full-history backfill precedes what's on screen — merge by seq so it slots in above the
|
||||
// tail rather than appending out of order.
|
||||
self.openEvents = Self.mergedEvents(self.openEvents, batch.events)
|
||||
self.persistOpenTranscript()
|
||||
}
|
||||
cb.openDiff = { [weak self] diff in
|
||||
guard let self, hostID == self.openSessionHostID, diff.sessionID == self.openSessionID else { return }
|
||||
@@ -587,7 +601,18 @@ final class RemoteStore: ObservableObject {
|
||||
/// or a representative one. No-op in demo, which seeds the aggregate directly.
|
||||
private func rebuildAggregate() {
|
||||
guard !demoMode else { return }
|
||||
sessions = aggregatedSessions()
|
||||
let live = aggregatedSessions()
|
||||
if !live.isEmpty {
|
||||
sessions = live
|
||||
cachedSummaries = live
|
||||
persistSummaries(live)
|
||||
} else if connections.values.contains(where: { $0.connectivity.isLive }) {
|
||||
// Connected, but the host genuinely has no sessions — reflect that honestly.
|
||||
sessions = []
|
||||
} else {
|
||||
// Offline: keep showing the saved history rather than blanking the list.
|
||||
sessions = cachedSummaries
|
||||
}
|
||||
dashboard = DashboardSnapshot.merged(connections.values.map(\.dashboard))
|
||||
meshPeers = connections.values.flatMap(\.meshPeers)
|
||||
|
||||
@@ -625,6 +650,48 @@ final class RemoteStore: ObservableObject {
|
||||
return bySeq.values.sorted { $0.seq < $1.seq }
|
||||
}
|
||||
|
||||
// MARK: - Offline cache (SessionCache)
|
||||
|
||||
/// Debounced write of the live session list to disk, so a read-only history survives a
|
||||
/// disconnect / relaunch.
|
||||
private func persistSummaries(_ list: [WireSessionSummary]) {
|
||||
summaryPersistTask?.cancel()
|
||||
summaryPersistTask = Task { [list] in
|
||||
try? await Task.sleep(nanoseconds: 2_000_000_000)
|
||||
guard !Task.isCancelled else { return }
|
||||
await SessionCache.saveSummaries(list)
|
||||
}
|
||||
}
|
||||
|
||||
/// Seed the open transcript from disk so a cached session's history shows instantly — even fully
|
||||
/// offline. A live snapshot/backfill merges on top by seq (dedup), so this never double-counts.
|
||||
private func loadCachedTranscript(_ sessionID: SessionID) {
|
||||
Task { [weak self] in
|
||||
let cached = await SessionCache.loadEvents(sessionID)
|
||||
guard let self, self.openSessionID == sessionID, !cached.isEmpty else { return }
|
||||
self.openEvents = Self.mergedEvents(cached, self.openEvents)
|
||||
}
|
||||
}
|
||||
|
||||
/// Debounced write of the open transcript, called after each batch of events lands.
|
||||
private func persistOpenTranscript() {
|
||||
guard !demoMode, let id = openSessionID, !openEvents.isEmpty else { return }
|
||||
let events = openEvents
|
||||
transcriptPersistTask?.cancel()
|
||||
transcriptPersistTask = Task { [events, id] in
|
||||
try? await Task.sleep(nanoseconds: 1_500_000_000)
|
||||
guard !Task.isCancelled else { return }
|
||||
await SessionCache.saveEvents(events, for: id)
|
||||
}
|
||||
}
|
||||
|
||||
/// Flush the open transcript to disk immediately (on close), cancelling any pending debounce.
|
||||
private func flushOpenTranscript(_ id: SessionID, _ events: [AgentEvent]) {
|
||||
transcriptPersistTask?.cancel()
|
||||
guard !demoMode, !events.isEmpty else { return }
|
||||
Task { await SessionCache.saveEvents(events, for: id) }
|
||||
}
|
||||
|
||||
/// The flat sessions list: every connected Mac's sessions, deduped by id (ids are globally
|
||||
/// unique). Views handle sorting/grouping.
|
||||
private func aggregatedSessions() -> [WireSessionSummary] {
|
||||
@@ -713,6 +780,9 @@ final class RemoteStore: ObservableObject {
|
||||
openSessionHostID = nil
|
||||
openEvents = []; openApprovals = []; openDiff = nil; diffLoading = false
|
||||
connectivity = .unpaired
|
||||
// No Mac left whose history to hold — drop the offline cache too.
|
||||
cachedSummaries = []
|
||||
SessionCache.clear()
|
||||
sessions = []
|
||||
dashboard = .empty
|
||||
// Nothing left to dial — spin the embedded Tailscale node down if it was running.
|
||||
@@ -770,6 +840,9 @@ final class RemoteStore: ObservableObject {
|
||||
diffLoading = false
|
||||
markOpened(sessionID)
|
||||
if demoMode { seedDemoTranscript(sessionID); return }
|
||||
// Seed from the on-device cache so the transcript shows instantly — including fully offline,
|
||||
// where the subscribe below is a no-op. Live events merge on top by seq (dedup).
|
||||
loadCachedTranscript(sessionID)
|
||||
// Tell the owning connection so it forwards the snapshot/events (and dedupes them), re-derive
|
||||
// the context host (capabilities/scope/catalog/connectivity now follow it), then subscribe.
|
||||
connection(owningSession: sessionID)?.openSessionID = sessionID
|
||||
@@ -913,6 +986,8 @@ final class RemoteStore: ObservableObject {
|
||||
connection(owningSession: target)?.openSessionID = nil
|
||||
openSessionID = nil
|
||||
openSessionHostID = nil
|
||||
// Persist the final transcript before clearing it, so it's warm for the next open / offline.
|
||||
flushOpenTranscript(target, openEvents)
|
||||
openEvents = []
|
||||
openApprovals = []
|
||||
openDiff = nil
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import Foundation
|
||||
import NucleicProtocol
|
||||
|
||||
/// An on-device cache of the phone's recent chat history — the session list and the transcripts of
|
||||
/// the sessions the user has actually opened — so the app can show a read-only history while it's
|
||||
/// disconnected from every Mac host (UX_IOS offline). It is a *projection cache*, never canonical
|
||||
/// state: a live host's data always supersedes it, and it's written straight from the same wire
|
||||
/// types the sync stream delivers (`SessionSummary` / `AgentEvent`), so nothing is invented.
|
||||
///
|
||||
/// Stored as JSON files under Application Support (durable across launches, unlike Caches, which the
|
||||
/// OS may purge under storage pressure):
|
||||
///
|
||||
/// NucleicSessionCache/summaries.json — the merged session list, recency-capped + pruned
|
||||
/// NucleicSessionCache/transcripts/<id>.json — one opened session's event tail
|
||||
///
|
||||
/// File I/O is synchronous, but the write/read paths hop onto a utility `Task.detached` so nothing
|
||||
/// touches disk on the main actor; the wire types are `Sendable`, so the arrays cross the boundary
|
||||
/// cleanly. Per-session transcript files keep an append-heavy live session from rewriting the whole
|
||||
/// history on every event, and cap the tail so a long run can't grow unbounded.
|
||||
enum SessionCache {
|
||||
/// Keep the most-recently-updated N sessions' summaries; older ones (and their transcript
|
||||
/// files) are pruned on every save so the cache stays bounded.
|
||||
private static let sessionLimit = 50
|
||||
/// Keep the newest N events per session — the tail is what a returning reader wants, and it
|
||||
/// bounds a long-running session's file.
|
||||
private static let eventLimit = 1500
|
||||
|
||||
// MARK: - Paths
|
||||
|
||||
private static var root: URL? {
|
||||
guard let base = try? FileManager.default.url(
|
||||
for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
|
||||
else { return nil }
|
||||
return base.appendingPathComponent("NucleicSessionCache", isDirectory: true)
|
||||
}
|
||||
private static var summariesURL: URL? { root?.appendingPathComponent("summaries.json") }
|
||||
private static var transcriptsDir: URL? {
|
||||
root?.appendingPathComponent("transcripts", isDirectory: true)
|
||||
}
|
||||
private static func transcriptURL(_ id: SessionID) -> URL? {
|
||||
transcriptsDir?.appendingPathComponent(transcriptFileName(id))
|
||||
}
|
||||
private static func transcriptFileName(_ id: SessionID) -> String {
|
||||
// Session ids are UUID strings today, but sanitize defensively so an id can never escape
|
||||
// the cache directory or collide with the summaries file.
|
||||
let allowed = Set("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_")
|
||||
return String(id.rawValue.map { allowed.contains($0) ? $0 : "_" }) + ".json"
|
||||
}
|
||||
|
||||
// MARK: - Summaries
|
||||
|
||||
/// Persist the recent session list (recency-capped) and prune transcript files whose session is
|
||||
/// no longer cached. Off the main actor.
|
||||
static func saveSummaries(_ summaries: [WireSessionSummary]) async {
|
||||
await Task.detached(priority: .utility) { writeSummaries(summaries) }.value
|
||||
}
|
||||
|
||||
/// Load the cached session list — called once at launch to seed the list before any host
|
||||
/// connects. Small (≤ `sessionLimit`), so a synchronous read is fine.
|
||||
static func loadSummaries() -> [WireSessionSummary] {
|
||||
guard let url = summariesURL, let data = try? Data(contentsOf: url),
|
||||
let list = try? JSONDecoder().decode([WireSessionSummary].self, from: data)
|
||||
else { return [] }
|
||||
return list
|
||||
}
|
||||
|
||||
private static func writeSummaries(_ summaries: [WireSessionSummary]) {
|
||||
guard let root, let url = summariesURL, let dir = transcriptsDir else { return }
|
||||
try? FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
|
||||
let kept = Array(summaries.sorted { $0.updatedAt > $1.updatedAt }.prefix(sessionLimit))
|
||||
if let data = try? JSONEncoder().encode(kept) { try? data.write(to: url, options: .atomic) }
|
||||
// Drop transcript files for sessions that fell out of the recent set.
|
||||
let keep = Set(kept.map { transcriptFileName($0.sessionID) })
|
||||
if let files = try? FileManager.default.contentsOfDirectory(
|
||||
at: dir, includingPropertiesForKeys: nil) {
|
||||
for file in files where !keep.contains(file.lastPathComponent) {
|
||||
try? FileManager.default.removeItem(at: file)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Transcripts
|
||||
|
||||
/// Persist one session's transcript (newest `eventLimit` events by seq). Off the main actor.
|
||||
static func saveEvents(_ events: [AgentEvent], for id: SessionID) async {
|
||||
await Task.detached(priority: .utility) { writeEvents(events, id) }.value
|
||||
}
|
||||
|
||||
/// Load a session's cached transcript — used to seed the open transcript instantly, before the
|
||||
/// host's snapshot/backfill arrives (and merges on top by seq). Off the main actor.
|
||||
static func loadEvents(_ id: SessionID) async -> [AgentEvent] {
|
||||
await Task.detached(priority: .utility) { readEvents(id) }.value
|
||||
}
|
||||
|
||||
private static func writeEvents(_ events: [AgentEvent], _ id: SessionID) {
|
||||
guard !events.isEmpty, let dir = transcriptsDir, let url = transcriptURL(id) else { return }
|
||||
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||
let sorted = events.sorted { $0.seq < $1.seq }
|
||||
let tail = sorted.count > eventLimit ? Array(sorted.suffix(eventLimit)) : sorted
|
||||
if let data = try? JSONEncoder().encode(tail) { try? data.write(to: url, options: .atomic) }
|
||||
}
|
||||
|
||||
private static func readEvents(_ id: SessionID) -> [AgentEvent] {
|
||||
guard let url = transcriptURL(id), let data = try? Data(contentsOf: url),
|
||||
let events = try? JSONDecoder().decode([AgentEvent].self, from: data)
|
||||
else { return [] }
|
||||
return events
|
||||
}
|
||||
|
||||
// MARK: - Clear
|
||||
|
||||
/// Drop the whole cache — on full unpair, when there's no longer any Mac whose history this
|
||||
/// device should hold.
|
||||
static func clear() {
|
||||
guard let root else { return }
|
||||
try? FileManager.default.removeItem(at: root)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user