Files
IronStorage/apple/Sources/Watch/IronStorageWatchApp.swift

353 lines
12 KiB
Swift

import Combine
import Security
import SwiftUI
import WatchConnectivity
private enum SecureSnapshotStore {
private static let service = "de.rfc1437.ironstorage.watch.snapshot"
private static let account = "selected-totp"
static func load() throws -> Data? {
var result: CFTypeRef?
let status = SecItemCopyMatching([
kSecClass: kSecClassGenericPassword,
kSecAttrService: service,
kSecAttrAccount: account,
kSecReturnData: true,
kSecMatchLimit: kSecMatchLimitOne,
] as CFDictionary, &result)
if status == errSecItemNotFound { return nil }
guard status == errSecSuccess, let data = result as? Data else {
throw SnapshotStoreError(status: status)
}
return data
}
static func replace(_ snapshot: Data) throws {
let query = [
kSecClass: kSecClassGenericPassword,
kSecAttrService: service,
kSecAttrAccount: account,
] as CFDictionary
let status = SecItemUpdate(query, [kSecValueData: snapshot] as CFDictionary)
if status == errSecItemNotFound {
let added = SecItemAdd([
kSecClass: kSecClassGenericPassword,
kSecAttrService: service,
kSecAttrAccount: account,
kSecValueData: snapshot,
kSecAttrAccessible: kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly,
] as CFDictionary, nil)
guard added == errSecSuccess else { throw SnapshotStoreError(status: added) }
} else if status != errSecSuccess {
throw SnapshotStoreError(status: status)
}
}
static func delete() throws {
let status = SecItemDelete([
kSecClass: kSecClassGenericPassword,
kSecAttrService: service,
kSecAttrAccount: account,
] as CFDictionary)
guard status == errSecSuccess || status == errSecItemNotFound else {
throw SnapshotStoreError(status: status)
}
}
}
private struct SnapshotStoreError: Error {
let status: OSStatus
}
@MainActor
private final class WatchSnapshotTransport: NSObject, ObservableObject, WCSessionDelegate {
private static let snapshotKey = "de.rfc1437.ironstorage.watch.snapshot"
private static let receiptKey = "de.rfc1437.ironstorage.watch.delivered"
@Published private(set) var presentation: WatchPresentation?
private let core = watchCore()
override init() {
super.init()
restoreSnapshot()
guard WCSession.isSupported() else {
try? core.syncUnavailable()
refreshPresentation()
return
}
let session = WCSession.default
session.delegate = self
try? core.syncStarted()
refreshPresentation()
session.activate()
}
func sceneBecameActive() {
restoreSnapshot()
guard WCSession.isSupported() else { return }
let session = WCSession.default
session.activate()
if session.activationState == .activated {
receive(session.receivedApplicationContext, session: session, authoritative: true)
}
}
func sceneBecameInactive() {
try? core.protectedDataUnavailable()
refreshPresentation()
}
private func restoreSnapshot() {
do {
if var snapshot = try SecureSnapshotStore.load() {
defer { snapshot.resetBytes(in: 0..<snapshot.count) }
let update = try core.applySnapshot(snapshot: snapshot)
if update.persistence == .delete { try SecureSnapshotStore.delete() }
} else {
try core.noPersistedSnapshot()
}
refreshPresentation()
} catch {
try? core.syncFailed()
refreshPresentation()
}
}
private func receive(
_ applicationContext: [String: Any],
session: WCSession,
authoritative: Bool = false
) {
try? core.syncStarted()
guard var snapshot = applicationContext[Self.snapshotKey] as? Data else {
try? core.syncFinished()
refreshPresentation()
return
}
defer { snapshot.resetBytes(in: 0..<snapshot.count) }
do {
let update = if authoritative {
try core.applyAuthoritativeSnapshot(snapshot: snapshot)
} else {
try core.applySnapshot(snapshot: snapshot)
}
switch update.persistence {
case .keep:
break
case .replace:
try SecureSnapshotStore.replace(snapshot)
case .delete:
try SecureSnapshotStore.delete()
}
refreshPresentation()
guard !update.receipt.isEmpty else { return }
acknowledge(update.receipt, through: session)
} catch {
try? core.syncFailed()
refreshPresentation()
}
}
private func acknowledge(_ receipt: Data, through session: WCSession) {
let acknowledgement = [Self.receiptKey: receipt]
for transfer in session.outstandingUserInfoTransfers
where transfer.userInfo[Self.receiptKey] != nil {
transfer.cancel()
}
session.transferUserInfo(acknowledgement)
if session.isReachable {
session.sendMessage(acknowledgement, replyHandler: nil) { _ in }
}
}
func refreshPresentation(at date: Date = .now) {
presentation = try? core.presentationAt(
unixSeconds: UInt64(date.timeIntervalSince1970)
)
}
nonisolated func session(
_ session: WCSession,
activationDidCompleteWith activationState: WCSessionActivationState,
error: Error?
) {
Task { @MainActor [weak self] in
guard let self else { return }
guard activationState == .activated, error == nil else {
try? self.core.syncUnavailable()
self.refreshPresentation()
return
}
self.receive(
session.receivedApplicationContext,
session: session,
authoritative: true
)
}
}
nonisolated func session(
_ session: WCSession,
didReceiveApplicationContext applicationContext: [String: Any]
) {
Task { @MainActor [weak self] in
self?.receive(applicationContext, session: session, authoritative: true)
}
}
nonisolated func session(_ session: WCSession, didReceiveMessage message: [String: Any]) {
Task { @MainActor [weak self] in
self?.receive(message, session: session)
}
}
nonisolated func session(_ session: WCSession, didReceiveUserInfo userInfo: [String: Any]) {
Task { @MainActor [weak self] in
self?.receive(userInfo, session: session)
}
}
}
private struct WatchRootView: View {
@ObservedObject var transport: WatchSnapshotTransport
private let clock = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
var body: some View {
NavigationStack {
if let presentation = transport.presentation {
if presentation.records.isEmpty {
WatchStateView(presentation: presentation)
} else {
List {
if presentation.state == .stale {
WatchStatusRow(presentation: presentation)
}
ForEach(presentation.records, id: \.path) { record in
NavigationLink {
WatchTotpDetail(path: record.path, transport: transport)
} label: {
VStack(alignment: .leading, spacing: 2) {
Text(record.issuer ?? record.account)
.font(.headline)
if record.issuer != nil {
Text(record.account)
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
}
}
.navigationTitle(presentation.title)
}
}
}
.onReceive(clock) { transport.refreshPresentation(at: $0) }
}
}
private struct WatchTotpDetail: View {
let path: String
@ObservedObject var transport: WatchSnapshotTransport
private var record: WatchTotpRecord? {
transport.presentation?.records.first { $0.path == path }
}
var body: some View {
if let record {
ScrollView {
VStack(spacing: 8) {
Text(record.issuer ?? record.account)
.font(.headline)
.multilineTextAlignment(.center)
Text(record.account)
.font(.caption)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
Text(record.code)
.font(.system(size: 42, weight: .bold, design: .rounded))
.monospacedDigit()
.minimumScaleFactor(0.7)
.lineLimit(1)
.privacySensitive()
ProgressView(
value: Double(min(record.remaining, record.period)),
total: Double(record.period)
)
.accessibilityLabel("Code validity")
.accessibilityValue("\(record.remaining) seconds remaining")
Text("\(record.remaining)s")
.font(.caption.monospacedDigit())
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity)
}
.navigationTitle(record.account)
} else if let presentation = transport.presentation {
WatchStateView(presentation: presentation)
}
}
}
private struct WatchStateView: View {
let presentation: WatchPresentation
var body: some View {
ContentUnavailableView(
presentation.title,
systemImage: icon,
description: Text(presentation.detail)
)
}
private var icon: String {
switch presentation.state {
case .ready: "timer"
case .empty: "iphone.and.arrow.forward"
case .syncing: "arrow.triangle.2.circlepath"
case .stale: "clock.badge.exclamationmark"
case .locked: "lock"
case .unavailable: "iphone.slash"
case .error: "exclamationmark.triangle"
}
}
}
private struct WatchStatusRow: View {
let presentation: WatchPresentation
var body: some View {
Label {
VStack(alignment: .leading) {
Text(presentation.title).font(.headline)
Text(presentation.detail)
.font(.caption)
.foregroundStyle(.secondary)
}
} icon: {
Image(systemName: "clock.badge.exclamationmark")
}
}
}
@main
struct IronStorageWatchApp: App {
@Environment(\.scenePhase) private var scenePhase
@StateObject private var transport = WatchSnapshotTransport()
var body: some Scene {
WindowGroup {
WatchRootView(transport: transport)
.onChange(of: scenePhase) { _, phase in
if phase == .active {
transport.sceneBecameActive()
} else {
transport.sceneBecameInactive()
}
}
}
}
}