Files
flamenet-messenger-ios/Sources/App/AppEnvironment.swift
T
Paul HittandClaude Opus 5 4b0117b07d Let CallKit own the call, not just announce it
CallKit was a doorbell. It rang the phone when a VoIP push arrived and
took no further part, which left four things wrong.

Declining from the lock screen did not decline. The end action cleared
the local ring and fulfilled itself, and nothing told the call manager,
so the caller went on listening to a phone that had already said no.

The audio session was never handed over. There was no didActivate and
no didDeactivate, and manual audio was set only on the Simulator, so on
a device WebRTC raced CallKit to bring the session up. Doing that first
is the documented way to get a call that connects and carries no sound.
The unit is now off by default and exactly one thing turns it on:
CallKit for a call it owns, the manager for one placed or answered
inside the app.

Ending a call never told CallKit either, and a call left open there
holds the phone in an in-call state and refuses the next incoming one.

And outgoing calls were invisible to iOS: no Recents entry, no session
ownership, nothing stopping the phone ringing for something else in the
middle of one. They are reported now, connecting and connected.

⚠️ The audio path is reasoned from Apple's and WebRTC's contract and is
NOT observed. A Simulator cannot exercise it, which is why none of this
was noticed. iPhone call audio has never been proven on hardware, and
this is the most likely explanation; it is also the change most in need
of a real device before it is trusted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 10:30:32 -04:00

528 lines
21 KiB
Swift

