mobiler 0.63.0

Build mobile apps in Rust — one core, native UI on Android, iOS, and the web (CLI)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
import Foundation
import Observation
import SharedTypes
import UIKit
import PhotosUI
import UniformTypeIdentifiers

// Keeps every non-HTTP plugin compiling unchanged now that PluginResponse.output is
// bytes: they all construct responses from Strings.
extension PluginResponse {
    init(ok: Bool, output: String) {
        self.init(ok: ok, output: [UInt8](output.utf8))
    }
}

// NOTE (verify on macOS): `SharedTypes` is the facet-generated ABI types package
// (Widget/Action/Effect/Request/Requests/PluginCall/PluginResponse/...). `CoreFfi`
// comes from the uniffi-generated bindings for the `shared` crate; depending on the
// Xcode setup it's either in this same target (generated sources compiled in) or a
// module to `import`. project.yml wires that.

/// Drives the Rust core from Swift — the iOS twin of the Android `Core.kt`.
///
/// Speaks only the fixed Mobiler ABI: send an `Action`, receive a `Widget` tree +
/// capability effects. Request/response capabilities resolve **asynchronously**
/// (Swift `async/await` / `Task`), so a network call never blocks the UI — exactly
/// like the Android shell's coroutine resolution.
@MainActor
final class Core: ObservableObject {
    @Published private(set) var view: Widget

    private let core = CoreFfi()
    // Live streaming subscriptions (cx.subscribe), keyed by subscription key, so
    // cx.unsubscribe(key) can cancel the matching native source.
    private var streamTasks: [String: Task<Void, Never>] = [:]

    init() {
        // First frame straight from the core's view model.
        self.view = try! Widget.bincodeDeserialize(input: [UInt8](core.view()))
        if case let .scaffold(_, _, _, _, darkMode, theme, _, _, _, _, _, _, labels, appearance, _) = view { ActiveTheme.current = theme; ActiveLabels.current = labels; let dark = resolvedDark(appearance, darkMode); ActivePalette.current = theme?.palette.map { dark ? $0.dark : $0.light } } else { ActiveTheme.current = nil; ActiveLabels.current = nil; ActivePalette.current = nil }
        // Light/dark is a window-level override set from the root (see applyWindowStyle); the window
        // exists by the next tick.
        DispatchQueue.main.async { [weak self] in if let v = self?.view { applyWindowStyle(v) } }
        // The app's own version first, so the core's restore/init already see it (cx.app_info()).
        let info = Bundle.main.infoDictionary
        update(.appInfo(
            version: info?["CFBundleShortVersionString"] as? String ?? "",
            build: info?["CFBundleVersion"] as? String ?? "",
            platform: "ios",
            bundleId: Bundle.main.bundleIdentifier ?? ""))
        // Restore persisted state, then fire Start so the app can load initial data.
        let saved = StoragePlugin.load()
        if !saved.isEmpty { update(.restore(data: saved)) }
        update(.start)
    }

    func update(_ action: Action) {
        process(core.update(data: Data(try! action.bincodeSerialize())))
    }

    private func process(_ effectBytes: Data) {
        let requests = try! Requests.bincodeDeserialize(input: [UInt8](effectBytes)).value
        for request in requests {
            switch request.effect {
            case .render:
                self.view = try! Widget.bincodeDeserialize(input: [UInt8](core.view()))
                if case let .scaffold(_, _, _, _, darkMode, theme, _, _, _, _, _, _, labels, appearance, _) = view { ActiveTheme.current = theme; ActiveLabels.current = labels; let dark = resolvedDark(appearance, darkMode); ActivePalette.current = theme?.palette.map { dark ? $0.dark : $0.light } } else { ActiveTheme.current = nil; ActiveLabels.current = nil; ActivePalette.current = nil }
                applyWindowStyle(view)

            // Fire-and-forget: dispatch, ignore the result, don't resolve. The
            // `stream`/`unsubscribe` control notify cancels a live subscription.
            case .pluginNotify(let notify):
                if notify.plugin == "stream", notify.op == "unsubscribe" {
                    streamTasks[notify.input]?.cancel()
                    streamTasks[notify.input] = nil
                } else {
                    Task { _ = await Plugins.handle(plugin: notify.plugin, op: notify.op, input: notify.input) }
                }

            // Request/response: dispatch (awaiting async work), resolve the core
            // with the response, then process the effects that produces.
            case .plugin(let call):
                let id = request.id
                Task {
                    let resp = await Plugins.handle(plugin: call.plugin, op: call.op, input: call.input)
                    let next = core.resolve(id: id, data: Data(try! resp.bincodeSerialize()))
                    process(next)
                }

            // Streaming subscription: start a native source that resolves the SAME
            // request id repeatedly (one PluginResponse per event). `emit` hops to the
            // main actor to touch the core/view. Parked by key for unsubscribe.
            case .pluginStream(let call):
                let id = request.id
                let emit: @Sendable (PluginResponse) -> Void = { [weak self] resp in
                    Task { @MainActor in
                        guard let self else { return }
                        self.process(self.core.resolve(id: id, data: Data(try! resp.bincodeSerialize())))
                    }
                }
                streamTasks[call.key] = Task {
                    await Plugins.subscribe(plugin: call.plugin, op: call.op, input: call.input, emit: emit)
                }
            }
        }
    }
}

// MARK: - Capability plugins (the iOS twin of the Android plugin registry)

