Files
buzz/mobile/ios/Runner/IdentityFilePlugin.swift
Wes 37693a81a4 feat(mobile): extend key recovery and community onboarding
Add Android HPKE sealing, bounded native key-file import, guarded export,
explicit cloud-key selection, and retry-safe self-hosted setup. Route hosted
pairing through account admission and preserve first-successful batch landing.
Keep account eligibility failures and setup progress on the entry surface.

Co-authored-by: Brain <1a02c72794dcd0f07058a353bc3a81f4028b8c77c92c87fce6d5c8b85970a20b@buzz.block.builderlab.xyz>
Signed-off-by: Wes <wesbillman@users.noreply.github.com>
2026-10-15 19:27:36 -07:00

125 lines
4.7 KiB
Swift

import Flutter
import UIKit
import UniformTypeIdentifiers
/// Reads the user-selected document in place; never imports a plaintext copy
/// into Buzz's sandbox. The security-scoped grant lasts only for the read.
final class IdentityFilePlugin: NSObject, UIDocumentPickerDelegate,
UIAdaptivePresentationControllerDelegate
{
private let channel: FlutterMethodChannel
private var pending: FlutterResult?
private var requestID: UUID?
init(messenger: FlutterBinaryMessenger) {
channel = FlutterMethodChannel(name: "buzz/identity_file", binaryMessenger: messenger)
super.init()
channel.setMethodCallHandler { [weak self] call, result in
guard let self else {
result(FlutterError(code: "import_failed", message: nil, details: nil))
return
}
guard call.method == "import" else {
result(FlutterMethodNotImplemented)
return
}
self.pick(result: result)
}
}
private func pick(result: @escaping FlutterResult) {
guard pending == nil else {
result(FlutterError(code: "import_busy", message: nil, details: nil))
return
}
// The implicit Flutter engine can initialize before a view controller is
// attached. Resolve the live scene at the user action, never cache the
// registrar's possibly-nil launch-time view controller.
let root = UIApplication.shared.connectedScenes
.compactMap { $0 as? UIWindowScene }
.filter { $0.activationState == .foregroundActive }
.flatMap(\.windows)
.first(where: \.isKeyWindow)?.rootViewController
guard var top = root else {
result(FlutterError(code: "import_failed", message: nil, details: nil))
return
}
while true {
if let presented = top.presentedViewController { top = presented }
else if let navigation = top as? UINavigationController,
let visible = navigation.visibleViewController { top = visible }
else if let tabs = top as? UITabBarController,
let selected = tabs.selectedViewController { top = selected }
else { break }
}
guard !top.isBeingDismissed, top.viewIfLoaded?.window != nil else {
result(FlutterError(code: "import_failed", message: nil, details: nil))
return
}
let picker = UIDocumentPickerViewController(forOpeningContentTypes: [.item], asCopy: false)
picker.allowsMultipleSelection = false
picker.delegate = self
pending = result
requestID = UUID()
top.present(picker, animated: true)
picker.presentationController?.delegate = self
}
func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) {
guard let url = urls.first, urls.count == 1 else {
finish(FlutterError(code: "import_failed", message: nil, details: nil))
return
}
guard let requestID else { return }
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
let scoped = url.startAccessingSecurityScopedResource()
defer { if scoped { url.stopAccessingSecurityScopedResource() } }
var coordinationError: NSError?
var text: String?
NSFileCoordinator().coordinate(readingItemAt: url, options: [], error: &coordinationError) { accessible in
text = Self.readBounded(accessible)
}
let value: Any? = coordinationError == nil && text != nil
? text : FlutterError(code: "import_failed", message: nil, details: nil)
DispatchQueue.main.async {
guard self?.requestID == requestID else { return }
self?.finish(value)
}
}
}
/// Reads at most 1025 bytes, including the one-byte oversize sentinel.
private static func readBounded(_ url: URL) -> String? {
guard let stream = InputStream(url: url) else { return nil }
stream.open()
var bytes = [UInt8](repeating: 0, count: 1025)
defer {
stream.close()
bytes.withUnsafeMutableBytes { $0.initializeMemory(as: UInt8.self, repeating: 0) }
}
var count = 0
while count < bytes.count {
let remaining = bytes.count - count
let read = bytes.withUnsafeMutableBufferPointer { buffer in
guard let base = buffer.baseAddress else { return -1 }
return stream.read(base.advanced(by: count), maxLength: remaining)
}
if read < 0 { return nil }
if read == 0 { break }
count += read
}
guard count > 0, count <= 1024 else { return nil }
return String(bytes: bytes.prefix(count), encoding: .utf8)
}
func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) { finish(nil) }
func presentationControllerDidDismiss(_ presentationController: UIPresentationController) { finish(nil) }
private func finish(_ value: Any?) {
let result = pending
pending = nil
requestID = nil
result?(value)
}
}