320 lines
11 KiB
Swift
320 lines
11 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()
|
|
}
|
|
|
|
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) {
|
|
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 = 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 }
|
|
let acknowledgement = [Self.receiptKey: update.receipt]
|
|
if session.isReachable {
|
|
session.sendMessage(acknowledgement, replyHandler: nil) { _ in
|
|
session.transferUserInfo(acknowledgement)
|
|
}
|
|
} else {
|
|
session.transferUserInfo(acknowledgement)
|
|
}
|
|
} catch {
|
|
try? core.syncFailed()
|
|
refreshPresentation()
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
|
|
nonisolated func session(
|
|
_ session: WCSession,
|
|
didReceiveApplicationContext applicationContext: [String: Any]
|
|
) {
|
|
Task { @MainActor [weak self] in
|
|
self?.receive(applicationContext, 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)
|
|
Text(record.account)
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
Text(record.code)
|
|
.font(.system(.title3, design: .rounded, weight: .semibold))
|
|
.monospacedDigit()
|
|
.privacySensitive()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.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(.largeTitle, design: .rounded, weight: .bold))
|
|
.monospacedDigit()
|
|
.minimumScaleFactor(0.7)
|
|
.lineLimit(1)
|
|
.privacySensitive()
|
|
ProgressView(
|
|
value: Double(record.period - 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()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|