/// Built-in `ticker` stream: emits an incrementing counter every `input` ms until the
/// subscription's Task is cancelled (cx.unsubscribe). The deterministic demonstrator
/// for the streaming primitive (cx.subscribe).
enum TickerStream {
    static func run(input: String, emit: @Sendable (PluginResponse) -> Void) async {
        let ms = UInt64(input) ?? 1000
        var count = 0
        while !Task.isCancelled {
            try? await Task.sleep(nanoseconds: ms * 1_000_000)
            if Task.isCancelled { break }
            count += 1
            emit(PluginResponse(ok: true, output: String(count)))
        }
    }
}

/// Streaming dispatch for the built-in `system` source: bridges deep-link + lifecycle events from
/// SystemBridge (App.swift) into the cx.subscribe stream. Attaches on subscribe — flushing any
/// buffered launch deep-link — parks until the Task is cancelled (cx.unsubscribe), then detaches.
enum SystemStream {
    static func run(emit: @escaping @Sendable (PluginResponse) -> Void) async {
        let sink: @Sendable (String) -> Void = { emit(PluginResponse(ok: true, output: $0)) }
        // A subscription cancelled before its hop to the main actor never attaches, so it can't
        // replace a newer subscription's sink.
        let attached: Int? = await MainActor.run { Task.isCancelled ? nil : SystemBridge.shared.attach(sink) }
        guard let id = attached else { return }
        await withTaskCancellationHandler {
            while !Task.isCancelled { try? await Task.sleep(nanoseconds: 1_000_000_000) }
        } onCancel: {
            Task { @MainActor in SystemBridge.shared.detach(id) }
        }
    }
}

/// The scaffold's dark flag outside a view (Core's per-render palette resolution): `.system` reads
/// the OS via the window scene; ScaffoldView refines it with the live environment scheme.
@MainActor
func resolvedDark(_ appearance: Appearance?, _ darkMode: Bool) -> Bool {
    switch appearance {
    case .some(.light): return false
    case .some(.dark): return true
    case .some(.system): return AppearanceBridge.osDark()
    case .none: return darkMode
    }
}

/// Light/dark for the whole window, from the ROOT view: a scaffold's appearance (System → the OS,
/// `.unspecified`) or its `dark_mode`; any other root follows the OS. A window-level override (not
/// SwiftUI's `preferredColorScheme`, which doesn't reliably let go when set back to nil) — and it sits
/// below the window scene, so the scene's trait always stays the OS value the appearance query reads.
@MainActor
func applyWindowStyle(_ view: Widget) {
    let style: UIUserInterfaceStyle
    if case let .scaffold(_, _, _, _, darkMode, _, _, _, _, _, _, _, _, appearance, _) = view {
        switch appearance {
        case .some(.system): style = .unspecified
        case .some(.light): style = .light
        case .some(.dark): style = .dark
        case .none: style = darkMode ? .dark : .light
        }
    } else {
        style = .unspecified
    }
    for scene in UIApplication.shared.connectedScenes.compactMap({ $0 as? UIWindowScene }) {
        for window in scene.windows where window.overrideUserInterfaceStyle != style {
            window.overrideUserInterfaceStyle = style
        }
    }
}

/// The OS light/dark setting: the active window scene's trait (system-level — the app's own
/// light/dark override is set on its windows, below the scene). iOS 17 trait-change registration feeds
/// the `appearance` stream; every subscription (key) gets its own sink, and the scene registration
/// lives while at least one is attached.
@MainActor
final class AppearanceBridge {
    static let shared = AppearanceBridge()
    private var sinks: [UUID: @Sendable (String) -> Void] = [:]
    private var registration: (any UITraitChangeRegistration)?
    private weak var registeredScene: UIWindowScene?
    private var last: String?
    private var activation: NSObjectProtocol?

    static func scene() -> UIWindowScene? {
        let scenes = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }
        return scenes.first { $0.activationState == .foregroundActive } ?? scenes.first
    }
    static func osDark() -> Bool {
        (scene()?.traitCollection.userInterfaceStyle ?? UITraitCollection.current.userInterfaceStyle) == .dark
    }
    private static func name(_ dark: Bool) -> String { dark ? "dark" : "light" }

    /// Add a subscriber: it gets the current value at once; returns its token for `detach`.
    func attach(_ sink: @escaping @Sendable (String) -> Void) -> UUID {
        let id = UUID()
        sinks[id] = sink
        let now = Self.name(Self.osDark())
        sink(now)
        if last == nil { last = now }
        registerIfNeeded()
        if activation == nil {
            // A scene that (re)activates — first launch, reconnect after a long background, iPad
            // multi-window — gets the registration, and a change made meanwhile is delivered.
            activation = NotificationCenter.default.addObserver(forName: UIScene.didActivateNotification, object: nil, queue: .main) { [weak self] _ in
                MainActor.assumeIsolated {
                    self?.registerIfNeeded()
                    self?.changed(Self.name(Self.osDark()))
                }
            }
        }
        return id
    }
    /// Register on the current scene if it isn't the one already registered (none yet, or replaced).
    private func registerIfNeeded() {
        guard !sinks.isEmpty, let scene = Self.scene(), scene !== registeredScene else { return }
        if let r = registration { registeredScene?.unregisterForTraitChanges(r) }
        registeredScene = scene
        registration = scene.registerForTraitChanges([UITraitUserInterfaceStyle.self]) { [weak self] (scene: UIWindowScene, _: UITraitCollection) in
            self?.changed(Self.name(scene.traitCollection.userInterfaceStyle == .dark))
        }
    }
    private func changed(_ value: String) {
        // iOS flips traits while it snapshots a backgrounded app for the switcher — ignore those; the
        // activation observer re-reads the real value when the app comes back.
        guard UIApplication.shared.applicationState != .background else { return }
        guard value != last else { return }
        last = value
        for sink in sinks.values { sink(value) }
    }
    /// Remove one subscriber; the scene registration goes with the last one.
    func detach(_ id: UUID) {
        sinks[id] = nil
        guard sinks.isEmpty else { return }
        if let r = registration { registeredScene?.unregisterForTraitChanges(r) }
        registration = nil
        registeredScene = nil
        last = nil
        if let a = activation { NotificationCenter.default.removeObserver(a) }
        activation = nil
    }
}

