Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
5 changes: 5 additions & 0 deletions apps/ios/Account.xcconfig
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
// WorkOS AuthKit client IDs are public and ship in the app. Debug signs in
// against the staging environment, Release against production.

WORKOS_CLIENT_ID[config=Debug] = client_01M3C9M944Y53VA9PJRCW8T5S6
WORKOS_CLIENT_ID[config=Release] = client_01M3CC0Q23FPHK50YW1WXCNZ3S
6 changes: 6 additions & 0 deletions apps/ios/Origin89.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
AB8900000000000000000001 /* Origin89UI in Frameworks */ = {isa = PBXBuildFile; productRef = AB8900000000000000000002 /* Origin89UI */; };
AB8900000000000000000009 /* SetupKit in Frameworks */ = {isa = PBXBuildFile; productRef = AB8900000000000000000007 /* SetupKit */; };
AB890000000000000000000A /* SetupCore in Frameworks */ = {isa = PBXBuildFile; productRef = AB8900000000000000000008 /* SetupCore */; };
AB8900000000000000000015 /* AccountView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB8900000000000000000014 /* AccountView.swift */; };
AB890000000000000000000C /* SetupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB890000000000000000000B /* SetupView.swift */; };
AB890000000000000000000E /* NetworkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB890000000000000000000D /* NetworkView.swift */; };
AB8900000000000000000010 /* CodeScannerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB890000000000000000000F /* CodeScannerView.swift */; };
Expand All @@ -19,6 +20,8 @@
/* End PBXBuildFile section */

