Merge nucleic/clever-velvet-newt-spy5 into dev
This commit is contained in:
@@ -38,6 +38,11 @@ final class HostConnection {
|
|||||||
/// This host's per-provider install/auth state (remote agent sign-in) — pushed post-hello
|
/// This host's per-provider install/auth state (remote agent sign-in) — pushed post-hello
|
||||||
/// and on every change; feeds Settings ▸ Agent Accounts.
|
/// and on every change; feeds Settings ▸ Agent Accounts.
|
||||||
private(set) var agentAuthStatuses: [WireProviderAuthStatus] = []
|
private(set) var agentAuthStatuses: [WireProviderAuthStatus] = []
|
||||||
|
/// Whether this connection has already taken its post-hello auth snapshot. The snapshot is
|
||||||
|
/// just "how things stand"; every *later* push follows a credential change on that host, which
|
||||||
|
/// is the only way this phone learns a sign-in happened somewhere else. Reset per client, so a
|
||||||
|
/// reconnect's snapshot is never mistaken for a change.
|
||||||
|
private var didReceiveAgentAuthSnapshot = false
|
||||||
/// This host's credential-sealing X25519 public key, from its post-hello `credentialNeeded`
|
/// This host's credential-sealing X25519 public key, from its post-hello `credentialNeeded`
|
||||||
/// push — what the phone seals an API key to (REMOTE_AGENT_LOGIN §8). Nil until pushed.
|
/// push — what the phone seals an API key to (REMOTE_AGENT_LOGIN §8). Nil until pushed.
|
||||||
private(set) var credentialSealingKey: Data?
|
private(set) var credentialSealingKey: Data?
|
||||||
@@ -156,6 +161,10 @@ final class HostConnection {
|
|||||||
var agentLoginChallenge: (WireAgentLoginChallenge) -> Void = { _ in }
|
var agentLoginChallenge: (WireAgentLoginChallenge) -> Void = { _ in }
|
||||||
/// The definitive outcome of this phone's sign-in attempt, keyed by requestID.
|
/// The definitive outcome of this phone's sign-in attempt, keyed by requestID.
|
||||||
var agentLoginResult: (WireAgentLoginResult) -> Void = { _ in }
|
var agentLoginResult: (WireAgentLoginResult) -> Void = { _ in }
|
||||||
|
/// A credential landed on this host *after* the post-hello snapshot — a sign-in completed
|
||||||
|
/// on another device, a mesh mirror, an API key set — carrying the providers that now hold
|
||||||
|
/// one. RemoteStore treats it exactly like this phone's own completed sign-in.
|
||||||
|
var agentAuthRefreshed: ([AgentLoginProvider]) -> Void = { _ in }
|
||||||
/// The phone vault changed under this connection — kinds landed from a
|
/// The phone vault changed under this connection — kinds landed from a
|
||||||
/// `credentialUpdate` or cleared by a mesh-wide deletion. RemoteStore refreshes the
|
/// `credentialUpdate` or cleared by a mesh-wide deletion. RemoteStore refreshes the
|
||||||
/// held-kinds mirror the Settings surface reads.
|
/// held-kinds mirror the Settings surface reads.
|
||||||
@@ -899,8 +908,19 @@ final class HostConnection {
|
|||||||
case .agentAuthStatus(let statuses):
|
case .agentAuthStatus(let statuses):
|
||||||
// Per-provider install/auth state (pushed post-hello and on change) — feeds the
|
// Per-provider install/auth state (pushed post-hello and on change) — feeds the
|
||||||
// Agent Accounts list and the in-chat sign-in affordance.
|
// Agent Accounts list and the in-chat sign-in affordance.
|
||||||
|
let isSnapshot = !didReceiveAgentAuthSnapshot
|
||||||
|
didReceiveAgentAuthSnapshot = true
|
||||||
agentAuthStatuses = statuses
|
agentAuthStatuses = statuses
|
||||||
callbacks.didUpdate()
|
callbacks.didUpdate()
|
||||||
|
// The host only re-pushes this after a credential moved (a sign-in here or on any
|
||||||
|
// other device, a mirrored login, a key entry, a revocation), so a *non-snapshot*
|
||||||
|
// push naming an authenticated provider is this phone's one observable "the login
|
||||||
|
// you were nagged about happened" signal. It has to be the transition, not the flag:
|
||||||
|
// an expired-but-present credential still reads as authenticated.
|
||||||
|
if !isSnapshot {
|
||||||
|
let refreshed = statuses.filter(\.authenticated).map(\.provider)
|
||||||
|
if !refreshed.isEmpty { callbacks.agentAuthRefreshed(refreshed) }
|
||||||
|
}
|
||||||
case .directAnswer(let offer):
|
case .directAnswer(let offer):
|
||||||
directBox?.deliver(.answer(offer))
|
directBox?.deliver(.answer(offer))
|
||||||
case .directGo:
|
case .directGo:
|
||||||
@@ -1284,6 +1304,9 @@ final class HostConnection {
|
|||||||
teardownDirect()
|
teardownDirect()
|
||||||
if let client { Task { await client.disconnect() } }
|
if let client { Task { await client.disconnect() } }
|
||||||
client = nil
|
client = nil
|
||||||
|
// The next connection opens with its own post-hello auth snapshot — arm the latch so that
|
||||||
|
// one isn't read as a credential change.
|
||||||
|
didReceiveAgentAuthSnapshot = false
|
||||||
// A dropped connection loses the in-flight prefetch's remaining chunks — settle it empty
|
// A dropped connection loses the in-flight prefetch's remaining chunks — settle it empty
|
||||||
// so RemoteStore's queue isn't left waiting on a completion that will never arrive.
|
// so RemoteStore's queue isn't left waiting on a completion that will never arrive.
|
||||||
finishPrefetch(delivering: false)
|
finishPrefetch(delivering: false)
|
||||||
|
|||||||
@@ -694,10 +694,13 @@ final class RemoteStore: ObservableObject {
|
|||||||
private var agentLoginListener: OAuthRedirectListener?
|
private var agentLoginListener: OAuthRedirectListener?
|
||||||
|
|
||||||
/// Per-session high-water seq below which a tail auth failure is considered resolved by a
|
/// Per-session high-water seq below which a tail auth failure is considered resolved by a
|
||||||
/// completed sign-in. Set to the open transcript's newest seq the moment an agent sign-in
|
/// completed sign-in. Anchored at the transcript's tip by `resolveAuthBanners` the moment a
|
||||||
/// succeeds (`agentLoginResultReceived`), so the in-chat "Sign in" banner clears immediately —
|
/// sign-in for that session's provider completes — this phone's own attempt
|
||||||
/// without waiting for a fresh run to push a non-error event onto the tail. A genuinely newer
|
/// (`agentLoginResultReceived`) or one observed landing on a host, which is how a login the
|
||||||
/// auth failure (a token that lapses again) carries a higher seq, so it re-arms the banner.
|
/// user finished on *another* device reaches this banner — so the in-chat "Sign in" banner
|
||||||
|
/// clears immediately, without waiting for a fresh run to push a non-error event onto the
|
||||||
|
/// tail. A genuinely newer auth failure (a token that lapses again) carries a higher seq, so
|
||||||
|
/// it re-arms the banner.
|
||||||
/// Keyed by session because seqs are per-session; this is the *observable* dismissal signal,
|
/// Keyed by session because seqs are per-session; this is the *observable* dismissal signal,
|
||||||
/// deliberately not tied to credential *presence* (an expired-but-present login still reads as
|
/// deliberately not tied to credential *presence* (an expired-but-present login still reads as
|
||||||
/// "authenticated", so presence can't tell a stale token from a fresh sign-in).
|
/// "authenticated", so presence can't tell a stale token from a fresh sign-in).
|
||||||
@@ -1652,6 +1655,14 @@ final class RemoteStore: ObservableObject {
|
|||||||
guard let self, hostID == self.agentLoginHostID else { return }
|
guard let self, hostID == self.agentLoginHostID else { return }
|
||||||
self.agentLoginResultReceived(result)
|
self.agentLoginResultReceived(result)
|
||||||
}
|
}
|
||||||
|
cb.agentAuthRefreshed = { [weak self] providers in
|
||||||
|
// A sign-in landed on that host without this phone driving it — the user finished the
|
||||||
|
// login on their Mac, or on another device that shares the mesh credential. Resolve
|
||||||
|
// the in-chat banner the same way this phone's own sign-in does; from any host,
|
||||||
|
// because a landed credential syncs to every member (and if some host somehow misses
|
||||||
|
// it, that host's next run fails again and re-arms the banner at a newer seq).
|
||||||
|
self?.resolveAuthBanners(for: providers)
|
||||||
|
}
|
||||||
return cb
|
return cb
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2456,19 +2467,35 @@ final class RemoteStore: ObservableObject {
|
|||||||
agentLoginListener?.stop()
|
agentLoginListener?.stop()
|
||||||
agentLoginListener = nil
|
agentLoginListener = nil
|
||||||
agentLoginRequestID = nil
|
agentLoginRequestID = nil
|
||||||
// A successful sign-in resolves any auth failure already in the open transcript's tail:
|
if result.succeeded, let provider = agentLoginProvider {
|
||||||
// anchor the dismissal at the newest seq so the in-chat banner clears now, and only a
|
resolveAuthBanners(for: [provider])
|
||||||
// *newer* failure re-arms it. Scoped to the open session whose provider we just signed in
|
|
||||||
// — the in-chat "Sign in" CTA is always the open session's, and a provider mismatch (a
|
|
||||||
// Settings-initiated login for a different provider) must not dismiss an unrelated banner.
|
|
||||||
if result.succeeded, let sid = openSessionID,
|
|
||||||
let backend = sessions.first(where: { $0.sessionID == sid })?.backend,
|
|
||||||
AgentLoginProvider.forBackend(backend) == agentLoginProvider {
|
|
||||||
authResolvedSeqBySession[sid] = openEvents.map(\.seq).max() ?? 0
|
|
||||||
}
|
}
|
||||||
agentLogin = .done(success: result.succeeded, message: result.error)
|
agentLogin = .done(success: result.succeeded, message: result.error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A sign-in for `providers` completed — here, or on any other device in the mesh. Resolve
|
||||||
|
/// every session those providers back: anchor its dismissal at the transcript's current tip so
|
||||||
|
/// an auth failure already sitting in the tail stops showing the in-chat banner immediately —
|
||||||
|
/// no waiting for a fresh run to rewrite the tail — while a genuinely newer failure (a token
|
||||||
|
/// that lapses again) carries a higher seq and re-arms it. Provider-matched, so a login for
|
||||||
|
/// one provider never dismisses another's banner, and `max` so an older signal can't lower an
|
||||||
|
/// anchor already set.
|
||||||
|
private func resolveAuthBanners(for providers: [AgentLoginProvider]) {
|
||||||
|
guard !providers.isEmpty else { return }
|
||||||
|
// The open session's on-screen tail can lead its summary's `lastSeq` (the events that
|
||||||
|
// arrived since the last summary push) — anchor past those too, or the very failure being
|
||||||
|
// resolved could sit above the anchor.
|
||||||
|
let openTip = openEvents.map(\.seq).max() ?? 0
|
||||||
|
for session in sessions {
|
||||||
|
guard let provider = AgentLoginProvider.forBackend(session.backend),
|
||||||
|
providers.contains(provider) else { continue }
|
||||||
|
let tip = session.sessionID == openSessionID
|
||||||
|
? max(session.lastSeq, openTip) : session.lastSeq
|
||||||
|
authResolvedSeqBySession[session.sessionID] = max(
|
||||||
|
authResolvedSeqBySession[session.sessionID] ?? 0, tip)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Whether `hostID` can take an API key from this phone right now (REMOTE_AGENT_LOGIN §8):
|
/// Whether `hostID` can take an API key from this phone right now (REMOTE_AGENT_LOGIN §8):
|
||||||
/// it advertises sealed-credential ingestion AND we hold its sealing key from the
|
/// it advertises sealed-credential ingestion AND we hold its sealing key from the
|
||||||
/// post-hello `credentialNeeded` push.
|
/// post-hello `credentialNeeded` push.
|
||||||
|
|||||||
@@ -391,6 +391,8 @@ struct AgentAuthErrorBanner: View {
|
|||||||
// sign-in hasn't already resolved. A successful sign-in anchors `authResolvedSeq` at the
|
// sign-in hasn't already resolved. A successful sign-in anchors `authResolvedSeq` at the
|
||||||
// tail's newest seq, so the banner clears the instant login finishes — no waiting for a
|
// tail's newest seq, so the banner clears the instant login finishes — no waiting for a
|
||||||
// fresh run to rewrite the tail — while a genuinely newer failure (a higher seq) re-arms it.
|
// fresh run to rewrite the tail — while a genuinely newer failure (a higher seq) re-arms it.
|
||||||
|
// "Completed" covers a sign-in finished on any device, not just this phone: the host's
|
||||||
|
// post-change `agentAuthStatus` push anchors the same seq (see `resolveAuthBanners`).
|
||||||
if AgentLoginProvider.forBackend(backend) != nil,
|
if AgentLoginProvider.forBackend(backend) != nil,
|
||||||
let failSeq = AgentAuthErrors.authFailureSeq(store.openEvents),
|
let failSeq = AgentAuthErrors.authFailureSeq(store.openEvents),
|
||||||
failSeq > store.authResolvedSeq(forSession: sessionID)
|
failSeq > store.authResolvedSeq(forSession: sessionID)
|
||||||
@@ -407,10 +409,12 @@ struct AgentAuthErrorBanner: View {
|
|||||||
.buttonStyle(.borderedProminent)
|
.buttonStyle(.borderedProminent)
|
||||||
.controlSize(.small)
|
.controlSize(.small)
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 14)
|
||||||
.padding(.vertical, 8)
|
.padding(.vertical, 10)
|
||||||
.background(.orange.opacity(0.12), in: RoundedRectangle(cornerRadius: 10))
|
// The same floating glass the disconnected banner and the chat bar it stacks with
|
||||||
.padding(.horizontal, 12)
|
// use, washed warning-orange — it hovers over the transcript right above the
|
||||||
|
// composer, where a flat translucent fill read as an unfinished sheet.
|
||||||
|
.glassSurface(cornerRadius: 20, tint: .orange)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -350,18 +350,31 @@ struct KeyboardDismissable: ViewModifier {
|
|||||||
/// hairline edge on earlier systems so the bar still reads as a translucent layer.
|
/// hairline edge on earlier systems so the bar still reads as a translucent layer.
|
||||||
struct GlassSurface: ViewModifier {
|
struct GlassSurface: ViewModifier {
|
||||||
var cornerRadius: CGFloat = 24
|
var cornerRadius: CGFloat = 24
|
||||||
|
/// An optional semantic wash (the auth banner's warning orange). Carried *by* the glass — a
|
||||||
|
/// tinted `glassEffect` on iOS 26, a thin veil above the material on the fallback — so a
|
||||||
|
/// surface that needs to read as a warning stays glass instead of a flat colored fill.
|
||||||
|
var tint: Color?
|
||||||
|
|
||||||
func body(content: Content) -> some View {
|
func body(content: Content) -> some View {
|
||||||
if #available(iOS 26.0, *) {
|
if #available(iOS 26.0, *) {
|
||||||
content.glassEffect(.regular, in: .rect(cornerRadius: cornerRadius, style: .continuous))
|
content.glassEffect(glass, in: .rect(cornerRadius: cornerRadius, style: .continuous))
|
||||||
} else {
|
} else {
|
||||||
content
|
content
|
||||||
.background(.ultraThinMaterial,
|
.background((tint ?? .clear).opacity(0.14), in: shape)
|
||||||
in: RoundedRectangle(cornerRadius: cornerRadius, style: .continuous))
|
.background(.ultraThinMaterial, in: shape)
|
||||||
.overlay(
|
.overlay(shape.strokeBorder(Color.primary.opacity(0.08), lineWidth: 1))
|
||||||
RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
|
|
||||||
.strokeBorder(Color.primary.opacity(0.08), lineWidth: 1))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var shape: RoundedRectangle {
|
||||||
|
RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(iOS 26.0, *)
|
||||||
|
private var glass: Glass {
|
||||||
|
guard let tint else { return .regular }
|
||||||
|
return .regular.tint(tint)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A circular Liquid Glass backing for a floating round control (the New-chat FAB). Real
|
/// A circular Liquid Glass backing for a floating round control (the New-chat FAB). Real
|
||||||
@@ -395,9 +408,10 @@ struct CardBackground: ViewModifier {
|
|||||||
|
|
||||||
extension View {
|
extension View {
|
||||||
func card(padding: CGFloat = 14) -> some View { modifier(CardBackground(padding: padding)) }
|
func card(padding: CGFloat = 14) -> some View { modifier(CardBackground(padding: padding)) }
|
||||||
/// Float content on Liquid Glass (material fallback pre-iOS 26). See `GlassSurface`.
|
/// Float content on Liquid Glass (material fallback pre-iOS 26), optionally washed with a
|
||||||
func glassSurface(cornerRadius: CGFloat = 24) -> some View {
|
/// semantic `tint` for a surface that carries a warning. See `GlassSurface`.
|
||||||
modifier(GlassSurface(cornerRadius: cornerRadius))
|
func glassSurface(cornerRadius: CGFloat = 24, tint: Color? = nil) -> some View {
|
||||||
|
modifier(GlassSurface(cornerRadius: cornerRadius, tint: tint))
|
||||||
}
|
}
|
||||||
/// Back a circular control (the floating New-chat FAB) with interactive Liquid Glass — the
|
/// Back a circular control (the floating New-chat FAB) with interactive Liquid Glass — the
|
||||||
/// round analogue of `glassSurface`, falling back to an ultra-thin material disc pre-iOS 26.
|
/// round analogue of `glassSurface`, falling back to an ultra-thin material disc pre-iOS 26.
|
||||||
|
|||||||
Reference in New Issue
Block a user