/// Built-in `appearance` stream: the OS light/dark setting — the current value first, then each change.
enum AppearanceStream {
    static func run(emit: @escaping @Sendable (PluginResponse) -> Void) async {
        let sink: @Sendable (String) -> Void = { emit(PluginResponse(ok: true, output: $0)) }
        let id = await MainActor.run { AppearanceBridge.shared.attach(sink) }
        await withTaskCancellationHandler {
            while !Task.isCancelled { try? await Task.sleep(nanoseconds: 1_000_000_000) }
        } onCancel: {
            Task { @MainActor in AppearanceBridge.shared.detach(id) }
        }
    }
}

/// Dispatches the opaque `{plugin, op, input}` envelope by name. Adding a plugin
/// never touches the wire ABI — only this registry.
enum Plugins {
    /// Streaming dispatch (cx.subscribe): start a long-lived source that calls `emit`
    /// per event until cancelled. Streaming-capable capabilities are matched here.
    static func subscribe(plugin: String, op: String, input: String, emit: @escaping @Sendable (PluginResponse) -> Void) async {
        switch plugin {
        case "ticker": await TickerStream.run(input: input, emit: emit)
        case "system": await SystemStream.run(emit: emit)
        case "appearance": await AppearanceStream.run(emit: emit)
        // mobiler:plugins-stream — streaming plugins inserted above this line
        default: break
        }
    }

    static func handle(plugin: String, op: String, input: String) async -> PluginResponse {
        switch plugin {
        case "http": return await HttpPlugin.handle(op: op, input: input)
        case "storage": return StoragePlugin.handle(op: op, input: input)
        case "clipboard": return await ClipboardPlugin.handle(op: op, input: input)
        case "share": return await SharePlugin.handle(op: op, input: input)
        case "browser": return await BrowserPlugin.handle(op: op, input: input)
        case "toast": return await ToastPlugin.handle(op: op, input: input)
        case "snackbar": return await SnackbarPlugin.handle(op: op, input: input)
        case "device": return await DevicePlugin.handle(op: op, input: input)
        case "haptics": return await HapticsPlugin.handle(op: op, input: input)
        case "dialog": return await DialogPlugin.handle(op: op, input: input)
        case "datetime": return await DateTimePlugin.handle(op: op, input: input)
        case "photo": return await PhotoPlugin.handle(op: op, input: input)
        case "camera": return await CameraPlugin.handle(op: op, input: input)
        // mobiler:plugins — `mobiler plugin add` inserts plugin cases above this line
        default:
            return PluginResponse(ok: false, output: "plugin '\(plugin)' not available in this build")
        }
    }
}

/// HTTP capability (paired with `cx.request`/`get`/`post`/`put`/... in Rust). `op` is
/// the method; `input` is `{"url":..., "headers":[{"name":...,"value":...}], "body":...}`.
/// Returns a bincode `HttpOutcome` in `output`; `ok` = 2xx.
enum HttpPlugin {
    static func handle(op: String, input: String) async -> PluginResponse {
        guard
            let data = input.data(using: .utf8),
            let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
            let urlString = obj["url"] as? String,
            let url = URL(string: urlString)
        else {
            return transportError("invalid http request envelope")
        }

        var req = URLRequest(url: url)
        req.httpMethod = op

        var callerSetContentType = false
        if let headers = obj["headers"] as? [[String: Any]] {
            for h in headers {
                guard let name = h["name"] as? String, let value = h["value"] as? String else { continue }
                req.addValue(value, forHTTPHeaderField: name)
                if name.lowercased() == "content-type" { callerSetContentType = true }
            }
        }

        if let body = obj["body"] as? String {
            req.httpBody = body.data(using: .utf8)
            if !callerSetContentType {
                req.setValue("application/json", forHTTPHeaderField: "Content-Type")
            }
        }

        do {
            let (respData, resp) = try await URLSession.shared.data(for: req)
            guard let http = resp as? HTTPURLResponse else {
                return transportError("non-HTTP response")
            }
            let headers = http.allHeaderFields.compactMap { key, value -> HttpHeader? in
                guard let name = key as? String else { return nil }
                return HttpHeader(name: name, value: String(describing: value))
            }
            // Clamp rather than trap: `UInt16(_:)` crashes on an out-of-range status
            // code, and a hard crash in the shell is worse than a clamped value.
            let status = UInt16(clamping: http.statusCode)
            let outcome = HttpOutcome.response(status: status, headers: headers, body: [UInt8](respData))
            return PluginResponse(ok: (200..<300).contains(http.statusCode), output: encode(outcome))
        } catch {
            // URLSession throws only when no response was obtained.
            return transportError(error.localizedDescription)
        }
    }