/* Begin PBXFileReference section */
AB8900000000000000000014 /* AccountView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountView.swift; sourceTree = "<group>"; };
AB8900000000000000000016 /* Account.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Account.xcconfig; sourceTree = "<group>"; };
291B0A8141E0F76A304D6029 /* Signing.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Signing.xcconfig; sourceTree = "<group>"; };
8E94264721F66A5291BC0C5B /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
AB890000000000000000000B /* SetupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetupView.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -56,6 +59,7 @@
isa = PBXGroup;
children = (
F00B56E0DCEE33A9A258AFB6 /* Origin89 */,
AB8900000000000000000016 /* Account.xcconfig */,
291B0A8141E0F76A304D6029 /* Signing.xcconfig */,
C6516E3FD84BCA0F3E0309D5 /* Products */,
);
Expand All @@ -64,6 +68,7 @@
F00B56E0DCEE33A9A258AFB6 /* Origin89 */ = {
isa = PBXGroup;
children = (
AB8900000000000000000014 /* AccountView.swift */,
8E94264721F66A5291BC0C5B /* Assets.xcassets */,
AB890000000000000000000F /* CodeScannerView.swift */,
AB890000000000000000000D /* NetworkView.swift */,
Expand Down Expand Up @@ -155,6 +160,7 @@
buildActionMask = 2147483647;
files = (
D725D6BB1B4CC521C45F1B0E /* Origin89App.swift in Sources */,
AB8900000000000000000015 /* AccountView.swift in Sources */,
AB890000000000000000000C /* SetupView.swift in Sources */,
AB890000000000000000000E /* NetworkView.swift in Sources */,
AB8900000000000000000010 /* CodeScannerView.swift in Sources */,
Expand Down
115 changes: 115 additions & 0 deletions apps/ios/Origin89/AccountView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import AuthenticationServices
import Origin89UI
import SetupKit
import SwiftUI

/// Optional sign-in. Setup never asks for it: pairing and the controller's
/// network work the same signed in or out.
struct AccountView: View {
let account: Account

@State private var failure: AccountError?
@State private var confirmingSignOut = false
@Environment(\.webAuthenticationSession) private var webAuthenticationSession
@Environment(\.dismiss) private var dismiss

var body: some View {
NavigationStack {
Form {
switch account.status {
case .signedOut, .signingIn: signedOut
case .signedIn(let user): signedIn(user)
}
if let failure {
Section { Origin89Notice(failure.message, tone: .alarm) }
}
}
.navigationTitle("Account")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .confirmationAction) { Button("Done") { dismiss() } }
}
}
.confirmationDialog(
"Sign out?", isPresented: $confirmingSignOut, titleVisibility: .visible
) {
Button("Sign out", role: .destructive, action: signOut)
} message: {
Text(
"Controllers paired while signed in stay on this phone and come back when you sign in again."
)
}
}

@ViewBuilder private var signedOut: some View {
Section {
if account.sessionEnded {
Origin89Notice("Your session ended. Sign in again to use your account.", tone: .info)
}
Text("An account is optional. Pairing and setting up a controller work without one.")
if !account.isAvailable { Origin89Notice(AccountError.notConfigured.message, tone: .info) }
Button(action: signIn) {
if account.status == .signingIn {
HStack(spacing: 12) {
ProgressView()
Text("Signing in…")
}
} else {
Text("Sign in")
}
}
.disabled(account.status == .signingIn || !account.isAvailable)
} footer: {
Text("Sign in with Apple, Google, a passkey or a code sent by email.")
}
}

@ViewBuilder private func signedIn(_ user: AccountUser) -> some View {
Section {
if let name = [user.firstName, user.lastName].compactMap(\.self).joined(separator: " ")
.nonEmpty
{
Text(name)
}
Text(user.email).foregroundStyle(.secondary)
} header: {
Text("Signed in")
}
Section {
Button("Sign out", role: .destructive) { confirmingSignOut = true }
} footer: {
Text(
"Controllers paired while signed in show only in this account. Controllers paired while signed out show in every account."
)
}
}

private func signIn() {
failure = nil
Task {
do throws(AccountError) {
try await account.signIn { url, scheme throws(AccountError) in
do {
return try await webAuthenticationSession.authenticate(
using: url, callbackURLScheme: scheme, preferredBrowserSession: .ephemeral)
} catch let error as ASWebAuthenticationSessionError where error.code == .canceledLogin {
throw .cancelled
} catch {
throw .unavailable
}
}
} catch {
if error != .cancelled { failure = error }
}
}
}

private func signOut() {
failure = nil
do { try account.signOut() } catch { failure = error }
}
}

extension String {
fileprivate var nonEmpty: String? { isEmpty ? nil : self }
}
2 changes: 2 additions & 0 deletions apps/ios/Origin89/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>WorkOSClientID</key>
<string>$(WORKOS_CLIENT_ID)</string>
<key>NSBonjourServices</key>
<array>
<string>_km43._tcp</string>
Expand Down
73 changes: 55 additions & 18 deletions apps/ios/Origin89/Origin89App.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,40 +5,77 @@ import os

@main
struct Origin89App: App {
@State private var flow = Self.makeFlow()
@State private var account: Account
@State private var flow: SetupFlow

@MainActor private static func makeFlow() -> SetupFlow {
let store = KeychainEnrolmentStore()
// Keychain items outlive an app delete; user defaults do not. A launch
// with no label suffix yet is a new install: drop what an old one kept.
// A failed removal is tried again on each launch until it succeeds, which
// then also drops any enrolment made in between.
@MainActor init() {
Self.clearPreviousInstall()
let account = Account(
client: Self.authKit.map { AuthKitClient(configuration: $0) },
store: KeychainAccountSessionStore())
_account = State(initialValue: account)
_flow = State(initialValue: Self.makeFlow(owner: account.owner))
}

/// Keychain items outlive an app delete; user defaults do not. A launch
/// with no label suffix yet is a new install: drop the enrolments and the
/// session an old one kept. A failed removal is tried again on each launch
/// until it succeeds, which then also drops any enrolment made in between.
@MainActor private static func clearPreviousInstall() {
let defaults = UserDefaults.standard
let pendingKey = "setup.clearPreviousInstall"
if DeviceLabel.isNewInstall { defaults.set(true, forKey: pendingKey) }
if defaults.bool(forKey: pendingKey) {
do {
try store.removeAll()
defaults.removeObject(forKey: pendingKey)
} catch {
Logger(subsystem: SetupLog.subsystem, category: "flow").error(
"could not remove enrolments left from a previous install: \(String(describing: error), privacy: .public)"
)
}
guard defaults.bool(forKey: pendingKey) else { return }
do {
try KeychainEnrolmentStore.removeEveryAccount()
try KeychainAccountSessionStore().remove()
defaults.removeObject(forKey: pendingKey)
} catch {
Logger(subsystem: SetupLog.subsystem, category: "flow").error(
"could not remove what a previous install kept: \(String(describing: error), privacy: .public)"
)
}
}

private static var authKit: AuthKitConfiguration? {
let clientID = Bundle.main.object(forInfoDictionaryKey: "WorkOSClientID") as? String
return clientID.flatMap { AuthKitConfiguration(clientID: $0) }
}

/// The flow for `owner`'s enrolments: signed out, those made signed out;
/// signed in, the account's own too. Each keeps its own last controller.
@MainActor private static func makeFlow(owner: AccountID?) -> SetupFlow {
let signedOut = KeychainEnrolmentStore()
let store: any EnrolmentStore =
owner.map {
AccountEnrolmentStore(own: KeychainEnrolmentStore(owner: $0), signedOut: signedOut)
} ?? signedOut
let lastController = DefaultsLastController(
key: owner.map { "setup.lastController.\($0.rawValue)" } ?? "setup.lastController")
return SetupFlow(
factory: RustControllerClientFactory(label: DeviceLabel.current),
store: store,
transportFactory: { BluetoothTransport(identifiers: .km43, codec: RustFragmentCodec()) },
lastController: DefaultsLastController(),
lastController: lastController,
webSocketFactory: { WebSocketTransport.km43(address: $0) },
addresses: DefaultsControllerAddresses(),
browser: NetworkControllerBrowser.km43())
}

var body: some Scene {
WindowGroup {
SetupView(flow: flow)
SetupView(flow: flow, account: account)
.id(ObjectIdentifier(flow))
.task { await account.refreshIfExpired() }
// Another account sees other enrolments: close this flow's connection
// first, then start the account's own flow.
.onChange(of: account.owner) { _, owner in
let previous = flow
Task {
await previous.suspend()
flow = Self.makeFlow(owner: owner)
}
}
}
}
}
Expand Down
14 changes: 13 additions & 1 deletion apps/ios/Origin89/SetupView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,15 @@ import SwiftUI
/// joins the network the session moves to Wi-Fi when this phone can reach it.
struct SetupView: View {
let flow: SetupFlow
let account: Account

@State private var windowOpenedAt: Date?
@State private var failedDuring: SetupFlow.State?
/// The network page the person opened, kept across a reconnect.
@State private var networkStep: NetworkStep?
@State private var confirming: Forget?
@State private var forgetFailed = false
@State private var accountShown = false
@Environment(\.scenePhase) private var scenePhase

private enum Forget: Identifiable {
Expand All @@ -31,6 +33,15 @@ struct SetupView: View {
Button("Start over") { Task { await startOver() } }
}
}
ToolbarItem(placement: .topBarTrailing) {
Button {
accountShown = true
} label: {
Image(
systemName: account.owner == nil ? "person.crop.circle" : "person.crop.circle.fill")
}
.accessibilityLabel("Account")
}
ToolbarItem(placement: .topBarLeading) {
Menu {
if flow.knownController != nil {
Expand All @@ -45,6 +56,7 @@ struct SetupView: View {
}
}
.tint(Color.origin89.action)
.sheet(isPresented: $accountShown) { AccountView(account: account) }
.confirmationDialog(
confirming == .all ? "Forget all controllers?" : "Forget this controller?",
isPresented: Binding(get: { confirming != nil }, set: { if !$0 { confirming = nil } }),
Expand All @@ -56,7 +68,7 @@ struct SetupView: View {
} message: { forget in
Text(
forget == .all
? "This phone removes every pairing it keeps. Each controller needs its setup code and pairing window again."
? "This phone removes every pairing it shows here. Each controller needs its setup code and pairing window again. Pairings made under another account stay."
: "This phone removes its pairing with this controller. Its setup code and pairing window are needed again."
)
}
Expand Down
Loading