tauri-plugin-system-components 0.1.3

Native system UI components for Tauri 2 — native iOS tab bar over the webview, native controls, and glass window backgrounds on macOS/iOS.
Documentation
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
//
//  SystemComponentsPlugin.swift
//  tauri-plugin-system-components
//
//  Mounts a native UITabBar (Liquid Glass on iOS 26) over the Tauri webview
//  and forwards tab selection to JS via the plugin event channel
//  (`addPluginListener('system-components', 'tabSelected', …)`).
//
//  Commands arrive on Tauri's ipc queue; all UIKit work hops to the main
//  queue (same pattern as the official plugins).
//

import Foundation
import Tauri
import UIKit
import WebKit

class TabItemArgs: Decodable {
    let id: String
    let title: String
    let sfSymbol: String?
    /// Bitmap icon as base64 (raw or data: URL) — e.g. a user avatar.
    /// Takes precedence over sfSymbol.
    let image: String?
    /// Clip the bitmap to a circle (avatar style).
    let circular: Bool?
    let badge: String?
}

/// The standalone account button beside the bar (Apple Music search-button
/// style). `image` (base64 / data URL) takes precedence over `sfSymbol`.
class AccessoryArgs: Decodable {
    let id: String
    let sfSymbol: String?
    let image: String?
}

class ConfigureTabBarArgs: Decodable {
    let items: [TabItemArgs]
    let selectedId: String?
    /// Hex accent color — applied to the selected item via the bar appearance.
    let tint: String?
    /// Optional circular account button floated beside the bar.
    let accessory: AccessoryArgs?
}

class SelectTabArgs: Decodable {
    let id: String
}

class SetBadgeArgs: Decodable {
    let id: String
    let value: String?
}

class SheetOptionArgs: Decodable {
    let value: String
    let label: String
}

class SheetRowArgs: Decodable {
    let id: String
    /// header | action | text | textfield | toggle | datetime | select | submit.
    /// Defaults to `action` (or `header` when `header == true`, for back-compat).
    let kind: String?
    let label: String
    let detail: String?
    /// Bitmap (base64 / data URL) — e.g. the account avatar. Wins over `sfSymbol`.
    let image: String?
    let sfSymbol: String?
    let badge: String?
    let destructive: Bool?
    /// Back-compat: `header: true` is the same as `kind: "header"`.
    let header: Bool?
    // Form fields:
    let value: String?
    let on: Bool?
    let placeholder: String?
    let options: [SheetOptionArgs]?
}

class PresentSheetArgs: Decodable {
    let id: String
    let tint: String?
    let rows: [SheetRowArgs]
}

class DismissSheetArgs: Decodable {
    let id: String
}

class SystemComponentsPlugin: Plugin {
    private var overlay: TabBarOverlayController?
    private var componentsOverlay: ComponentsOverlayController?
    private var webView: WKWebView?
    private var sheets: [String: SheetController] = [:]

    @objc public override func load(webview: WKWebView) {
        self.webView = webview
    }

    @objc public func configureTabBar(_ invoke: Invoke) throws {
        let args = try invoke.parseArgs(ConfigureTabBarArgs.self)
        guard !args.items.isEmpty else {
            invoke.reject("configureTabBar requires at least one item")
            return
        }
        DispatchQueue.main.async { [weak self] in
            guard let self else { return }
            guard let host = self.manager.viewController else {
                invoke.reject("host view controller unavailable")
                return
            }
            // Idempotent: reconfiguring updates the mounted bar in place.
            let overlay = self.overlay ?? self.mount(on: host)
            overlay.apply(items: args.items, selectedId: args.selectedId)
            overlay.applyTint(args.tint.flatMap(ColorUtil.from(hex:)))
            if let acc = args.accessory {
                // Avatar aspect-filled to a square; the round button clips it.
                let image = acc.image.flatMap(ImageUtil.decode).map {
                    ImageUtil.icon($0, side: 50, circular: false)
                }
                overlay.setAccessory(id: acc.id, image: image, sfSymbol: acc.sfSymbol)
            } else {
                overlay.setAccessory(id: nil, image: nil, sfSymbol: nil)
            }
            invoke.resolve()
        }
    }

    @objc public func removeTabBar(_ invoke: Invoke) {
        DispatchQueue.main.async { [weak self] in
            if let overlay = self?.overlay {
                overlay.willMove(toParent: nil)
                overlay.view.removeFromSuperview()
                overlay.removeFromParent()
                self?.overlay = nil
            }
            invoke.resolve()
        }
    }

    @objc public func showTabBar(_ invoke: Invoke) {
        setHidden(false, invoke)
    }

    @objc public func hideTabBar(_ invoke: Invoke) {
        setHidden(true, invoke)
    }

    @objc public func selectTab(_ invoke: Invoke) throws {
        let args = try invoke.parseArgs(SelectTabArgs.self)
        DispatchQueue.main.async { [weak self] in
            guard let overlay = self?.overlay else {
                invoke.reject("tab bar is not configured")
                return
            }
            if overlay.select(id: args.id) {
                invoke.resolve()
            } else {
                invoke.reject("unknown tab id: \(args.id)")
            }
        }
    }