    private static func encode(_ outcome: HttpOutcome) -> [UInt8] {
        (try? outcome.bincodeSerialize()) ?? []
    }

    private static func transportError(_ message: String) -> PluginResponse {
        PluginResponse(ok: false, output: encode(.transportError(message: message)))
    }
}

/// Persistence capability (paired with `cx.save` + `restore`). Backed by UserDefaults.
enum StoragePlugin {
    private static let key = "mobiler.state"
    static func load() -> String { UserDefaults.standard.string(forKey: key) ?? "" }
    static func handle(op: String, input: String) -> PluginResponse {
        switch op {
        case "save": UserDefaults.standard.set(input, forKey: key); return PluginResponse(ok: true, output: "")
        case "load": return PluginResponse(ok: true, output: load())
        default: return PluginResponse(ok: false, output: "unknown op '\(op)'")
        }
    }
}

/// Clipboard capability — copy text (UIPasteboard is main-actor only).
@MainActor
enum ClipboardPlugin {
    static func handle(op: String, input: String) -> PluginResponse {
        UIPasteboard.general.string = input
        return PluginResponse(ok: true, output: "")
    }
}

/// Share capability — the system share sheet (UIActivityViewController).
@MainActor
enum SharePlugin {
    static func handle(op: String, input: String) -> PluginResponse {
        guard let presenter = topViewController() else {
            return PluginResponse(ok: false, output: "no view controller to present from")
        }
        let sheet = UIActivityViewController(activityItems: [input], applicationActivities: nil)
        sheet.popoverPresentationController?.sourceView = presenter.view // iPad anchor
        presenter.present(sheet, animated: true)
        return PluginResponse(ok: true, output: "")
    }
}

/// Open a URL externally (Safari / the default handler).
@MainActor
enum BrowserPlugin {
    /// Honest result (cx.open_url_then): iOS reports whether an app took the link — e.g. `tel:` on
    /// an iPad without calling answers `ok: false`.
    static func handle(op: String, input: String) async -> PluginResponse {
        guard let url = URL(string: input) else {
            return PluginResponse(ok: false, output: "invalid url")
        }
        let opened = await UIApplication.shared.open(url)
        if opened { return PluginResponse(ok: true, output: "opened") }
        // A declined confirmation (the tel: "Call …?" prompt) also reports false; only say nothing
        // can open it when that is actually true.
        return PluginResponse(ok: false, output: UIApplication.shared.canOpenURL(url) ? "cancelled" : "no app can open this link")
    }
}

/// The hardware identifier (`utsname.machine`, e.g. "iPhone15,2"); on the simulator, the simulated one.
private func hardwareModel() -> String {
    if let sim = ProcessInfo.processInfo.environment["SIMULATOR_MODEL_IDENTIFIER"] { return sim }
    var sys = utsname()
    uname(&sys)
    return withUnsafeBytes(of: &sys.machine) { raw in
        String(decoding: raw.prefix(while: { $0 != 0 }), as: UTF8.self)
    }
}

/// Device info — request/response. `model` returns e.g. "Apple iPhone (iOS 18.0)".
@MainActor
enum DevicePlugin {
    static func handle(op: String, input: String) -> PluginResponse {
        switch op {
        case "model":
            let d = UIDevice.current
            return PluginResponse(ok: true, output: "Apple \(d.model) (\(d.systemName) \(d.systemVersion))")
        case "info":
            // Structured (cx.device_info): OS version and the hardware identifier ("iPhone15,2"),
            // not the user-set device name. The simulator reports its simulated model.
            let info = DeviceInfo(osVersion: UIDevice.current.systemVersion, osApiLevel: 0, manufacturer: "Apple", model: hardwareModel())
            return PluginResponse(ok: true, output: (try? info.bincodeSerialize()) ?? [])
        case "locale":
            return PluginResponse(ok: true, output: Locale.preferredLanguages.first ?? Locale.current.identifier)
        case "appearance":
            // The OS setting, whatever the app forces.
            return PluginResponse(ok: true, output: AppearanceBridge.osDark() ? "dark" : "light")
        default:
            return PluginResponse(ok: false, output: "unknown op '\(op)'")
        }
    }
}

/// Haptic tap — iOS has no permission requirement. `op` is the style.
@MainActor
enum HapticsPlugin {
    static func handle(op: String, input: String) -> PluginResponse {
        let style: UIImpactFeedbackGenerator.FeedbackStyle = switch op {
        case "light": .light
        case "heavy": .heavy
        default: .medium
        }
        UIImpactFeedbackGenerator(style: style).impactOccurred()
        return PluginResponse(ok: true, output: "")
    }
}

