Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
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
6 changes: 4 additions & 2 deletions apps/mobile/app.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -199,7 +199,7 @@ const widgetsPlugin: NonNullable<ExpoConfig["plugins"]>[number] = [
supportedFamilies: ["systemSmall", "systemMedium", "accessoryRectangular"],
// Embeds the layout so the gallery preview and a new widget render
// before the app has run once; the app replaces it on publish.
initialLayout: "./src/widgets/AgentActivityWidget.tsx",
initialLayout: "./src/widgets/AgentActivity.tsx",
},
},
],
Expand Down Expand Up @@ -450,7 +450,9 @@ const config: ExpoConfig = {
// Compile out the unused pedometer to avoid an iOS motion purpose string.
["expo-sensors", { motionPermission: false }],
// Mods run in reverse order; expo-widgets must create and wipe the target before assets are added.
...(!isIosPersonalTeamBuild ? ["./plugins/withWidgetLogoAsset.cjs", widgetsPlugin] : []),
...(!isIosPersonalTeamBuild
? ["./plugins/withWidgetLogoAsset.cjs", "./plugins/withAgentWidgetRefresh.cjs", widgetsPlugin]
: []),
"./plugins/withAndroidNetworkSecurityConfig.cjs",
"./plugins/withAndroidGradleHeap.cjs",
"./plugins/withAndroidInputBackground.cjs",
Expand Down
10 changes: 9 additions & 1 deletion apps/mobile/fingerprint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,5 +14,13 @@ if (!majorVersion) {

module.exports = {
nativeModuleSourceType: "files",
extraSources: [{ type: "contents", id: "appMajorVersion", contents: majorVersion }],
extraSources: [
{ type: "contents", id: "appMajorVersion", contents: majorVersion },
...[
"plugins/withAgentWidgetRefresh.cjs",
"plugins/widget/AgentWidgetTimelineProvider.swift",
"plugins/widget/AgentWidgetState.swift",
"modules/t3-native-controls/ios/AgentWidgetCredential.swift",
].map((filePath) => ({ type: "file", filePath, reasons: ["agentWidgetRefresh"] })),
],
};
Original file line number Diff line number Diff line change
Expand Up @@ -94,9 +94,11 @@ internal class ActivityPresentation(data: Map<String, String>, private val activ
?: rows.count { ActivityPhase.forStatus(it.status)?.finished != true }
private val attentionCount = data["activity_attention_count"]?.toIntOrNull()?.coerceAtLeast(0)
?: rows.count { ActivityPhase.forStatus(it.status)?.needsUser == true }
private val workingCount = data["activity_working_count"]?.toIntOrNull()?.coerceAtLeast(0)
?: (activeCount - attentionCount).coerceAtLeast(0)
private val failedCount = rows.count { it.status == ActivityPhase.FAILED.status }
val threadCount =
activeCount + rows.count { ActivityPhase.forStatus(it.status)?.finished == true }
val threadCount = data["activity_total_count"]?.toIntOrNull()?.coerceAtLeast(0)
?: (activeCount + rows.count { ActivityPhase.forStatus(it.status)?.finished == true })
private val singleProject = rows.map { it.project }.distinct().size == 1
private val legacyBody = (0..4).mapNotNull { data["activity_line_$it"]?.take(300) }
.takeIf { it.isNotEmpty() }?.joinToString("\n")
Expand All @@ -109,7 +111,8 @@ internal class ActivityPresentation(data: Map<String, String>, private val activ
attentionCount == 1 -> "1 needs you"
attentionCount > 1 -> "$attentionCount need you"
activeCount > 0 && failedCount > 0 -> "$failedCount failed"
activeCount > 0 -> "$activeCount working"
workingCount > 0 -> "$workingCount working"
activeCount > 0 -> "$activeCount active"
failedCount > 0 -> "Finished, $failedCount failed"
else -> "All finished"
}
Expand All @@ -136,7 +139,11 @@ internal class ActivityPresentation(data: Map<String, String>, private val activ
builder.setSubText(
listOfNotNull(
hero!!.project.takeIf { singleProject && it.isNotBlank() },
if (activeCount > 0) "$activeCount active" else "$threadCount threads"
when {
workingCount > 0 -> "$workingCount working"
activeCount > 0 -> "$activeCount active"
else -> "$threadCount threads"
}
).joinToString(" · ")
)
}
Expand Down
105 changes: 105 additions & 0 deletions apps/mobile/modules/t3-native-controls/ios/AgentWidgetCredential.swift
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 apps/mobile/modules/t3-native-controls/ios/AgentWidgetStorage.swift
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
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
}
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
}
}
Loading
Loading