    @objc public func setBadge(_ invoke: Invoke) throws {
        let args = try invoke.parseArgs(SetBadgeArgs.self)
        DispatchQueue.main.async { [weak self] in
            guard let overlay = self?.overlay else {
                invoke.reject("tab bar is not configured")
                return
            }
            if overlay.setBadge(id: args.id, value: args.value) {
                invoke.resolve()
            } else {
                invoke.reject("unknown tab id: \(args.id)")
            }
        }
    }

    @objc public func createComponent(_ invoke: Invoke) throws {
        let args = try invoke.parseArgs(CreateComponentArgs.self)
        DispatchQueue.main.async { [weak self] in
            guard let self else { return }
            guard let host = self.manager.viewController else {
                invoke.reject("host view controller unavailable")
                return
            }
            let overlay = self.componentsOverlay ?? self.mountComponents(on: host)
            overlay.create(args, webView: self.webView)
            invoke.resolve()
        }
    }

    @objc public func updateComponent(_ invoke: Invoke) throws {
        let args = try invoke.parseArgs(UpdateComponentArgs.self)
        DispatchQueue.main.async { [weak self] in
            guard let overlay = self?.componentsOverlay, overlay.update(id: args.id, props: args.props)
            else {
                invoke.reject("unknown component: \(args.id)")
                return
            }
            invoke.resolve()
        }
    }

    @objc public func updateComponents(_ invoke: Invoke) throws {
        let args = try invoke.parseArgs(UpdateComponentsArgs.self)
        DispatchQueue.main.async { [weak self] in
            if let overlay = self?.componentsOverlay {
                for item in args.components {
                    _ = overlay.update(id: item.id, props: item.props)
                }
            }
            invoke.resolve()
        }
    }

    @objc public func removeComponent(_ invoke: Invoke) throws {
        let args = try invoke.parseArgs(RemoveComponentArgs.self)
        DispatchQueue.main.async { [weak self] in
            self?.componentsOverlay?.remove(id: args.id)
            invoke.resolve()
        }
    }

    @objc public func getTabBarInsets(_ invoke: Invoke) {
        DispatchQueue.main.async { [weak self] in
            let bottom = self?.overlay?.bottomInset ?? 0
            invoke.resolve(["bottom": Double(bottom)])
        }
    }

