Repository navigation
fix(mobile): keep agent widgets updated in the background #14861
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
jakeleventhal
wants to merge
11
commits into
pingdotgg:main
Choose a base branch
from
jakeleventhal:t3code/agent-widget-background-refresh-v2
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.
+6,540
−236
Open
Changes from all commits
Commits
Show all changes
11 commits
Select commit
Hold shift + click to select a range
bcae605
fix(mobile): iOS home screen widgets render instead of a containerBac…
jakeleventhal 545cb1c
fix(mobile): initialize widget layouts and clear previous account act…
juliusmarminge ff43814
fix(mobile): expire agent activity widget timelines
jakeleventhal 33d293e
fix(mobile): encode unavailable widget counts for native storage
jakeleventhal d93a68d
fix(mobile): refresh agent widgets while the app is closed
jakeleventhal 91c6f6f
fix(ci): recognize mobile fingerprint configuration
jakeleventhal 877d2d8
fix(mobile): clear and revoke signed-out widget state
jakeleventhal 3332575
fix(mobile): isolate widget credentials in the shared keychain
jakeleventhal e51c20f
fix(mobile): align shared keychain query for native lint
jakeleventhal bb12fb2
fix(mobile): project widget activity from the new orchestrator
jakeleventhal a3260ae
fix(mobile): adapt widget refresh to current upstream
jakeleventhal 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
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
38 changes: 38 additions & 0 deletions
38
apps/mobile/modules/t3-native-controls/ios/AgentWidgetCredential.swift
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,38 @@ | ||
| import Foundation | ||
| import Security | ||
|
|
||
| // Both targets already belong to this app group, which is also a Keychain | ||
| // access group. Keep the bearer credential out of preferences and backups. | ||
| enum AgentWidgetCredential { | ||
| private static var query: [String: Any]? { | ||
| guard let group = Bundle.main.object(forInfoDictionaryKey: "ExpoWidgetsAppGroupIdentifier") as? String else { return nil } | ||
| return [ | ||
| kSecClass as String: kSecClassGenericPassword, | ||
| kSecAttrService as String: "t3-agent-widget", | ||
| kSecAttrAccount as String: "read-capability", | ||
| kSecAttrAccessGroup as String: group, | ||
| ] | ||
| } | ||
|
|
||
| static func read() -> String? { | ||
| guard var query else { return nil } | ||
| query[kSecReturnData as String] = true | ||
| query[kSecMatchLimit as String] = kSecMatchLimitOne | ||
| var result: CFTypeRef? | ||
| guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess, | ||
| let data = result as? Data else { return nil } | ||
| return String(data: data, encoding: .utf8) | ||
| } | ||
|
|
||
| static func store(_ token: String) -> Bool { | ||
| guard var query else { return false } | ||
| query[kSecValueData as String] = Data(token.utf8) | ||
| query[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly | ||
| return SecItemAdd(query as CFDictionary, nil) == errSecSuccess | ||
| } | ||
|
|
||
| static func remove() { | ||
| guard let query else { return } | ||
| SecItemDelete(query as CFDictionary) | ||
| } | ||
| } | ||
64 changes: 64 additions & 0 deletions
64
apps/mobile/modules/t3-native-controls/ios/T3AgentWidgetConfiguration.swift
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,64 @@ | ||
| import Foundation | ||
| import Security | ||
| import WidgetKit | ||
|
|
||
| // Shared with the WidgetKit extension, which runs independently of React Native. | ||
| enum T3AgentWidgetConfiguration { | ||
| static var defaults: UserDefaults? { | ||
| guard let group = Bundle.main.object(forInfoDictionaryKey: "ExpoWidgetsAppGroupIdentifier") as? String else { return nil } | ||
| return UserDefaults(suiteName: group) | ||
| } | ||
|
|
||
| static func token(identity: String) -> String? { | ||
| guard let defaults else { return nil } | ||
| if defaults.string(forKey: "t3_agent_widget_identity") == identity, | ||
| let token = AgentWidgetCredential.read() { return token } | ||
| clear() | ||
| var bytes = [UInt8](repeating: 0, count: 32) | ||
| guard SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes) == errSecSuccess else { return nil } | ||
| let token = bytes.map { String(format: "%02x", $0) }.joined() | ||
| guard AgentWidgetCredential.store(token) else { return nil } | ||
| defaults.set(identity, forKey: "t3_agent_widget_identity") | ||
| return token | ||
| } | ||
|
|
||
| static func configure(url: String, token: String) { | ||
| guard let defaults, AgentWidgetCredential.read() == token else { return } | ||
| defaults.set(url, forKey: "t3_agent_widget_url") | ||
| WidgetCenter.shared.reloadTimelines(ofKind: "AgentActivity") | ||
| } | ||
|
|
||
| static func observe(props: String) { | ||
| defaults?.set(props, forKey: "t3_agent_widget_local_observation") | ||
| } | ||
|
|
||
| static func clear() { | ||
| guard let defaults else { return } | ||
| let request = clearStoredState(in: defaults) | ||
| WidgetCenter.shared.reloadTimelines(ofKind: "AgentActivity") | ||
| // Clerk may already be signed out. The capability can revoke itself without | ||
| // needing the old account's session token; normal device cleanup still runs. | ||
| if let request { | ||
| URLSession.shared.dataTask(with: request) { _, response, error in | ||
| if error != nil || (response as? HTTPURLResponse)?.statusCode != 200 { | ||
| NSLog("Agent widget capability revocation failed") | ||
| } | ||
| }.resume() | ||
| } | ||
| } | ||
|
|
||
| static func clearStoredState(in defaults: UserDefaults) -> URLRequest? { | ||
| let url = defaults.string(forKey: "t3_agent_widget_url").flatMap(URL.init(string:)) | ||
| // Revoke the pre-Keychain development credential when upgrading this branch. | ||
| let token = AgentWidgetCredential.read() ?? defaults.string(forKey: "t3_agent_widget_token") | ||
| AgentWidgetCredential.remove() | ||
| for key in ["t3_agent_widget_identity", "t3_agent_widget_token", "t3_agent_widget_url", "t3_agent_widget_local_observation", "__expo_widgets_AgentActivity_timeline"] { | ||
| defaults.removeObject(forKey: key) | ||
| } | ||
| guard let url, let token else { return nil } | ||
| var request = URLRequest(url: url, timeoutInterval: 10) | ||
| request.httpMethod = "DELETE" | ||
| request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") | ||
| return request | ||
| } | ||
| } |
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
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,66 @@ | ||
| import Foundation | ||
|
|
||
| // A confirmed relay read renews relay observations. Direct-only rows keep the | ||
| // app's original deadline; an empty relay never claims that they finished. | ||
| enum AgentWidgetState { | ||
| static func timeline(aggregate: [String: Any]?, environmentIds: [String], localObservation: [String: Any]?, now: Date) -> [[String: Any]] { | ||
| let freshnessDeadline = now.addingTimeInterval(60 * 60) | ||
| var base = aggregate ?? [ | ||
| "title": "T3 Code", "subtitle": "No active agents", "activeCount": 0, | ||
| "updatedAt": ISO8601DateFormatter().string(from: now), "activities": [[String: Any]]() | ||
| ] | ||
| base = base.filter { !($0.value is NSNull) } | ||
| base["isExpired"] = false | ||
| base["isStale"] = false | ||
| base["expiresAt"] = freshnessDeadline.timeIntervalSince1970 * 1000 | ||
| let covered = Set(environmentIds) | ||
| let relayRows = base["activities"] as? [[String: Any]] ?? [] | ||
| let localRows = (localObservation?["activities"] as? [[String: Any]] ?? []).filter { | ||
| guard let environmentId = $0["environmentId"] as? String else { return false } | ||
| return !covered.contains(environmentId) | ||
| } | ||
| let localDeadline = (localObservation?["expiresAt"] as? NSNumber).map { | ||
| Date(timeIntervalSince1970: $0.doubleValue / 1000) | ||
| } | ||
| func merged(at date: Date) -> [String: Any] { | ||
| var props = base | ||
| let directRows = localRows.map { row in | ||
| localDeadline.map { $0 > date } == true ? row : stale(row) | ||
| } | ||
| props["activities"] = relayRows + directRows | ||
| let unavailable = directRows.contains { $0["phase"] as? String == "stale" } | ||
| let directCount = directRows.filter { isUnfinished($0) }.count | ||
| props["activeCount"] = unavailable ? -1 : (base["activeCount"] as? Int ?? 0) + directCount | ||
| return props | ||
| } | ||
| var entries = [entry(at: now, props: merged(at: now))] | ||
| if let localDeadline, localDeadline > now, localDeadline < freshnessDeadline, localRows.contains(where: isUnfinished) { | ||
| entries.append(entry(at: localDeadline, props: merged(at: localDeadline))) | ||
| } | ||
| var expired = merged(at: freshnessDeadline) | ||
| expired["activities"] = (expired["activities"] as? [[String: Any]] ?? []).map(stale) | ||
| expired["activeCount"] = -1 | ||
| expired["isExpired"] = true | ||
| expired["subtitle"] = "Open T3 to refresh" | ||
| entries.append(entry(at: freshnessDeadline, props: expired)) | ||
| return entries | ||
| } | ||
|
|
||
| private static func isUnfinished(_ row: [String: Any]) -> Bool { | ||
| let phase = row["phase"] as? String | ||
| return phase != "completed" && phase != "failed" && phase != "stale" | ||
| } | ||
|
|
||
| private static func stale(_ row: [String: Any]) -> [String: Any] { | ||
| let phase = row["phase"] as? String | ||
| guard phase != "completed" && phase != "failed" else { return row } | ||
| var result = row | ||
| result["phase"] = "stale" | ||
| result["status"] = "Out of date" | ||
| return result | ||
| } | ||
|
|
||
| private static func entry(at date: Date, props: [String: Any]) -> [String: Any] { | ||
| ["timestamp": Int(date.timeIntervalSince1970 * 1000), "props": props] | ||
| } | ||
| } |
80 changes: 80 additions & 0 deletions
80
apps/mobile/plugins/widget/AgentWidgetTimelineProvider.swift
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,80 @@ | ||
| import Foundation | ||
| import WidgetKit | ||
| import SwiftUI | ||
| internal import ExpoWidgets | ||
|
|
||
| // Fetch from the widget extension, not the suspended app's JS runtime. The | ||
| // capability can only read this install's owner's linked agent activity. | ||
| struct AgentWidgetTimelineProvider: TimelineProvider { | ||
| typealias Entry = WidgetsTimelineEntry | ||
| private let cached = WidgetsTimelineProvider(name: "AgentActivity") | ||
|
|
||
| func placeholder(in context: Context) -> Entry { cached.placeholder(in: context) } | ||
| func getSnapshot(in context: Context, completion: @escaping @Sendable (Entry) -> Void) { | ||
| cached.getTimeline(in: context) { timeline in | ||
| completion(timeline.entries.last(where: { $0.date <= Date() }) ?? cached.placeholder(in: context)) | ||
| } | ||
| } | ||
|
|
||
| func getTimeline(in context: Context, completion: @escaping @Sendable (Timeline<Entry>) -> Void) { | ||
| AgentWidgetNetwork.refresh { _ in | ||
| cached.getTimeline(in: context) { timeline in | ||
| // Ask for another read even if no agent state changes. WidgetKit controls | ||
| // the actual schedule; pushes complement this, they don't replace it. | ||
| completion(Timeline(entries: timeline.entries, policy: .after(Date().addingTimeInterval(5 * 60)))) | ||
| } | ||
| } | ||
| } | ||
| } | ||
|
|
||
| enum AgentWidgetNetwork { | ||
| static var defaults: UserDefaults? { | ||
| guard let group = Bundle.main.object(forInfoDictionaryKey: "ExpoWidgetsAppGroupIdentifier") as? String else { return nil } | ||
| return UserDefaults(suiteName: group) | ||
| } | ||
|
|
||
| static func refresh(completion: @escaping @Sendable (Bool) -> Void) { | ||
| guard let defaults, let rawURL = defaults.string(forKey: "t3_agent_widget_url"), | ||
| let token = AgentWidgetCredential.read(), | ||
| var components = URLComponents(string: rawURL) else { completion(false); return } | ||
| if let pushToken = defaults.string(forKey: "t3_agent_widget_push_token") { | ||
| components.queryItems = [URLQueryItem(name: "pushToken", value: pushToken)] | ||
| } | ||
| guard let url = components.url else { completion(false); return } | ||
| var request = URLRequest(url: url, cachePolicy: .reloadIgnoringLocalCacheData, timeoutInterval: 10) | ||
| request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") | ||
| URLSession.shared.dataTask(with: request) { data, response, _ in | ||
| // Sign-out / another account's configuration wins over an in-flight read. | ||
| guard AgentWidgetCredential.read() == token, | ||
| defaults.string(forKey: "t3_agent_widget_url") == rawURL else { completion(false); return } | ||
| guard let http = response as? HTTPURLResponse, http.statusCode == 200, | ||
| let data, let body = try? JSONSerialization.jsonObject(with: data) as? [String: Any], | ||
| body.keys.contains("aggregate") else { completion(false); return } | ||
| let aggregate = body["aggregate"] as? [String: Any] | ||
| guard aggregate != nil || body["aggregate"] is NSNull else { completion(false); return } | ||
| let localObservation = defaults.string(forKey: "t3_agent_widget_local_observation").flatMap { | ||
| $0.data(using: .utf8).flatMap { try? JSONSerialization.jsonObject(with: $0) as? [String: Any] } | ||
| } | ||
| let environmentIds = body["environmentIds"] as? [String] ?? (aggregate?["activities"] as? [[String: Any]] ?? []).compactMap { $0["environmentId"] as? String } | ||
| defaults.set(AgentWidgetState.timeline(aggregate: aggregate, environmentIds: environmentIds, | ||
| localObservation: localObservation, now: Date()), forKey: "__expo_widgets_AgentActivity_timeline") | ||
| completion(true) | ||
| }.resume() | ||
| } | ||
| } | ||
|
|
||
| @available(iOS 26.0, *) | ||
| struct AgentWidgetPushHandler: WidgetPushHandler { | ||
| func pushTokenDidChange(_ pushInfo: WidgetPushInfo, widgets: [WidgetInfo]) { | ||
| let token = widgets.isEmpty ? "" : pushInfo.token.map { String(format: "%02x", $0) }.joined() | ||
| AgentWidgetNetwork.defaults?.set(token, forKey: "t3_agent_widget_push_token") | ||
| AgentWidgetNetwork.refresh { _ in WidgetCenter.shared.reloadTimelines(ofKind: "AgentActivity") } | ||
| } | ||
| } | ||
|
|
||
| extension WidgetConfiguration { | ||
| func agentWidgetPushHandler() -> some WidgetConfiguration { | ||
| if #available(iOS 26.0, *) { return self.pushHandler(AgentWidgetPushHandler.self) } | ||
| else { return self } | ||
| } | ||
| } |
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,58 @@ | ||
| import Foundation | ||
|
|
||
| let now = Date(timeIntervalSince1970: 1000) | ||
| func row(_ environmentId: String, _ phase: String) -> [String: Any] { | ||
| ["environmentId": environmentId, "phase": phase, "status": phase, "threadTitle": "Test agent"] | ||
| } | ||
| func props(_ entry: [String: Any]) -> [String: Any] { entry["props"] as! [String: Any] } | ||
| let running: [String: Any] = ["activeCount": 1, "updatedAt": "old unchanged observation", "activities": [row("relay", "running")]] | ||
| let local: [String: Any] = ["expiresAt": now.addingTimeInterval(600).timeIntervalSince1970 * 1000, | ||
| "activities": [row("relay", "running"), row("direct", "waiting_for_input"), row("direct", "failed")]] | ||
| let entries = AgentWidgetState.timeline(aggregate: running, environmentIds: ["relay"], localObservation: local, now: now) | ||
| assert(entries.count == 3) | ||
| assert(props(entries[0])["activeCount"] as? Int == 2) | ||
| let directExpired = props(entries[1])["activities"] as! [[String: Any]] | ||
| assert(directExpired.map { $0["phase"] as! String } == ["running", "stale", "failed"]) | ||
| assert(props(entries[1])["isExpired"] as? Bool == false) | ||
| assert(props(entries[1])["activeCount"] as? Int == -1) | ||
| let fullyExpired = props(entries[2])["activities"] as! [[String: Any]] | ||
| assert(fullyExpired.map { $0["phase"] as! String } == ["stale", "stale", "failed"]) | ||
| assert(props(entries[2])["isExpired"] as? Bool == true) | ||
| assert(entries[2]["timestamp"] as? Int == 4_600_000) | ||
|
|
||
| // A successful unchanged read renews freshness without inventing a state update. | ||
| let renewed = AgentWidgetState.timeline(aggregate: running, environmentIds: ["relay"], localObservation: nil, now: now.addingTimeInterval(300)) | ||
| assert(renewed.last?["timestamp"] as? Int == 4_900_000) | ||
| assert(props(renewed[0])["updatedAt"] as? String == "old unchanged observation") | ||
|
|
||
| // An authoritative empty relay clears its own rows and retains direct observations. | ||
| let empty = AgentWidgetState.timeline(aggregate: nil, environmentIds: ["relay"], localObservation: local, now: now) | ||
| assert((props(empty[0])["activities"] as! [[String: Any]]).count == 2) | ||
| assert(props(empty[0])["activeCount"] as? Int == 1) | ||
| let terminals: [String: Any] = ["activeCount": 0, "optional": NSNull(), "activities": [row("relay", "completed"), row("relay", "failed")]] | ||
| let final = AgentWidgetState.timeline(aggregate: terminals, environmentIds: ["relay"], localObservation: nil, now: now) | ||
| assert((props(final.last!)["activities"] as! [[String: Any]]).map { $0["phase"] as! String } == ["completed", "failed"]) | ||
| assert(PropertyListSerialization.propertyList(final, isValidFor: .binary)) | ||
| print("AgentWidgetState: background renewal, source reconciliation, expiration, and native storage passed") | ||
|
|
||
| // Native background reads can replace JS's last idle publication. Sign-out must | ||
| // remove that timeline even when JS would deduplicate its next idle publication. | ||
| let suite = "t3-widget-signout-test-\(UUID().uuidString)" | ||
| let defaults = UserDefaults(suiteName: suite)! | ||
| defer { defaults.removePersistentDomain(forName: suite) } | ||
| defaults.set("previous-account", forKey: "t3_agent_widget_identity") | ||
| defaults.set("test-capability", forKey: "t3_agent_widget_token") | ||
| defaults.set("https://relay.test/v1/widget/agent-activity", forKey: "t3_agent_widget_url") | ||
| defaults.set("previous rows", forKey: "t3_agent_widget_local_observation") | ||
| defaults.set(entries, forKey: "__expo_widgets_AgentActivity_timeline") | ||
| defaults.set("install-push-token", forKey: "t3_agent_widget_push_token") | ||
| let revocation = T3AgentWidgetConfiguration.clearStoredState(in: defaults) | ||
| assert(revocation?.httpMethod == "DELETE") | ||
| assert(revocation?.url?.absoluteString == "https://relay.test/v1/widget/agent-activity") | ||
| assert(revocation?.value(forHTTPHeaderField: "Authorization") == "Bearer test-capability") | ||
| for key in ["t3_agent_widget_identity", "t3_agent_widget_token", "t3_agent_widget_url", "t3_agent_widget_local_observation", "__expo_widgets_AgentActivity_timeline"] { | ||
| assert(defaults.object(forKey: key) == nil) | ||
| } | ||
| assert(defaults.string(forKey: "t3_agent_widget_push_token") == "install-push-token") | ||
| assert(T3AgentWidgetConfiguration.clearStoredState(in: defaults) == nil) | ||
| print("Agent widget sign-out: cached rows removed and previous capability revocation prepared") |
Oops, something went wrong.
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.
🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win
Make
storehandle an existing Keychain item.SecItemAddreturnserrSecDuplicateItemwhen an item already exists for this service and account.token(identity:)callsclear()first, andclear()callsremove().remove()ignores theSecItemDeletestatus. Suppose the delete fails, or the extension'sread()fails while the item still exists, for example because of a protection-class mismatch. In that case,storereturnsfalseon every attempt, and the widget credential can never register. Delete the item before adding it, or fall back toSecItemUpdatewhen the status iserrSecDuplicateItem.Proposed fix
📝 Committable suggestion
🤖 Prompt for AI Agents