/// Toast — iOS has no native toast, so show a transient padded label in the key
/// window (the SwiftUI/UIKit twin of Android's Toast / the web's `.toast` div).
@MainActor
enum ToastPlugin {
    static func handle(op: String, input: String) -> PluginResponse {
        guard let window = keyWindow() else { return PluginResponse(ok: false, output: "no window") }
        let label = PaddedLabel()
        label.text = input
        label.numberOfLines = 0
        label.textColor = .white
        label.textAlignment = .center
        label.font = .systemFont(ofSize: 14)
        label.backgroundColor = UIColor.black.withAlphaComponent(0.85)
        label.layer.cornerRadius = 18
        label.clipsToBounds = true
        label.alpha = 0
        label.translatesAutoresizingMaskIntoConstraints = false
        window.addSubview(label)
        NSLayoutConstraint.activate([
            label.centerXAnchor.constraint(equalTo: window.centerXAnchor),
            label.bottomAnchor.constraint(equalTo: window.safeAreaLayoutGuide.bottomAnchor, constant: -32),
            label.leadingAnchor.constraint(greaterThanOrEqualTo: window.leadingAnchor, constant: 24),
            label.trailingAnchor.constraint(lessThanOrEqualTo: window.trailingAnchor, constant: -24),
        ])
        UIView.animate(withDuration: 0.2) { label.alpha = 1 }
        UIView.animate(withDuration: 0.3, delay: 2.3) { label.alpha = 0 } completion: { _ in label.removeFromSuperview() }
        return PluginResponse(ok: true, output: "")
    }
}

/// The snackbar on screen, drawn by `ScaffoldView` (`SnackbarView`). `nonisolated(unsafe)` like
/// `ActiveTheme`: only ever touched on the main thread, from the plugin and the view.
@Observable final class SnackbarHost {
    nonisolated(unsafe) static let shared = SnackbarHost()
    struct Request: Identifiable {
        let id: Int
        let text: String
        let action: String?
        let seconds: Double
    }
    var current: Request?
    /// ScaffoldViews on screen: the timeout runs in `SnackbarView`, so with none there'd be no end.
    @ObservationIgnored var hosts = 0
    @ObservationIgnored private var answer: ((String) -> Void)?
    @ObservationIgnored private var nextId = 0

    /// Show a snackbar; a visible one answers "replaced" first (one at a time).
    func show(text: String, action: String?, seconds: Double, answer: @escaping (String) -> Void) {
        finish("replaced")
        nextId += 1
        current = Request(id: nextId, text: text, action: action, seconds: seconds)
        self.answer = answer
    }

    /// Resolve and hide the visible snackbar — only if it's still request `id`, when one is given
    /// (a late timer or swipe must not close a newer snackbar).
    func finish(_ outcome: String, id: Int? = nil) {
        guard let cur = current, id == nil || id == cur.id else { return }
        let a = answer
        answer = nil
        current = nil
        a?(outcome)
    }
}

/// Built-in `snackbar` capability (request/response). Input is JSON {text, action_label?, duration:
/// "short"|"long"}. ok=true "action" when the action is tapped; else ok=false "timeout", "dismissed"
/// (swiped down) or "replaced" (a newer snackbar).
@MainActor
enum SnackbarPlugin {
    static func handle(op: String, input: String) async -> PluginResponse {
        guard op == "show" else { return PluginResponse(ok: false, output: "unknown op '\(op)'") }
        let obj = (try? JSONSerialization.jsonObject(with: Data(input.utf8))) as? [String: Any]
        let text = obj?["text"] as? String ?? ""
        let action = (obj?["action_label"] as? String).flatMap { $0.isEmpty ? nil : $0 }
        var seconds: Double = (obj?["duration"] as? String) == "long" ? 10 : 4
        // VoiceOver needs time to reach the action (Android's M3 host lengthens it the same way).
        if action != nil && UIAccessibility.isVoiceOverRunning { seconds = max(seconds, 10) }
        // No scaffold on screen → nothing to show it on, and nothing would ever time it out.
        if SnackbarHost.shared.hosts == 0 { return PluginResponse(ok: false, output: "timeout") }
        let outcome: String = await withCheckedContinuation { cont in
            SnackbarHost.shared.show(text: text, action: action, seconds: seconds) { cont.resume(returning: $0) }
        }
        return PluginResponse(ok: outcome == "action", output: outcome)
    }
}

/// Confirm dialog — request/response. Presents a UIAlertController and awaits the
/// user's choice (`ok` = confirmed) via a continuation, so the core resolves only
/// once they tap. Input is JSON {title, message, confirm_label?, cancel_label?, destructive?}; system alerts keep their own size.
@MainActor
enum DialogPlugin {
    /// The confirm alert currently on screen and how to answer it. A new confirm answers it
    /// `ok: false` and dismisses it first — one confirm at a time on every shell.
    /// `nonisolated(unsafe)` (like `ActiveTheme`/`ActiveLabels`): it's touched from the nested
    /// `func done` below (called from the alert-action handlers), which the compiler treats as
    /// nonisolated — though it always runs on the main thread.
    nonisolated(unsafe) private static var openConfirm: (alert: UIAlertController, answer: (PluginResponse) -> Void)?

    /// `topViewController()`, but walking past any `UIAlertController` that's already
    /// mid-dismissal (each one is still `presentedViewController` for a moment, yet presenting on
    /// top of it fails) to its own presenter instead — not just the current `openConfirm`'s alert,
    /// since a third confirm can arrive while an earlier one is still only queued behind that
    /// dismissal (its own alert never presented, so it can't be the one we see here).
    private static func topViewControllerForConfirm() -> UIViewController? {
        var top = topViewController()
        while let alert = top as? UIAlertController, alert.isBeingDismissed {
            top = alert.presentingViewController
        }
        return top
    }