    @objc public func presentSheet(_ invoke: Invoke) throws {
        let args = try invoke.parseArgs(PresentSheetArgs.self)
        DispatchQueue.main.async { [weak self] in
            guard let self else { return }
            guard let host = self.manager.viewController else {
                invoke.reject("host view controller unavailable")
                return
            }
            let tintColor = args.tint.flatMap(ColorUtil.from(hex:))
            let mappedRows: [SheetRow] = args.rows.map { r in
                let kind: SheetRowKind =
                    r.header == true
                    ? .header : (r.kind.flatMap(SheetRowKind.init(rawValue:)) ?? .action)
                let image = r.image.flatMap(ImageUtil.decode).map {
                    ImageUtil.icon($0, side: kind == .header ? 40 : 24, circular: true)
                }
                return SheetRow(
                    id: r.id, kind: kind, label: r.label, detail: r.detail, image: image,
                    sfSymbol: r.sfSymbol, badge: r.badge, destructive: r.destructive ?? false,
                    value: r.value, on: r.on ?? false, placeholder: r.placeholder,
                    options: (r.options ?? []).map { SheetOption(value: $0.value, label: $0.label) })
            }
            let sheetId = args.id

            // Already on screen with this id → swap content in place (no
            // dismiss→present race/flicker) and we're done.
            if let existing = self.sheets[sheetId], existing.presentingViewController != nil {
                existing.update(rows: mappedRows, tint: tintColor)
                invoke.resolve()
                return
            }

            let sheet = SheetController()
            sheet.tint = tintColor
            sheet.rows = mappedRows
            sheet.onSelect = { [weak self] rowId in
                self?.trigger("sheetRow", data: ["sheetId": sheetId, "rowId": rowId])
            }
            sheet.onField = { [weak self] rowId, value in
                self?.trigger(
                    "sheetField", data: ["sheetId": sheetId, "rowId": rowId, "value": value])
            }
            sheet.onSubmit = { [weak self] rowId, values in
                let json =
                    (try? JSONSerialization.data(withJSONObject: values)).flatMap {
                        String(data: $0, encoding: .utf8)
                    } ?? "{}"
                self?.trigger(
                    "sheetSubmit", data: ["sheetId": sheetId, "rowId": rowId, "values": json])
            }
            sheet.onDismissed = { [weak self] in
                self?.trigger("sheetDismissed", data: ["sheetId": sheetId])
                self?.sheets[sheetId] = nil
            }
            // Sheet detents / grabber are iOS 15+; iOS 14 gets a plain modal.
            if #available(iOS 15.0, *), let pres = sheet.sheetPresentationController {
                pres.detents = [.medium(), .large()]
                pres.prefersGrabberVisible = true
                pres.preferredCornerRadius = 24
            }
            // Drop any stale controller stored under this id that never made it
            // on screen, then register the new one.
            self.sheets[sheetId]?.dismiss(animated: false)
            self.sheets[sheetId] = sheet

            // Sequence the presentation behind any in-flight modal transition.
            // iOS silently drops a present that overlaps another present/dismiss,
            // so a caller swapping one native sheet for another (dismiss A +
            // present B in the same turn) would otherwise have to time an
            // arbitrary delay. Instead we wait for the host to settle: ride out
            // an animating transition via its coordinator; if one of OUR sheets
            // is still up, dismiss it and swap; if a modal we DON'T own is up
            // (system alert, share sheet, permission prompt, …) leave it and
            // present above it. Attempt-capped so a stuck modal can't loop.
            func present(from presenter: UIViewController) {
                presenter.present(sheet, animated: true) {
                    sheet.presentationController?.delegate = sheet
                }
            }
            func presentWhenClear(_ attempt: Int) {
                if attempt >= 10 {
                    present(from: host)
                    return
                }
                if let coordinator = host.transitionCoordinator {
                    coordinator.animate(alongsideTransition: nil) { _ in
                        presentWhenClear(attempt + 1)
                    }
                    return
                }
                // Walk to the topmost presented controller.
                var top: UIViewController = host
                while let next = top.presentedViewController { top = next }
                if top === host {
                    present(from: host)
                } else if self.sheets.values.contains(where: { $0 === top }) {
                    // Our own sheet — dismiss it, then swap in the new one.
                    top.dismiss(animated: true) { presentWhenClear(attempt + 1) }
                } else {
                    // A modal we don't own — never force-dismiss it; stack above.
                    present(from: top)
                }
            }
            presentWhenClear(0)
            invoke.resolve()
        }
    }

    @objc public func dismissSheet(_ invoke: Invoke) throws {
        let args = try invoke.parseArgs(DismissSheetArgs.self)
        DispatchQueue.main.async { [weak self] in
            if let sheet = self?.sheets[args.id] {
                sheet.dismiss(animated: true)
                self?.sheets[args.id] = nil
            }
            invoke.resolve()
        }
    }

    private func setHidden(_ hidden: Bool, _ invoke: Invoke) {
        DispatchQueue.main.async { [weak self] in
            guard let overlay = self?.overlay else {
                invoke.reject("tab bar is not configured")
                return
            }
            UIView.transition(
                with: overlay.view, duration: 0.25, options: .transitionCrossDissolve
            ) {
                overlay.tabBar.isHidden = hidden
            }
            invoke.resolve()
        }
    }

    /// Adds the overlay as a child VC of the webview's host VC. The overlay's
    /// root view is a touch-passthrough covering the host view, so the bar
    /// tracks rotation/safe-area while the webview stays interactive.
    private func mount(on host: UIViewController) -> TabBarOverlayController {
        let overlay = TabBarOverlayController()
        overlay.onSelect = { [weak self] id in
            self?.trigger("tabSelected", data: ["id": id])
        }
        host.addChild(overlay)
        overlay.view.translatesAutoresizingMaskIntoConstraints = false
        host.view.addSubview(overlay.view)
        NSLayoutConstraint.activate([
            overlay.view.leadingAnchor.constraint(equalTo: host.view.leadingAnchor),
            overlay.view.trailingAnchor.constraint(equalTo: host.view.trailingAnchor),
            overlay.view.topAnchor.constraint(equalTo: host.view.topAnchor),
            overlay.view.bottomAnchor.constraint(equalTo: host.view.bottomAnchor),
        ])
        overlay.didMove(toParent: host)
        self.overlay = overlay
        return overlay
    }

    /// Adds the components overlay as a child VC of the webview's host VC,
    /// same pattern as the tab bar.
    private func mountComponents(on host: UIViewController) -> ComponentsOverlayController {
        let overlay = ComponentsOverlayController()
        overlay.onEvent = { [weak self] id, event, on, value, detail in
            var data: JSObject = ["id": id, "event": event]
            if let on { data["on"] = on }
            if let value { data["value"] = value }
            if let detail { data["detail"] = detail }
            self?.trigger("componentEvent", data: data)
        }
        host.addChild(overlay)
        overlay.view.translatesAutoresizingMaskIntoConstraints = false
        host.view.addSubview(overlay.view)
        NSLayoutConstraint.activate([
            overlay.view.leadingAnchor.constraint(equalTo: host.view.leadingAnchor),
            overlay.view.trailingAnchor.constraint(equalTo: host.view.trailingAnchor),
            overlay.view.topAnchor.constraint(equalTo: host.view.topAnchor),
            overlay.view.bottomAnchor.constraint(equalTo: host.view.bottomAnchor),
        ])
        overlay.didMove(toParent: host)
        self.componentsOverlay = overlay
        return overlay
    }
}

@_cdecl("init_plugin_system_components")
func initPlugin() -> Plugin {
    return SystemComponentsPlugin()
}