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
|
||||
/// and on every change; feeds Settings ▸ Agent Accounts.
|
||||
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`
|
||||
/// push — what the phone seals an API key to (REMOTE_AGENT_LOGIN §8). Nil until pushed.
|
||||
private(set) var credentialSealingKey: Data?
|
||||
@@ -156,6 +161,10 @@ final class HostConnection {
|
||||
var agentLoginChallenge: (WireAgentLoginChallenge) -> Void = { _ in }
|
||||
/// The definitive outcome of this phone's sign-in attempt, keyed by requestID.
|
||||
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
|
||||
/// `credentialUpdate` or cleared by a mesh-wide deletion. RemoteStore refreshes the
|
||||
/// held-kinds mirror the Settings surface reads.
|
||||
@@ -899,8 +908,19 @@ final class HostConnection {
|
||||
case .agentAuthStatus(let statuses):
|
||||
// Per-provider install/auth state (pushed post-hello and on change) — feeds the
|
||||
// Agent Accounts list and the in-chat sign-in affordance.
|
||||
let isSnapshot = !didReceiveAgentAuthSnapshot
|
||||
didReceiveAgentAuthSnapshot = true
|
||||
agentAuthStatuses = statuses
|
||||
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):
|
||||
directBox?.deliver(.answer(offer))
|
||||
case .directGo:
|
||||
@@ -1284,6 +1304,9 @@ final class HostConnection {
|
||||
teardownDirect()
|
||||
if let client { Task { await client.disconnect() } }
|
||||
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
|
||||
// so RemoteStore's queue isn't left waiting on a completion that will never arrive.
|
||||
finishPrefetch(delivering: false)
|
||||
|
||||
@@ -694,10 +694,13 @@ final class RemoteStore: ObservableObject {
|
||||
private var agentLoginListener: OAuthRedirectListener?
|
||||
|
||||
/// 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
|
||||
/// succeeds (`agentLoginResultReceived`), 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.
|
||||
/// completed sign-in. Anchored at the transcript's tip by `resolveAuthBanners` the moment a
|
||||
/// sign-in for that session's provider completes — this phone's own attempt
|
||||
/// (`agentLoginResultReceived`) or one observed landing on a host, which is how a login the
|
||||
/// 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,
|
||||
/// 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).
|
||||
@@ -1652,6 +1655,14 @@ final class RemoteStore: ObservableObject {
|
||||
guard let self, hostID == self.agentLoginHostID else { return }
|
||||
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
|
||||
}
|
||||
|
||||
@@ -2456,19 +2467,35 @@ final class RemoteStore: ObservableObject {
|
||||
agentLoginListener?.stop()
|
||||
agentLoginListener = nil
|
||||
agentLoginRequestID = nil
|
||||
// A successful sign-in resolves any auth failure already in the open transcript's tail:
|
||||
// anchor the dismissal at the newest seq so the in-chat banner clears now, and only a
|
||||
// *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
|
||||
if result.succeeded, let provider = agentLoginProvider {
|
||||
resolveAuthBanners(for: [provider])
|
||||
}
|
||||
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):
|
||||
/// it advertises sealed-credential ingestion AND we hold its sealing key from the
|
||||
/// 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
|
||||
// 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.
|
||||
// "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,
|
||||
let failSeq = AgentAuthErrors.authFailureSeq(store.openEvents),
|
||||
failSeq > store.authResolvedSeq(forSession: sessionID)
|
||||
@@ -407,10 +409,12 @@ struct AgentAuthErrorBanner: View {
|
||||
.buttonStyle(.borderedProminent)
|
||||
.controlSize(.small)
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 8)
|
||||
.background(.orange.opacity(0.12), in: RoundedRectangle(cornerRadius: 10))
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 10)
|
||||
// The same floating glass the disconnected banner and the chat bar it stacks with
|
||||
// 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.
|
||||
struct GlassSurface: ViewModifier {
|
||||
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 {
|
||||
if #available(iOS 26.0, *) {
|
||||
content.glassEffect(.regular, in: .rect(cornerRadius: cornerRadius, style: .continuous))
|
||||
content.glassEffect(glass, in: .rect(cornerRadius: cornerRadius, style: .continuous))
|
||||
} else {
|
||||
content
|
||||
.background(.ultraThinMaterial,
|
||||
in: RoundedRectangle(cornerRadius: cornerRadius, style: .continuous))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
|
||||
.strokeBorder(Color.primary.opacity(0.08), lineWidth: 1))
|
||||
.background((tint ?? .clear).opacity(0.14), in: shape)
|
||||
.background(.ultraThinMaterial, in: shape)
|
||||
.overlay(shape.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
|
||||
@@ -395,9 +408,10 @@ struct CardBackground: ViewModifier {
|
||||
|
||||
extension View {
|
||||
func card(padding: CGFloat = 14) -> some View { modifier(CardBackground(padding: padding)) }
|
||||
/// Float content on Liquid Glass (material fallback pre-iOS 26). See `GlassSurface`.
|
||||
func glassSurface(cornerRadius: CGFloat = 24) -> some View {
|
||||
modifier(GlassSurface(cornerRadius: cornerRadius))
|
||||
/// Float content on Liquid Glass (material fallback pre-iOS 26), optionally washed with a
|
||||
/// semantic `tint` for a surface that carries a warning. See `GlassSurface`.
|
||||
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
|
||||
/// round analogue of `glassSurface`, falling back to an ultra-thin material disc pre-iOS 26.
|
||||
|
||||
Reference in New Issue
Block a user