    static func handle(op: String, input: String) async -> PluginResponse {
        guard op == "confirm" else { return PluginResponse(ok: false, output: "unknown op '\(op)'") }
        let obj = (try? JSONSerialization.jsonObject(with: Data(input.utf8))) as? [String: Any]
        let title = obj?["title"] as? String ?? ""
        let message = obj?["message"] as? String ?? ""
        // Optional app labels; a plain `cx.confirm` sends none and falls back to the scaffold's
        // ShellLabels, then OK / Cancel.
        let confirmLabel = (obj?["confirm_label"] as? String).flatMap { $0.isEmpty ? nil : $0 } ?? ((ActiveLabels.current?.ok).flatMap { $0.isEmpty ? nil : $0 } ?? "OK")
        let cancelLabel = (obj?["cancel_label"] as? String).flatMap { $0.isEmpty ? nil : $0 } ?? ((ActiveLabels.current?.cancel).flatMap { $0.isEmpty ? nil : $0 } ?? "Cancel")
        let destructive = obj?["destructive"] as? Bool ?? false

        // One confirm at a time: a new confirm answers the open one ok:false before we even look
        // for a view controller to present the new one from — unless it's already being dismissed
        // (the user just tapped it), in which case its own handler is about to answer it, so we
        // leave it alone. Either way, the actual UIKit dismissal (ours, or the one already running)
        // happens in `presentWhenReady` below, and the new alert is presented only once it's done —
        // never while a dismissal is in flight.
        //
        // A previous confirm that was itself still queued behind an EARLIER dismissal never
        // actually reached `presenter.present` — its `presentingViewController` is still nil, so
        // there's nothing on screen for it to dismiss. Answer it, but don't queue it as
        // `prevToDismiss`; a stray `dismiss(animated:false)` with no real presentation behind it
        // would otherwise land on whatever view controller `topViewControllerForConfirm()` finds
        // next (possibly an unrelated app sheet) and dismiss that instead.
        var prevToDismiss: UIAlertController?
        if let prev = openConfirm, !prev.alert.isBeingDismissed {
            if prev.alert.presentingViewController != nil {
                prevToDismiss = prev.alert
            }
            openConfirm = nil
            prev.answer(PluginResponse(ok: false, output: "cancel"))
        }

        guard let presenter = prevToDismiss?.presentingViewController ?? topViewControllerForConfirm() else {
            return PluginResponse(ok: false, output: "no view controller to present from")
        }
        return await withCheckedContinuation { cont in
            let alert = UIAlertController(
                title: title.isEmpty ? nil : title, message: message, preferredStyle: .alert)
            // Identity only, never a strong capture of `alert` — the closure below is retained by
            // the UIAlertAction, which is retained by `alert` itself, so capturing `alert` there
            // would be a retain cycle.
            let alertID = ObjectIdentifier(alert)
            var resumed = false
            func done(_ r: PluginResponse) {
                if !resumed {
                    resumed = true
                    if openConfirm.map({ ObjectIdentifier($0.alert) }) == alertID { openConfirm = nil }
                    cont.resume(returning: r)
                }
            }
            alert.addAction(UIAlertAction(title: cancelLabel, style: .cancel) { _ in
                done(PluginResponse(ok: false, output: "cancel"))
            })
            alert.addAction(UIAlertAction(title: confirmLabel, style: destructive ? .destructive : .default) { _ in
                done(PluginResponse(ok: true, output: "ok"))
            })
            openConfirm = (alert, done)
            presentWhenReady(alert, from: presenter, afterDismissing: prevToDismiss)
        }
    }

    /// Presents `alert` from `presenter`, but never while a dismissal is in flight — presenting on
    /// top of an alert that's still animating away silently fails. Three cases:
    /// - `prevToDismiss` (the previous confirm) is still on screen: dismiss it ourselves and
    ///   present in that dismissal's completion.
    /// - Nothing of ours needs dismissing, but the front-most controller already is an alert
    ///   mid-dismissal (the user tapped it directly, and iOS is still animating it away): ride that
    ///   transition's completion instead of racing it — or, if the transition can't be ridden (no
    ///   coordinator, or `animate(alongsideTransition:)` itself fails to schedule), fall back to
    ///   presenting on the next run loop turn.
    /// - Otherwise: nothing is dismissing, so present immediately.
    ///
    /// Every one of those goes through `presentIfStillCurrent` rather than presenting directly:
    /// by the time a deferred one actually runs, a THIRD confirm may already have superseded and
    /// answered `alert` (`openConfirm` no longer names it), or `presenter` may no longer be able to
    /// accept a presentation at all — either way, presenting would silently do nothing and leave
    /// the caller's continuation hanging forever.
    ///
    /// `@MainActor`, the default for a member of this enum: the `withCheckedContinuation` body in
    /// `handle` that calls this, and the UIKit completion closures inside it (`dismiss`'s
    /// completion, a transition coordinator's `animate(alongsideTransition:)` completion,
    /// `DispatchQueue.main.async`), all run main-actor-isolated — unlike the nested `func done`
    /// above, which the compiler treats as nonisolated and which is why `openConfirm` itself needs
    /// `nonisolated(unsafe)`.
    @MainActor
    private static func presentWhenReady(_ alert: UIAlertController, from presenter: UIViewController, afterDismissing prevToDismiss: UIAlertController?) {
        if let prevToDismiss {
            let prevPresenter = prevToDismiss.presentingViewController ?? presenter
            prevPresenter.dismiss(animated: false) {
                presentIfStillCurrent(alert, on: presenter)
            }
            return
        }
        let top = topViewController()
        if let dismissing = top as? UIAlertController, dismissing.isBeingDismissed {
            let scheduled = dismissing.transitionCoordinator?.animate(alongsideTransition: nil) { _ in
                presentIfStillCurrent(alert, on: presenter)
            }
            if scheduled != true {
                DispatchQueue.main.async { presentIfStillCurrent(alert, on: presenter) }
            }
            return
        }
        presentIfStillCurrent(alert, on: presenter)
    }

