diff --git a/NucleicRemote/NucleicRemote/Models/HostConnection.swift b/NucleicRemote/NucleicRemote/Models/HostConnection.swift index 4359d46..f8f6259 100644 --- a/NucleicRemote/NucleicRemote/Models/HostConnection.swift +++ b/NucleicRemote/NucleicRemote/Models/HostConnection.swift @@ -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) diff --git a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift index dcfbe70..e3f1cd2 100644 --- a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift +++ b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift @@ -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. diff --git a/NucleicRemote/NucleicRemote/Views/AgentAccountsView.swift b/NucleicRemote/NucleicRemote/Views/AgentAccountsView.swift index af53cf9..25c6d4f 100644 --- a/NucleicRemote/NucleicRemote/Views/AgentAccountsView.swift +++ b/NucleicRemote/NucleicRemote/Views/AgentAccountsView.swift @@ -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) } } } diff --git a/NucleicRemote/NucleicRemote/Views/Theme.swift b/NucleicRemote/NucleicRemote/Views/Theme.swift index 176e226..1b4c85c 100644 --- a/NucleicRemote/NucleicRemote/Views/Theme.swift +++ b/NucleicRemote/NucleicRemote/Views/Theme.swift @@ -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.