Repository navigation
fix(mobile): keep agent widgets updated in the background #35318
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
15
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.
+7,742
−947
Open
Changes from all commits
Commits
Show all changes
15 commits
Select commit
Hold shift + click to select a range
9298d95
fix(mobile): keep agent widgets updated in the background
jakeleventhal 4acf036
fix(mobile): keep widget placeholders and stale-only summaries accurate
maria-rcks 7818fb8
fix: snoozed awareness rows and superseded widget reads
maria-rcks 5540476
fix(mobile): preserve widget integration across main rebase
jakeleventhal 27cb16d
fix(mobile): retain delegated approvals and revoked widget access
jakeleventhal c172395
fix(mobile): retain coverage for direct work beyond the widget row limit
jakeleventhal 6506eae
fix(relay): use the canonical widget revocation limiter service key
jakeleventhal 7695f6d
fix(mobile): reconcile widget refresh and delivery races
jakeleventhal afe3309
fix(mobile): reconcile widget relay rotation and capped observations
jakeleventhal 4d84b26
fix(mobile): preserve widget cleanup and observation state
jakeleventhal c8ab3ac
fix(widget): preserve sign-out and future expiry timelines
jakeleventhal 34c01ab
fix(mobile): hash widget registration tokens and preserve relay fresh…
jakeleventhal 7dcb662
fix(mobile): retain exact widget freshness deadlines
jakeleventhal db2a154
merge: reconcile widget background refresh with main
maria-rcks 26d84d4
fix: preserve widget awareness integration after main merge
maria-rcks 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
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
105 changes: 105 additions & 0 deletions
105
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,105 @@ | ||
| import Foundation | ||
| import Security | ||
| import CryptoKit | ||
|
|
||
| // 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 func query(account: String) -> [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: account, | ||
| kSecAttrAccessGroup as String: group, | ||
| ] | ||
| } | ||
|
|
||
| enum Lookup: Equatable { | ||
| case available(String) | ||
| case missing | ||
| case unavailable | ||
| } | ||
|
|
||
| static func read(account: String = "read-capability") -> String? { | ||
| guard case let .available(token) = lookup(account: account) else { return nil } | ||
| return token | ||
| } | ||
|
|
||
| static func lookup(account: String = "read-capability") -> Lookup { | ||
| guard var query = query(account: account) else { return .unavailable } | ||
| query[kSecReturnData as String] = true | ||
| query[kSecMatchLimit as String] = kSecMatchLimitOne | ||
| var result: CFTypeRef? | ||
| let status = SecItemCopyMatching(query as CFDictionary, &result) | ||
| if status == errSecItemNotFound { return .missing } | ||
| guard status == errSecSuccess, let data = result as? Data, | ||
| let token = String(data: data, encoding: .utf8) else { return .unavailable } | ||
| return .available(token) | ||
| } | ||
|
|
||
| static func store(_ token: String, account: String = "read-capability") -> Bool { | ||
| guard var query = query(account: account) else { return false } | ||
| query[kSecValueData as String] = Data(token.utf8) | ||
| query[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly | ||
| var status = SecItemAdd(query as CFDictionary, nil) | ||
| if status == errSecDuplicateItem, let base = self.query(account: account) { | ||
| status = SecItemUpdate(base as CFDictionary, [ | ||
| kSecValueData as String: Data(token.utf8), | ||
| kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly, | ||
| ] as CFDictionary) | ||
| } | ||
| return status == errSecSuccess | ||
| } | ||
|
|
||
| static func remove(account: String = "read-capability") { | ||
| guard let query = query(account: account) else { return } | ||
| SecItemDelete(query as CFDictionary) | ||
| } | ||
|
|
||
| static func preserveRevocation(_ request: URLRequest) -> Bool { | ||
| guard let revocation = Revocation(request: request), | ||
| let data = try? JSONEncoder().encode(revocation), | ||
| let value = String(data: data, encoding: .utf8) else { return false } | ||
| return store(value, account: revocation.account) | ||
| } | ||
|
|
||
| static func pendingRevocations() -> [Revocation] { | ||
| guard var query = query(account: "") else { return [] } | ||
| query.removeValue(forKey: kSecAttrAccount as String) | ||
| query[kSecReturnAttributes as String] = true | ||
| query[kSecReturnData as String] = true | ||
| query[kSecMatchLimit as String] = kSecMatchLimitAll | ||
| var result: CFTypeRef? | ||
| guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess, | ||
| let items = result as? [[String: Any]] else { return [] } | ||
| return items.compactMap { item in | ||
| guard (item[kSecAttrAccount as String] as? String)?.hasPrefix("revoke-") == true, | ||
| let data = item[kSecValueData as String] as? Data else { return nil } | ||
| return try? JSONDecoder().decode(Revocation.self, from: data) | ||
| } | ||
| } | ||
|
|
||
| struct Revocation: Codable { | ||
| let url: URL | ||
| let token: String | ||
|
|
||
| init?(request: URLRequest) { | ||
| guard let url = request.url, let header = request.value(forHTTPHeaderField: "Authorization"), | ||
| header.hasPrefix("Bearer ") else { return nil } | ||
| self.url = url | ||
| self.token = String(header.dropFirst(7)) | ||
| } | ||
|
|
||
| var account: String { | ||
| "revoke-" + SHA256.hash(data: Data((url.absoluteString + "\n" + token).utf8)).map { String(format: "%02x", $0) }.joined() | ||
| } | ||
|
|
||
| var request: URLRequest { | ||
| var request = URLRequest(url: url, timeoutInterval: 10) | ||
| request.httpMethod = "DELETE" | ||
| request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") | ||
| return request | ||
| } | ||
| } | ||
| } |
168 changes: 168 additions & 0 deletions
168
apps/mobile/modules/t3-native-controls/ios/AgentWidgetStorage.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,168 @@ | ||
| import Foundation | ||
| import Darwin | ||
|
|
||
| enum AgentWidgetStorage { | ||
| static let timelineKey = "__expo_widgets_AgentActivity_timeline" | ||
| static let generationKey = "t3_agent_widget_generation" | ||
|
|
||
| static func withLock<T>(_ body: (UserDefaults) -> T) -> T? { | ||
| guard let group = Bundle.main.object(forInfoDictionaryKey: "ExpoWidgetsAppGroupIdentifier") as? String, | ||
| let directory = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: group), | ||
| let defaults = UserDefaults(suiteName: group) else { return nil } | ||
| return withLock(at: directory.appendingPathComponent("agent-widget.lock"), defaults: defaults, body) | ||
| } | ||
|
|
||
| // App and extension must synchronize preferences inside the same OS lock. | ||
| static func withLock<T>(at url: URL, defaults: UserDefaults, _ body: (UserDefaults) -> T) -> T? { | ||
| let fd = open(url.path, O_CREAT | O_RDWR, S_IRUSR | S_IWUSR) | ||
| guard fd >= 0 else { return nil } | ||
| defer { close(fd) } | ||
| guard flock(fd, LOCK_EX) == 0 else { return nil } | ||
| defer { flock(fd, LOCK_UN) } | ||
| defaults.synchronize() | ||
| defer { defaults.synchronize() } | ||
| return body(defaults) | ||
| } | ||
|
|
||
| static func generation(in defaults: UserDefaults) -> String { | ||
| if let generation = defaults.string(forKey: generationKey) { return generation } | ||
| let generation = UUID().uuidString | ||
| defaults.set(generation, forKey: generationKey) | ||
| return generation | ||
| } | ||
|
|
||
| static func owns(_ props: [String: Any]?, in defaults: UserDefaults) -> Bool { | ||
| guard let owner = props?[generationKey] as? String else { return false } | ||
| return owner == defaults.string(forKey: generationKey) | ||
| } | ||
|
|
||
| @discardableResult | ||
| static func commit(_ entries: [[String: Any]], generation: String, requestId: String? = nil, in defaults: UserDefaults) -> Bool { | ||
| guard defaults.string(forKey: generationKey) == generation else { return false } | ||
| if let requestId, defaults.string(forKey: "t3_agent_widget_request") != requestId { return false } | ||
| if requestId == nil { _ = beginRefresh(in: defaults) } | ||
| defaults.set(entries.map { entry in | ||
| var entry = entry | ||
| var props = (entry["props"] as? [String: Any] ?? [:]).filter { !($0.value is NSNull) } | ||
| props[generationKey] = generation | ||
| entry["props"] = props | ||
| return entry | ||
| }, forKey: timelineKey) | ||
| return true | ||
| } | ||
|
|
||
| static func retryRevocations() { | ||
| let pending: [AgentWidgetCredential.Revocation] = withLock { defaults in | ||
| prepareRevocations(in: defaults) | ||
| return claimRevocations(AgentWidgetCredential.pendingRevocations(), in: defaults, now: Date()) | ||
| } ?? [] | ||
| for revocation in pending { | ||
| URLSession.shared.dataTask(with: revocation.request) { _, response, error in | ||
| _ = withLock { defaults in | ||
| finishRevocation(account: revocation.account, in: defaults) | ||
| if error == nil, (response as? HTTPURLResponse)?.statusCode == 200 { | ||
| AgentWidgetCredential.remove(account: revocation.account) | ||
| } | ||
| } | ||
| }.resume() | ||
| } | ||
| } | ||
|
|
||
| static func prepareRelay(url: String, in defaults: UserDefaults, readCredential: () -> AgentWidgetCredential.Lookup = { AgentWidgetCredential.lookup() }, storeRevocation: (URLRequest) -> Bool = AgentWidgetCredential.preserveRevocation) -> Bool { | ||
| guard let previous = defaults.string(forKey: "t3_agent_widget_url"), previous != url else { return true } | ||
| clearStoredState(in: defaults, readCredential: readCredential, storeRevocation: storeRevocation) | ||
| return canCreateCredential(in: defaults, credential: readCredential()) | ||
| } | ||
|
|
||
| static func prepareRevocations(in defaults: UserDefaults, readCredential: () -> AgentWidgetCredential.Lookup = { AgentWidgetCredential.lookup() }, storeRevocation: (URLRequest) -> Bool = AgentWidgetCredential.preserveRevocation) { | ||
| if defaults.string(forKey: "t3_agent_widget_identity") == nil, defaults.string(forKey: "t3_agent_widget_url") != nil { | ||
| clearCredential(in: defaults, readCredential: readCredential, storeRevocation: storeRevocation) | ||
| } | ||
| } | ||
|
|
||
| static func claimRevocations(_ pending: [AgentWidgetCredential.Revocation], in defaults: UserDefaults, now: Date) -> [AgentWidgetCredential.Revocation] { | ||
| var claims = defaults.dictionary(forKey: "t3_agent_widget_revocation_claims") as? [String: Double] ?? [:] | ||
| claims = claims.filter { now.timeIntervalSince1970 - $0.value < 30 } | ||
| let claimed = pending.filter { claims[$0.account] == nil } | ||
| for revocation in claimed { claims[revocation.account] = now.timeIntervalSince1970 } | ||
| defaults.set(claims, forKey: "t3_agent_widget_revocation_claims") | ||
| return claimed | ||
| } | ||
|
|
||
| static func finishRevocation(account: String, in defaults: UserDefaults) { | ||
| var claims = defaults.dictionary(forKey: "t3_agent_widget_revocation_claims") as? [String: Double] ?? [:] | ||
| claims.removeValue(forKey: account) | ||
| defaults.set(claims, forKey: "t3_agent_widget_revocation_claims") | ||
| } | ||
|
|
||
| static func canCreateCredential(in defaults: UserDefaults, credential: AgentWidgetCredential.Lookup) -> Bool { | ||
| credential == .missing && defaults.string(forKey: "t3_agent_widget_url") == nil && defaults.string(forKey: "t3_agent_widget_token") == nil | ||
| } | ||
|
|
||
| static func observe(_ props: String, entries: [[String: Any]], in defaults: UserDefaults) -> Bool { | ||
| let previous = defaults.string(forKey: "t3_agent_widget_local_observation") | ||
| defaults.set(props, forKey: "t3_agent_widget_local_observation") | ||
| let storedEntries = defaults.array(forKey: timelineKey) as? [[String: Any]] | ||
| let stored = storedEntries?.first?["props"] as? [String: Any] | ||
| if let previous, let old = presentation(previous), let current = presentation(props), old.isEqual(current), | ||
| let stored, let storedEntries, let next = entries.first?["props"] as? [String: Any], presentation(stored).isEqual(presentation(next)), | ||
| storedEntries.dropFirst().map({ $0["timestamp"] as? Int }) == entries.dropFirst().map({ $0["timestamp"] as? Int }) { | ||
| return false | ||
| } | ||
| return commit(entries, generation: generation(in: defaults), in: defaults) | ||
| } | ||
|
|
||
| private static func presentation(_ props: String) -> NSDictionary? { | ||
| guard let data = props.data(using: .utf8), | ||
| let value = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return nil } | ||
| return presentation(value) | ||
| } | ||
|
|
||
| private static func presentation(_ props: [String: Any]) -> NSDictionary { | ||
| var value = props.filter { !($0.value is NSNull) } | ||
| for key in ["observedActivities", "updatedAt", generationKey] { value.removeValue(forKey: key) } | ||
| if let deadline = value["expiresAt"] as? NSNumber { value["expiresAt"] = floor(deadline.doubleValue / 60_000) } | ||
| if let rows = value["activities"] as? [[String: Any]] { | ||
| value["activities"] = rows.map { $0.filter { $0.key != "updatedAt" } } | ||
| } | ||
| return value as NSDictionary | ||
| } | ||
|
|
||
| static func beginRefresh(in defaults: UserDefaults) -> String { | ||
| let requestId = UUID().uuidString | ||
| defaults.set(requestId, forKey: "t3_agent_widget_request") | ||
| return requestId | ||
| } | ||
|
|
||
| @discardableResult | ||
| static func clearStoredState(in defaults: UserDefaults, readCredential: () -> AgentWidgetCredential.Lookup = { AgentWidgetCredential.lookup() }, storeRevocation: (URLRequest) -> Bool = AgentWidgetCredential.preserveRevocation) -> URLRequest? { | ||
| defaults.set(UUID().uuidString, forKey: AgentWidgetStorage.generationKey) | ||
| for key in ["t3_agent_widget_identity", "t3_agent_widget_local_observation", AgentWidgetStorage.timelineKey] { | ||
| defaults.removeObject(forKey: key) | ||
| } | ||
| return clearCredential(in: defaults, readCredential: readCredential, storeRevocation: storeRevocation) | ||
| } | ||
|
|
||
| @discardableResult | ||
| private static func clearCredential(in defaults: UserDefaults, readCredential: () -> AgentWidgetCredential.Lookup, storeRevocation: (URLRequest) -> Bool) -> 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 credential = readCredential() | ||
| var tokens = Set<String>() | ||
| if case let .available(token) = credential { tokens.insert(token) } | ||
| if let legacy = defaults.string(forKey: "t3_agent_widget_token") { tokens.insert(legacy) } | ||
| guard credential != .unavailable else { return nil } | ||
| var request: URLRequest? | ||
| for token in tokens { | ||
| guard let url else { continue } | ||
| var revoke = URLRequest(url: url, timeoutInterval: 10) | ||
| revoke.httpMethod = "DELETE" | ||
| revoke.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") | ||
| guard storeRevocation(revoke) else { return nil } | ||
| request = revoke | ||
| } | ||
| AgentWidgetCredential.remove() | ||
| for key in ["t3_agent_widget_token", "t3_agent_widget_url"] { defaults.removeObject(forKey: key) } | ||
| return request | ||
| } | ||
| } | ||
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.
Uh oh!
There was an error while loading. Please reload this page.