    /// Presents `alert` on `presenter` — but only if `alert` is still `openConfirm` (a third
    /// confirm arriving while this one was deferred may already have superseded and answered it —
    /// see `presentWhenReady`) and `presenter` can actually accept a presentation right now (still
    /// in a window, and not already presenting something else). When either isn't true, `alert` is
    /// resolved right here through its own stored answer instead of being silently dropped, so a
    /// deferred present that can never happen still resolves the waiting continuation exactly once,
    /// the same way `done` itself would.
    ///
    /// `@MainActor`, like `presentWhenReady` above: the deferred UIKit completion closures that
    /// call this — `dismiss`'s completion, a transition coordinator's
    /// `animate(alongsideTransition:)` completion, `DispatchQueue.main.async` — all run
    /// main-actor-isolated, so this needs no isolation override of its own; only `openConfirm`
    /// (touched from the genuinely nonisolated nested `func done`) does.
    @MainActor
    private static func presentIfStillCurrent(_ alert: UIAlertController, on presenter: UIViewController) {
        let alertID = ObjectIdentifier(alert)
        guard openConfirm.map({ ObjectIdentifier($0.alert) }) == alertID else { return }
        guard presenter.view.window != nil, presenter.presentedViewController == nil else {
            let current = openConfirm
            openConfirm = nil
            current?.answer(PluginResponse(ok: false, output: "no view controller to present from"))
            return
        }
        presenter.present(alert, animated: true)
    }
}

/// Native date / time capability — request/response. `op`:
///   "now"  → the current local date-time as "yyyy-MM-dd HH:mm:ss" (no UI, resolves immediately),
///   "date" → a picker returning ISO "YYYY-MM-DD", "time" → a picker returning 24-hour "HH:MM".
/// Pickers resolve `ok=false` on cancel. The pickers present a UIDatePicker in an action sheet.
@MainActor
enum DateTimePlugin {
    static func handle(op: String, input: String) async -> PluginResponse {
        // "now" needs no UI — stamp the current local date-time and return at once.
        if op == "now" {
            let fmt = DateFormatter()
            fmt.locale = Locale(identifier: "en_US_POSIX")
            fmt.dateFormat = "yyyy-MM-dd HH:mm:ss"
            return PluginResponse(ok: true, output: fmt.string(from: Date()))
        }
        let mode: UIDatePicker.Mode
        switch op {
        case "date": mode = .date
        case "time": mode = .time
        default: return PluginResponse(ok: false, output: "unknown op '\(op)'")
        }
        // Optional app labels ({title?, confirm_label?, cancel_label?}); "" (plain pick_date) → defaults.
        let labels = (try? JSONSerialization.jsonObject(with: Data(input.utf8))) as? [String: Any]
        func label(_ key: String, _ fallback: String) -> String {
            (labels?[key] as? String).flatMap { $0.isEmpty ? nil : $0 } ?? fallback
        }
        guard let presenter = topViewController() else {
            return PluginResponse(ok: false, output: "no view controller to present from")
        }
        return await withCheckedContinuation { cont in
            let picker = UIDatePicker()
            picker.datePickerMode = mode
            picker.preferredDatePickerStyle = .wheels
            picker.translatesAutoresizingMaskIntoConstraints = false

            // An action sheet with blank message lines reserves room for the wheel picker.
            let alert = UIAlertController(
                title: label("title", op == "date" ? "Pick a date" : "Pick a time"),
                message: "\n\n\n\n\n\n\n\n\n", preferredStyle: .actionSheet)
            alert.view.addSubview(picker)
            NSLayoutConstraint.activate([
                picker.centerXAnchor.constraint(equalTo: alert.view.centerXAnchor),
                picker.topAnchor.constraint(equalTo: alert.view.topAnchor, constant: 48),
                picker.widthAnchor.constraint(equalTo: alert.view.widthAnchor, constant: -16),
            ])

            let fmt = DateFormatter()
            fmt.locale = Locale(identifier: "en_US_POSIX")
            fmt.dateFormat = op == "date" ? "yyyy-MM-dd" : "HH:mm"

            var resumed = false
            func done(_ r: PluginResponse) { if !resumed { resumed = true; cont.resume(returning: r) } }
            alert.addAction(UIAlertAction(title: label("cancel_label", (ActiveLabels.current?.cancel).flatMap { $0.isEmpty ? nil : $0 } ?? "Cancel"), style: .cancel) { _ in
                done(PluginResponse(ok: false, output: "cancel"))
            })
            alert.addAction(UIAlertAction(title: label("confirm_label", (ActiveLabels.current?.done).flatMap { $0.isEmpty ? nil : $0 } ?? "Done"), style: .default) { _ in
                done(PluginResponse(ok: true, output: fmt.string(from: picker.date)))
            })
            // iPad presents action sheets in a popover, which needs a source.
            alert.popoverPresentationController?.sourceView = presenter.view
            alert.popoverPresentationController?.sourceRect = CGRect(
                x: presenter.view.bounds.midX, y: presenter.view.bounds.midY, width: 0, height: 0)
            presenter.present(alert, animated: true)
        }
    }
}