import Combine
import CryptoKit
import FilesClient
import Foundation
import os.log
/// Composition root + top-level app state.
@MainActor
final class AppEnvironment: ObservableObject {
enum Phase: Equatable {
case signedOut(SiteConfig)
case signedIn(SiteConfig)
}
@Published private(set) var phase: Phase
@Published var lastError: String?
/// The refusal's own code, kept alongside the sentence.
@Published var lastErrorCode: String?
/// Updated by `BuddyListViewModel` after each load; surfaced on the Home
/// tab as a `.badge`.
@Published var homeBadgeCount: Int = 0
/// Everybody a profile picture should be offered to.
@Published var knownBuddyIDs: [Int] = []
/// The buddies themselves, so screens outside the list can put a name to an
/// id.
@Published var knownBuddies: [Buddy] = []
/// Whether the sign-up sheet should be showing.
@Published var showingSignUp = false
/// Set by `NotificationDelegate` when the user taps a message notification,
/// `BuddyListView` watches this and pushes the matching chat onto its
/// `NavigationPath`.
@Published var deepLinkBuddyID: Int?
/// An address scanned or typed somewhere OTHER than Home, the Messages
/// tab's scanner drops it here, MainTabView switches to Home, and Home's
/// search box picks it up.
@Published var pendingAddress: String?
/// The unlocked Files vault, while there is one.
@Published var filesVault: LocalVault?
/// The identity key behind that vault, while Files is unlocked.
@Published var filesIdentity: Curve25519.KeyAgreement.PrivateKey?
private let store: SessionStore
private(set) var client: MessengerClient?
private(set) var api: MessengerAPI?
private(set) var auth: AuthService?
/// The site this session is pointed at.
var siteConfig: SiteConfig {
switch phase {
case .signedOut(let c), .signedIn(let c): return c
}
}
/// App-level end-to-end encryption coordinator.
@Published private(set) var e2eInbox: E2EInbox?
/// Voice calls (spec §6.8).
@Published private(set) var callManager: CallManager?
init(store: SessionStore = SessionStore()) {
self.store = store
// A previously chosen site wins over the bundled default.
let config = store.config ?? SiteConfig.default
self.phase = .signedOut(config)
buildGraph(for: config)
// Ask the server what it serves before anything reads the answer.
Task { @MainActor in
await self.resolveAccountProvider(for: config.baseURL)
await self.detectCapabilities(for: config.baseURL)
// Restore only after the probe, so the restored session is built on
// the settled configuration rather than racing a graph rebuild.
guard let user = self.store.username, let pwd = self.store.appPassword else { return }
let settled =
self.phase.siteBaseURL == config.baseURL
? (self.store.config ?? config) : config
if let auth = self.auth, await auth.restore(username: user, appPassword: pwd) {
if let info = auth.currentUser {
// Stand up the inbox BEFORE flipping phase, so the chat
// view never sees a transient `e2eInbox == nil` and falls
// back to the legacy nonce-scraping path.
await self.startE2E(
userId: info.userID, username: user,
appPassword: pwd, config: settled)
}
self.phase = .signedIn(settled)
}
}
}
var savedUsername: String? { store.username }
// MARK: - Configuration
func configure(with config: SiteConfig) {
store.config = config
buildGraph(for: config)
phase = .signedOut(config)
}
private func buildGraph(for config: SiteConfig) {
let client = MessengerClient(baseURL: config.baseURL, restPrefix: config.restPrefix)
self.client = client
self.api = MessengerAPI(client: client)
// Which identity provider this server uses is decided from its
// hostname, in the app, not advertised by the server, so a compromised
// relay cannot name where credentials should be sent.
let accounts: FlamenetAccountsClient?
if case .shared(let service) = config.accountProvider {
accounts = FlamenetAccountsClient(baseURL: service)
} else {
accounts = nil
}
self.auth = AuthService(client: client, accounts: accounts)
}
/// Block or unblock somebody, and take their sealed capabilities with it.
func setBlocked(_ blocked: Bool, userID: Int) async throws {
guard let api else { throw MessengerError.notAuthenticated }
try await api.setBlocked(blocked, userID: userID)
guard blocked else { return }
// Best-effort and after the block has landed: the block itself is the
// safety action, and failing it because a follow-up call did would
// leave somebody unblocked who believes otherwise.
_ = try? await e2eInbox?.revokeDeliveryTokens(for: userID)
}
private var registrationWatch: AnyCancellable?
private var revocationWatch: AnyCancellable?
private var deregistrationWatch: AnyCancellable?
private var unreadWatch: AnyCancellable?
private var presenceTask: Task<Void, Never>?
/// Stand up the E2E inbox using the App-Password credential (HTTP Basic)
/// the REST sign-in already established.
private func startE2E(userId: Int, username: String, appPassword: String, config: SiteConfig)
async
{
let client = E2EClient(
siteBaseURL: config.baseURL, basicAuth: (username, appPassword),
restPrefix: config.restPrefix)
let store = KeychainE2EStore(userId: userId)
// nil means we couldn't produce a usable signed prekey.
guard let inbox = await E2EInbox.make(myUserId: userId, client: client, store: store) else {
return
}
// Our return address, before the first send.
if let key = auth?.accountKey, let host = config.baseURL.host {
inbox.myAddress = ContactCard.canonical(
"\(AccountIdentityService.address(of: key))@\(host)")
// Persisted, because a RESTORED session cannot re-derive it: the
// fnid comes off the passphrase, and the passphrase is not stored.
if let address = inbox.myAddress {
store.saveMyAddress(address)
}
// Register it, don't just display it.
Task.detached(priority: .utility) { [weak inbox] in
guard let inbox else { return }
_ = try? await inbox.accountIdentity.claim(key: key, userID: userId)
}
} else {
inbox.myAddress = store.savedMyAddress()
}
// Reconnect the other mailboxes before polling starts.
inbox.restoreExtraHomes(restPrefix: config.restPrefix)
inbox.start()
self.e2eInbox = inbox
self.callManager = CallManager(inbox: inbox)
// A device revoked elsewhere (Devices screen, or the expiry sweep)
// learns it from the poll's 410, then this install cleans itself up
// rather than sitting on stale ciphertext for a mailbox that no longer
// exists.
deregistrationWatch = inbox.$deviceDeregistered
.filter { $0 }
.first()
.receive(on: RunLoop.main)
.sink { [weak self] _ in
guard let self else { return }
self.signOut()
self.lastError =
"This device was removed from the account, so it has "
+ "signed out. Your messages on this phone were left alone."
}
revocationWatch = inbox.$deviceRevoked
.filter { $0 }
.first()
.receive(on: RunLoop.main)
.sink { [weak self, weak store] _ in
guard let self else { return }
// The wipe is spelled out here rather than delegated to
// `signOut(erasingDevice:)` because it has to be SYNCHRONOUS.
self.e2eInbox?.purgeLocalHistory()
self.signOut()
store?.wipe()
self.lastError =
"This device was removed from the account, so "
+ "its local messages were cleared. Signing in again "
+ "registers it as a new device."
}
// Registration is per session, because the token is per device AND the
// relay stores it against a user.
presenceTask?.cancel()
presenceTask = Task { [weak self] in
while !Task.isCancelled {
try? await Task.sleep(nanoseconds: 20 * 1_000_000_000)
guard let self, let api = self.api else { continue }
do {
let fresh = try await api.fetchBuddies(fromPagePath: "")
await MainActor.run { self.knownBuddies = fresh }
os_log(
"presence refreshed: %{public}@",
log: OSLog(subsystem: "io.flamenet.messenger", category: "e2e"),
type: .info,
fresh.map { "\($0.id):\($0.presence.label)" }.joined(separator: " "))
} catch {
// Said out loud rather than swallowed.
os_log(
"presence refresh failed: %{public}@",
log: OSLog(subsystem: "io.flamenet.messenger", category: "e2e"),
type: .error, String(describing: error))
}
}
}
unreadWatch = Publishers.CombineLatest(inbox.$unreadPeers, $knownBuddies)
.receive(on: RunLoop.main)
.sink { [weak self] peers, buddies in
self?.homeBadgeCount =
peers.values.reduce(0, +)
+ buddies.map(\.unreadCount).reduce(0, +)
}
registrationWatch = inbox.$registrationFailed
.filter { $0 }
.first()
.receive(on: RunLoop.main)
.sink { [weak self] _ in
self?.lastError =
"Could not finish setting this device up on "
+ "the relay, so it is not receiving messages yet. Your "
+ "account and messages are untouched, this usually clears "
+ "on its own; check your connection and try again."
}
PushRegistrar.shared.upload = { [weak inbox] token in
guard let inbox else { return }
// Carries whatever VoIP token PushKit has issued so far.
try await inbox.registerPushToken(
token, voipToken: VoIPCallReceiver.shared.currentToken)
}
PushRegistrar.shared.register()
// §6.14, ringing a locked phone.
VoIPCallReceiver.shared.uploadToken = { [weak inbox] voip in
guard let inbox else { return }
try await inbox.registerPushToken(
PushRegistrar.shared.currentToken ?? "", voipToken: voip)
}
// What happens after the phone starts ringing.
VoIPCallReceiver.shared.wake = { [weak inbox, weak self] in
guard let inbox else { return }
await inbox.pollOnce()
// The call manager picks the offer up from the inbox exactly as it
// does in the foreground; only the ringing is new.
if let name = self?.callManager?.ringingCallerName() {
VoIPCallReceiver.shared.identify(caller: name)
}
}
// Refusing from the lock screen has to refuse the call, not just this
// phone's ringing. Without this the action was fulfilled, the ring
// stopped here, and the caller went on listening to a phone that had
// already said no.
VoIPCallReceiver.shared.endCall = { [weak self] in
self?.callManager?.declineFromSystem()
}
VoIPCallReceiver.shared.start()
}
private func stopE2E() {
// Stop ringing before anything else goes.
VoIPCallReceiver.shared.finish(reason: .failed)
callManager?.teardown()
callManager = nil
// The presence poll outlives the session it authenticates with unless
// something stops it, a signed-out app quietly asking the relay who is
// online, for ever.
presenceTask?.cancel()
presenceTask = nil
e2eInbox?.stop()
e2eInbox = nil
}
// MARK: - Auth
func signIn(usernameOrEmail: String, password: String) async {
// Settle what this server offers first.
if case .signedOut(let current) = phase {
await resolveAccountProvider(for: current.baseURL)
await detectCapabilities(for: current.baseURL)
}
guard let auth, case .signedOut(let config) = phase else { return }
lastError = nil
lastErrorCode = nil
do {
let info = try await auth.signIn(usernameOrEmail: usernameOrEmail, password: password)
store.username = info.username
store.appPassword = info.appPassword
await startE2E(
userId: info.userID, username: info.username, appPassword: info.appPassword,
config: config)
showingSignUp = false
phase = .signedIn(config)
} catch {
lastError = (error as? MessengerError)?.errorDescription ?? error.localizedDescription
lastErrorCode = (error as? MessengerError).flatMap {
if case .ajaxFailure(let code) = $0 { return code } else { return nil }
}
}
}
func signUp(username: String, email: String, password: String, displayName: String?) async {
if case .signedOut(let current) = phase {
await resolveAccountProvider(for: current.baseURL)
await detectCapabilities(for: current.baseURL)
}
guard let auth, case .signedOut(let config) = phase else { return }
lastError = nil
lastErrorCode = nil
do {
let info = try await auth.signUp(
username: username,
email: email,
password: password,
displayName: displayName
)
store.username = info.username
store.appPassword = info.appPassword
await startE2E(
userId: info.userID, username: info.username, appPassword: info.appPassword,
config: config)
// Cleared here as well as on sign-in: the flag outlives the view
// that set it, so leaving it true means the sheet reappears the
// next time somebody signs out.
showingSignUp = false
phase = .signedIn(config)
} catch {
lastError = (error as? MessengerError)?.errorDescription ?? error.localizedDescription
lastErrorCode = (error as? MessengerError).flatMap {
if case .ajaxFailure(let code) = $0 { return code } else { return nil }
}
}
}
/// Try again to stand up encryption after `startE2E` gave up.
@discardableResult
func retryEncryptionSetup() async -> Bool {
guard e2eInbox == nil,
case .signedIn(let config) = phase,
let username = store.username,
let appPassword = store.appPassword,
let userID = auth?.currentUser?.userID
else { return e2eInbox != nil }
await startE2E(
userId: userID, username: username,
appPassword: appPassword, config: config)
return e2eInbox != nil
}
/// Sign out, in the one order that cannot leave a half-signed-out install.
/// - Parameter erasingDevice: destroy this device's identity and stored
/// messages as well as ending the session.
func signOut(erasingDevice: Bool = false) {
// Held across the teardown: `stopE2E` clears the property, and the
// relay half below still needs the client and the keys.
let inbox = e2eInbox
// 1.
store.appPassword = nil
auth?.signOut()
PushRegistrar.shared.signedOut()
stopE2E()
// The unlocked vault goes with the session.
filesVault = nil
filesIdentity = nil
// Signing out keeps the chosen site: it is a property of the
// installation, not of the session.
phase = .signedOut(store.config ?? SiteConfig.default)
guard let inbox else { return }
Task { @MainActor in
guard erasingDevice else {
// Still stop the buzzing.
try? await inbox.forgetPushToken()
return
}
// 2.
await inbox.retireThisDevice()
// 3.
inbox.eraseLocalData()
}
}
/// Point the app at a different Flamenet installation.
func useSite(baseURL: URL) {
stopE2E()
auth?.signOut()
store.appPassword = nil
let config = SiteConfig(baseURL: baseURL)
store.config = config
buildGraph(for: config)
phase = .signedOut(config)
// Then settle the REST prefix in the background, rather than asking a
// person which one their server uses.
Task {
await self.resolveAccountProvider(for: baseURL)
await self.detectCapabilities(for: baseURL)
}
}
/// Work out what this site actually offers: which REST prefix it answers
/// on, and whether it serves the buddy-command surface.
private func resolveAccountProvider(for baseURL: URL) async {
let descriptor = await RelayDiscovery.describe(baseURL)
let service = RelayDiscovery.resolveAccountService(
host: baseURL.host, descriptor: descriptor)
// Nothing is stored for a pinned host: a persisted value is one that
// could later disagree with the pin, and the pin has to win.
let discovered =
RelayDiscovery.pinnedHosts.contains(baseURL.host?.lowercased() ?? "")
? nil : service
guard case .signedOut(var cfg) = phase, cfg.baseURL == baseURL else { return }
guard cfg.discoveredAccountsURL != discovered else { return }
cfg.discoveredAccountsURL = discovered
store.config = cfg
buildGraph(for: cfg)
phase = .signedOut(cfg)
}
private func detectCapabilities(for baseURL: URL) async {
// Only `api`.
for candidate in ["api"] {
let probe = baseURL.appendingPathComponent(candidate)
.appendingPathComponent("flamenet/v1")
var req = URLRequest(url: probe)
req.timeoutInterval = 8
guard let (data, resp) = try? await URLSession.shared.data(for: req),
let http = resp as? HTTPURLResponse,
(200...499).contains(http.statusCode), http.statusCode != 404
else { continue }
// The namespace index lists every route the site registers.
let body = String(decoding: data, as: UTF8.self)
.replacingOccurrences(of: "\\/", with: "/")
let hasCommands = body.contains("/flamenet/v1/search")
guard case .signedOut(var cfg) = phase, cfg.baseURL == baseURL else { return }
let prefixChanged = cfg.restPrefix != candidate
guard prefixChanged || cfg.supportsCommands != hasCommands else { return }
cfg.restPrefix = candidate
cfg.supportsCommands = hasCommands
store.config = cfg
// Rebuild ONLY when the prefix moved.
if prefixChanged {
buildGraph(for: cfg)
}
phase = .signedOut(cfg)
return
}
}
/// Forget the site entirely (used to switch servers).
func resetConfiguration() {
stopE2E()
auth?.signOut()
store.clear()
let config = SiteConfig.default
buildGraph(for: config)
phase = .signedOut(config)
}
}
// Moved here from SiteConfig.swift, which is about a server's connection
// settings and had no business extending the app's environment, and could not
// be compiled on its own for the interop driver while it did.
extension AppEnvironment.Phase {
/// The site URL bound to this phase, used for things like the forgot-
/// password deep-link.
var siteBaseURL: URL {
switch self {
case .signedOut(let config), .signedIn(let config): return config.baseURL
}
}
/// Whether the bound site serves the buddy-command surface.
var supportsCommands: Bool {
switch self {
case .signedOut(let c), .signedIn(let c): return c.supportsCommands
}
}
/// The bound `SiteConfig` in either phase.
var siteConfig: SiteConfig? {
switch self {
case .signedOut(let c), .signedIn(let c): return c
}
}
/// Convenience for callers that only care about the signed-in config,
/// returns the bound `SiteConfig` or nil when not signed in.
var signedInConfig: SiteConfig? {
if case .signedIn(let config) = self { return config }
return nil
}
}