/// Photo picker — request/response, permission-less (the system PHPicker). Loads the
/// pick into a temp file and returns its `file://` URL (which the image widget renders).
@MainActor
enum PhotoPlugin {
    static func handle(op: String, input: String) async -> PluginResponse {
        guard op == "pick" else { return PluginResponse(ok: false, output: "unknown op '\(op)'") }
        guard let presenter = topViewController() else {
            return PluginResponse(ok: false, output: "no view controller to present from")
        }
        return await withCheckedContinuation { cont in
            var config = PHPickerConfiguration()
            config.filter = .images
            config.selectionLimit = 1
            let picker = PHPickerViewController(configuration: config)
            let delegate = PhotoPickerDelegate { cont.resume(returning: $0) }
            PhotoPickerDelegate.retained = delegate // PHPicker holds its delegate weakly
            picker.delegate = delegate
            presenter.present(picker, animated: true)
        }
    }
}

private final class PhotoPickerDelegate: NSObject, PHPickerViewControllerDelegate {
    static var retained: PhotoPickerDelegate?
    private let onResult: (PluginResponse) -> Void
    init(onResult: @escaping (PluginResponse) -> Void) { self.onResult = onResult }

    func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
        picker.dismiss(animated: true)
        guard let provider = results.first?.itemProvider else {
            finish(PluginResponse(ok: false, output: "cancelled")); return
        }
        // Copy the pick to our own temp file (the system one is short-lived) and return it.
        provider.loadFileRepresentation(forTypeIdentifier: UTType.image.identifier) { url, error in
            guard let url else {
                self.finish(PluginResponse(ok: false, output: error?.localizedDescription ?? "load failed")); return
            }
            let ext = url.pathExtension.isEmpty ? "jpg" : url.pathExtension
            let dest = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString + "." + ext)
            do {
                try FileManager.default.copyItem(at: url, to: dest)
                self.finish(PluginResponse(ok: true, output: dest.absoluteString))
            } catch {
                self.finish(PluginResponse(ok: false, output: error.localizedDescription))
            }
        }
    }

    private func finish(_ r: PluginResponse) {
        onResult(r)
        PhotoPickerDelegate.retained = nil
    }
}

/// Camera capture — request/response. Launches UIImagePickerController(.camera), writes
/// the shot to a temp file and returns its `file://` URL. Requires NSCameraUsageDescription
/// (project.yml). The simulator has no camera, so it returns ok:false there; on a device it
/// presents the system camera. No separate permission API call — iOS prompts on first use.
@MainActor
enum CameraPlugin {
    static func handle(op: String, input: String) async -> PluginResponse {
        guard op == "capture" else { return PluginResponse(ok: false, output: "unknown op '\(op)'") }
        guard UIImagePickerController.isSourceTypeAvailable(.camera) else {
            return PluginResponse(ok: false, output: "camera not available")
        }
        guard let presenter = topViewController() else {
            return PluginResponse(ok: false, output: "no view controller to present from")
        }
        return await withCheckedContinuation { cont in
            let picker = UIImagePickerController()
            picker.sourceType = .camera
            let delegate = CameraCaptureDelegate { cont.resume(returning: $0) }
            CameraCaptureDelegate.retained = delegate // the picker holds its delegate weakly
            picker.delegate = delegate
            presenter.present(picker, animated: true)
        }
    }
}

private final class CameraCaptureDelegate: NSObject, UIImagePickerControllerDelegate, UINavigationControllerDelegate {
    static var retained: CameraCaptureDelegate?
    private let onResult: (PluginResponse) -> Void
    init(onResult: @escaping (PluginResponse) -> Void) { self.onResult = onResult }

    func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]) {
        picker.dismiss(animated: true)
        guard let image = info[.originalImage] as? UIImage, let data = image.jpegData(compressionQuality: 0.9) else {
            finish(PluginResponse(ok: false, output: "no image")); return
        }
        let dest = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString + ".jpg")
        do {
            try data.write(to: dest)
            finish(PluginResponse(ok: true, output: dest.absoluteString))
        } catch {
            finish(PluginResponse(ok: false, output: error.localizedDescription))
        }
    }

    func imagePickerControllerDidCancel(_ picker: UIImagePickerController) {
        picker.dismiss(animated: true)
        finish(PluginResponse(ok: false, output: "cancelled"))
    }

    private func finish(_ r: PluginResponse) {
        onResult(r)
        CameraCaptureDelegate.retained = nil
    }
}

/// A UILabel with inner padding (UILabel alone has none) — for the toast pill.
private final class PaddedLabel: UILabel {
    private let insets = UIEdgeInsets(top: 10, left: 18, bottom: 10, right: 18)
    override func drawText(in rect: CGRect) { super.drawText(in: rect.inset(by: insets)) }
    override var intrinsicContentSize: CGSize {
        let s = super.intrinsicContentSize
        return CGSize(width: s.width + insets.left + insets.right, height: s.height + insets.top + insets.bottom)
    }
}

/// The active key window — where the shell hangs modals/toasts (it owns no VC).
@MainActor
private func keyWindow() -> UIWindow? {
    (UIApplication.shared.connectedScenes
        .first { $0.activationState == .foregroundActive } as? UIWindowScene)?.keyWindow
}

/// The frontmost view controller — modals (the share sheet) present from here.
@MainActor
private func topViewController() -> UIViewController? {
    var top = keyWindow()?.rootViewController
    while let presented = top?.presentedViewController { top = presented }
    return top
}