@tamer4lynx/tamer-dev-client 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,198 @@
1
+ import Foundation
2
+ import Lynx
3
+
4
+ private let maxQueue = 100
5
+ private let reconnectDelay: TimeInterval = 3.0
6
+
7
+ @objcMembers
8
+ public final class TamerRelogLogService: NSObject {
9
+ public static let shared = TamerRelogLogService()
10
+
11
+ private var webSocketTask: URLSessionWebSocketTask?
12
+ private var urlSession: URLSession?
13
+ private let queue = DispatchQueue(label: "com.tamerdevclient.relog", qos: .utility)
14
+ private var pendingQueue: [String] = []
15
+ private let queueLock = NSLock()
16
+ private var shouldReconnect = false
17
+ private var isConnecting = false
18
+ private var loggingDelegateId: NSInteger = -1
19
+
20
+ private let sessionDelegate = RelogURLSessionDelegate()
21
+
22
+ public static func connect() {
23
+ shared.connect()
24
+ }
25
+
26
+ public static func disconnect() {
27
+ shared.disconnect()
28
+ }
29
+
30
+ public static func forwardLog(level: UInt32, tag: String, message: String) {
31
+ shared.forwardLog(level: level, tag: tag, message: message)
32
+ }
33
+
34
+ public func connect() {
35
+ shouldReconnect = true
36
+ registerLoggingDelegate()
37
+ guard !isConnecting, webSocketTask == nil else { return }
38
+ guard let devUrl = DevServerPrefs.getUrl(), !devUrl.isEmpty else { return }
39
+ guard let wsURL = buildWsURL(devUrl: devUrl) else { return }
40
+ isConnecting = true
41
+ let config = URLSessionConfiguration.default
42
+ config.timeoutIntervalForRequest = 5
43
+ let session = URLSession(configuration: config, delegate: sessionDelegate, delegateQueue: nil)
44
+ urlSession = session
45
+ sessionDelegate.onOpen = { [weak self] in
46
+ self?.isConnecting = false
47
+ self?.sendConnected()
48
+ self?.flushPending()
49
+ }
50
+ sessionDelegate.onClose = { [weak self] in
51
+ self?.webSocketTask = nil
52
+ self?.isConnecting = false
53
+ self?.scheduleReconnect()
54
+ }
55
+ sessionDelegate.onFailure = { [weak self] _ in
56
+ self?.webSocketTask = nil
57
+ self?.isConnecting = false
58
+ self?.scheduleReconnect()
59
+ }
60
+ webSocketTask = session.webSocketTask(with: wsURL)
61
+ webSocketTask?.resume()
62
+ }
63
+
64
+ public func disconnect() {
65
+ shouldReconnect = false
66
+ unregisterLoggingDelegate()
67
+ webSocketTask?.cancel(with: .goingAway, reason: nil)
68
+ webSocketTask = nil
69
+ urlSession?.invalidateAndCancel()
70
+ urlSession = nil
71
+ queueLock.lock()
72
+ pendingQueue.removeAll()
73
+ queueLock.unlock()
74
+ isConnecting = false
75
+ }
76
+
77
+ private func registerLoggingDelegate() {
78
+ guard loggingDelegateId < 0 else { return }
79
+ SetJSLogsFromExternalChannels(true)
80
+ guard let delegate = LynxLogDelegate(
81
+ logFunction: { [weak self] level, message in
82
+ guard let self = self, let msg = message else { return }
83
+ self.forwardLog(level: UInt32(level.rawValue), tag: "lynx-console", message: msg)
84
+ },
85
+ minLogLevel: .verbose
86
+ ) else { return }
87
+ delegate.acceptSource = LynxLogSource(rawValue: 1 << 1)
88
+ loggingDelegateId = AddLoggingDelegate(delegate)
89
+ }
90
+
91
+ private func unregisterLoggingDelegate() {
92
+ guard loggingDelegateId >= 0 else { return }
93
+ RemoveLoggingDelegate(loggingDelegateId)
94
+ loggingDelegateId = -1
95
+ }
96
+
97
+ func forwardLog(level: UInt32, tag: String, message: String) {
98
+ let (forwardTag, forwardMsg) = parseConsoleMessage(tag: tag, message: message)
99
+ let payload: [String: Any] = [
100
+ "type": "console_log",
101
+ "tag": forwardTag,
102
+ "message": [forwardMsg]
103
+ ]
104
+ guard let data = try? JSONSerialization.data(withJSONObject: payload),
105
+ let str = String(data: data, encoding: .utf8) else { return }
106
+ queue.async { [weak self] in
107
+ self?.send(payload: str)
108
+ }
109
+ }
110
+
111
+ private func parseConsoleMessage(tag: String, message: String) -> (String, String) {
112
+ if tag != "lynx" { return (tag, message) }
113
+ let pattern = #"\[.*?:(?:INFO|ERROR|WARN(?:ING)?|DEBUG|VERBOSE|FATAL):lynx_console\.cc\(\d+\)]\s*(.+)"#
114
+ guard let regex = try? NSRegularExpression(pattern: pattern, options: .dotMatchesLineSeparators),
115
+ let match = regex.firstMatch(in: message, range: NSRange(message.startIndex..., in: message)),
116
+ let range = Range(match.range(at: 1), in: message) else {
117
+ return (tag, message)
118
+ }
119
+ return ("lynx-console", String(message[range]))
120
+ }
121
+
122
+ private func buildWsURL(devUrl: String) -> URL? {
123
+ guard let url = URL(string: devUrl),
124
+ let scheme = url.scheme,
125
+ let host = url.host else { return nil }
126
+ let wsScheme = scheme == "https" ? "wss" : "ws"
127
+ let port = url.port ?? 0
128
+ let portPart = port > 0 ? ":\(port)" : ""
129
+ var path = url.path
130
+ if !path.hasSuffix("/") { path += "/" }
131
+ path += "__hmr"
132
+ let wsString = "\(wsScheme)://\(host)\(portPart)\(path)"
133
+ return URL(string: wsString)
134
+ }
135
+
136
+ private func sendConnected() {
137
+ let payload: [String: Any] = [
138
+ "type": "console_log",
139
+ "tag": "lynx-console",
140
+ "message": ["[TamerRelog] connected"]
141
+ ]
142
+ if let data = try? JSONSerialization.data(withJSONObject: payload),
143
+ let str = String(data: data, encoding: .utf8) {
144
+ send(payload: str)
145
+ }
146
+ }
147
+
148
+ private func send(payload: String) {
149
+ guard let task = webSocketTask else {
150
+ queueLock.lock()
151
+ if pendingQueue.count < maxQueue { pendingQueue.append(payload) }
152
+ queueLock.unlock()
153
+ return
154
+ }
155
+ task.send(.string(payload)) { [weak self] error in
156
+ if error != nil {
157
+ self?.queueLock.lock()
158
+ if self?.pendingQueue.count ?? 0 < maxQueue { self?.pendingQueue.append(payload) }
159
+ self?.queueLock.unlock()
160
+ }
161
+ }
162
+ }
163
+
164
+ private func flushPending() {
165
+ queueLock.lock()
166
+ let toSend = pendingQueue
167
+ pendingQueue.removeAll()
168
+ queueLock.unlock()
169
+ for payload in toSend {
170
+ webSocketTask?.send(.string(payload)) { _ in }
171
+ }
172
+ }
173
+
174
+ private func scheduleReconnect() {
175
+ guard shouldReconnect else { return }
176
+ queue.asyncAfter(deadline: .now() + reconnectDelay) { [weak self] in
177
+ self?.connect()
178
+ }
179
+ }
180
+ }
181
+
182
+ private final class RelogURLSessionDelegate: NSObject, URLSessionWebSocketDelegate {
183
+ var onOpen: (() -> Void)?
184
+ var onClose: (() -> Void)?
185
+ var onFailure: ((Error) -> Void)?
186
+
187
+ func urlSession(_ session: URLSession, webSocketTask: URLSessionWebSocketTask, didOpenWithProtocol protocol: String?) {
188
+ DispatchQueue.main.async { self.onOpen?() }
189
+ }
190
+
191
+ func urlSession(_ session: URLSession, webSocketTask: URLSessionWebSocketTask, didCloseWith closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) {
192
+ DispatchQueue.main.async { self.onClose?() }
193
+ }
194
+
195
+ func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
196
+ if let error = error { DispatchQueue.main.async { self.onFailure?(error) } }
197
+ }
198
+ }
@@ -0,0 +1,16 @@
1
+ package = JSON.parse(File.read(File.join(__dir__, "..", "..", "package.json")))
2
+
3
+ Pod::Spec.new do |s|
4
+ s.name = 'tamerdevclient'
5
+ s.version = package["version"]
6
+ s.summary = 'Tamer dev client native module for iOS.'
7
+ s.description = 'QR scan, HMR, Bonjour discovery, and project reload for the Tamer dev app.'
8
+ s.homepage = "https://github.com/nanofuxion"
9
+ s.license = package["license"]
10
+ s.authors = package["author"]
11
+ s.source = { :path => '.' }
12
+ s.swift_version = '5.0'
13
+ s.ios.deployment_target = '13.0'
14
+ s.source_files = 'tamerdevclient/Classes/**/*.swift'
15
+ s.dependency "Lynx"
16
+ end
@@ -0,0 +1,51 @@
1
+ import Foundation
2
+ import tamerdevclient
3
+
4
+ class DevClientManager {
5
+ private var webSocketTask: URLSessionWebSocketTask?
6
+ private let onReload: () -> Void
7
+ private var session: URLSession?
8
+
9
+ init(onReload: @escaping () -> Void) {
10
+ self.onReload = onReload
11
+ }
12
+
13
+ func connect() {
14
+ guard let devUrl = DevServerPrefs.getUrl(), !devUrl.isEmpty else { return }
15
+ guard let base = URL(string: devUrl) else { return }
16
+
17
+ let scheme = (base.scheme == "https") ? "wss" : "ws"
18
+ let host = base.host ?? "localhost"
19
+ let port = base.port.map { ":\($0)" } ?? ""
20
+ let rawPath = base.path.isEmpty ? "/" : base.path
21
+ let dir = rawPath.hasSuffix("/") ? rawPath : rawPath + "/"
22
+ guard let wsUrl = URL(string: "\(scheme)://\(host)\(port)\(dir)__hmr") else { return }
23
+
24
+ session = URLSession(configuration: .default)
25
+ let task = session!.webSocketTask(with: wsUrl)
26
+ webSocketTask = task
27
+ task.resume()
28
+ receive()
29
+ }
30
+
31
+ private func receive() {
32
+ webSocketTask?.receive { [weak self] result in
33
+ guard let self = self else { return }
34
+ switch result {
35
+ case .success(let msg):
36
+ if case .string(let text) = msg, text.contains("\"type\":\"reload\"") {
37
+ DispatchQueue.main.async { self.onReload() }
38
+ }
39
+ self.receive()
40
+ case .failure:
41
+ break
42
+ }
43
+ }
44
+ }
45
+
46
+ func disconnect() {
47
+ webSocketTask?.cancel(with: .normalClosure, reason: nil)
48
+ webSocketTask = nil
49
+ session = nil
50
+ }
51
+ }
@@ -0,0 +1,102 @@
1
+ import UIKit
2
+ import Lynx
3
+ import tamerdevclient
4
+ import tamerinsets
5
+
6
+ class DevLauncherViewController: UIViewController {
7
+ private var lynxView: LynxView?
8
+
9
+ override func viewDidLoad() {
10
+ super.viewDidLoad()
11
+ view.backgroundColor = .black
12
+ edgesForExtendedLayout = .all
13
+ extendedLayoutIncludesOpaqueBars = true
14
+ additionalSafeAreaInsets = .zero
15
+ view.insetsLayoutMarginsFromSafeArea = false
16
+ view.preservesSuperviewLayoutMargins = false
17
+ viewRespectsSystemMinimumLayoutMargins = false
18
+ setupLynxView()
19
+ setupDevClientModule()
20
+ }
21
+
22
+ override func viewDidLayoutSubviews() {
23
+ super.viewDidLayoutSubviews()
24
+ if let lynxView = lynxView {
25
+ applyFullscreenLayout(to: lynxView)
26
+ }
27
+ }
28
+
29
+ override func viewSafeAreaInsetsDidChange() {
30
+ super.viewSafeAreaInsetsDidChange()
31
+ TamerInsetsModule.reRequestInsets()
32
+ }
33
+
34
+ override var preferredStatusBarStyle: UIStatusBarStyle { .lightContent }
35
+
36
+ private func setupLynxView() {
37
+ let size = fullscreenBounds().size
38
+ let lv = LynxView { builder in
39
+ builder.config = LynxConfig(provider: DevTemplateProvider())
40
+ builder.screenSize = size
41
+ builder.fontScale = 1.0
42
+ }
43
+ lv.autoresizingMask = [.flexibleWidth, .flexibleHeight]
44
+ lv.insetsLayoutMarginsFromSafeArea = false
45
+ lv.preservesSuperviewLayoutMargins = false
46
+ view.addSubview(lv)
47
+ applyFullscreenLayout(to: lv)
48
+ lv.loadTemplate(fromURL: "dev-client.lynx.bundle", initData: nil)
49
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { [weak self, weak lv] in
50
+ guard let self, let lv else { return }
51
+ self.logViewport("devclient post-load", lynxView: lv)
52
+ self.applyFullscreenLayout(to: lv)
53
+ }
54
+ self.lynxView = lv
55
+ }
56
+
57
+ private func applyFullscreenLayout(to lynxView: LynxView) {
58
+ let bounds = fullscreenBounds()
59
+ let size = bounds.size
60
+ lynxView.frame = bounds
61
+ lynxView.updateScreenMetrics(withWidth: size.width, height: size.height)
62
+ lynxView.updateViewport(withPreferredLayoutWidth: size.width, preferredLayoutHeight: size.height, needLayout: true)
63
+ lynxView.preferredLayoutWidth = size.width
64
+ lynxView.preferredLayoutHeight = size.height
65
+ lynxView.layoutWidthMode = .exact
66
+ lynxView.layoutHeightMode = .exact
67
+ logViewport("devclient apply", lynxView: lynxView)
68
+ }
69
+
70
+ private func fullscreenBounds() -> CGRect {
71
+ let bounds = view.bounds
72
+ if bounds.width > 0, bounds.height > 0 {
73
+ return bounds
74
+ }
75
+ return UIScreen.main.bounds
76
+ }
77
+
78
+ private func logViewport(_ label: String, lynxView: LynxView) {
79
+ let rootWidth = lynxView.rootWidth()
80
+ let rootHeight = lynxView.rootHeight()
81
+ let intrinsic = lynxView.intrinsicContentSize
82
+ NSLog("[DevLauncher] %@ view=%@ safe=%@ lynxFrame=%@ lynxBounds=%@ root=%0.2fx%0.2f intrinsic=%@", label, NSCoder.string(for: view.bounds), NSCoder.string(for: view.safeAreaInsets), NSCoder.string(for: lynxView.frame), NSCoder.string(for: lynxView.bounds), rootWidth, rootHeight, NSCoder.string(for: intrinsic))
83
+ }
84
+
85
+ private func setupDevClientModule() {
86
+ DevClientModule.presentQRScanner = { [weak self] completion in
87
+ let scanner = QRScannerViewController()
88
+ scanner.onResult = { url in
89
+ scanner.dismiss(animated: true) { completion(url) }
90
+ }
91
+ scanner.modalPresentationStyle = .fullScreen
92
+ self?.present(scanner, animated: true)
93
+ }
94
+
95
+ DevClientModule.reloadProjectHandler = { [weak self] in
96
+ guard let self = self else { return }
97
+ let projectVC = ProjectViewController()
98
+ projectVC.modalPresentationStyle = .fullScreen
99
+ self.present(projectVC, animated: true)
100
+ }
101
+ }
102
+ }
@@ -0,0 +1,66 @@
1
+ import Foundation
2
+ import Lynx
3
+ import tamerdevclient
4
+
5
+ class DevTemplateProvider: NSObject, LynxTemplateProvider {
6
+ private static let devClientBundle = "dev-client.lynx.bundle"
7
+
8
+ func loadTemplate(withUrl url: String!, onComplete callback: LynxTemplateLoadBlock!) {
9
+ DispatchQueue.global(qos: .background).async {
10
+ if url == Self.devClientBundle || url?.hasSuffix("/" + Self.devClientBundle) == true {
11
+ self.loadFromBundle(url: Self.devClientBundle, callback: callback)
12
+ return
13
+ }
14
+
15
+ if let devUrl = DevServerPrefs.getUrl(), !devUrl.isEmpty {
16
+ let origin: String
17
+ if let parsed = URL(string: devUrl) {
18
+ let scheme = parsed.scheme ?? "http"
19
+ let host = parsed.host ?? "localhost"
20
+ let port = parsed.port.map { ":\($0)" } ?? ""
21
+ origin = "\(scheme)://\(host)\(port)"
22
+ } else {
23
+ origin = devUrl
24
+ }
25
+
26
+ let candidates = ["/\(url!)", "/{{PROJECT_BUNDLE_SEGMENT}}/\(url!)"]
27
+ for candidate in candidates {
28
+ if let data = self.httpFetch(url: origin + candidate) {
29
+ callback?(data, nil)
30
+ return
31
+ }
32
+ }
33
+ }
34
+
35
+ self.loadFromBundle(url: url, callback: callback)
36
+ }
37
+ }
38
+
39
+ private func loadFromBundle(url: String?, callback: LynxTemplateLoadBlock!) {
40
+ guard let url = url,
41
+ let bundleUrl = Bundle.main.url(forResource: url, withExtension: nil),
42
+ let data = try? Data(contentsOf: bundleUrl) else {
43
+ let err = NSError(domain: "DevTemplateProvider", code: 404,
44
+ userInfo: [NSLocalizedDescriptionKey: "Bundle not found: \(url ?? "nil")"])
45
+ callback?(nil, err)
46
+ return
47
+ }
48
+ callback?(data, nil)
49
+ }
50
+
51
+ private func httpFetch(url: String) -> Data? {
52
+ guard let u = URL(string: url) else { return nil }
53
+ var req = URLRequest(url: u)
54
+ req.timeoutInterval = 10
55
+ var result: Data?
56
+ let sem = DispatchSemaphore(value: 0)
57
+ URLSession.shared.dataTask(with: req) { data, response, _ in
58
+ if let http = response as? HTTPURLResponse, http.statusCode == 200 {
59
+ result = data
60
+ }
61
+ sem.signal()
62
+ }.resume()
63
+ sem.wait()
64
+ return result
65
+ }
66
+ }
@@ -0,0 +1,37 @@
1
+ // Copyright 2024 The Lynx Authors. All rights reserved.
2
+ // Licensed under the Apache License Version 2.0 that can be found in the
3
+ // LICENSE file in the root directory of this source tree.
4
+
5
+ import Foundation
6
+
7
+ // GENERATED IMPORTS START
8
+ // This section is automatically generated by Tamer4Lynx.
9
+ // Manual edits will be overwritten.
10
+ // GENERATED IMPORTS END
11
+
12
+ final class LynxInitProcessor {
13
+ static let shared = LynxInitProcessor()
14
+ private init() {}
15
+
16
+ func setupEnvironment() {
17
+ TamerIconElement.registerFonts()
18
+ setupLynxEnv()
19
+ setupLynxService()
20
+ }
21
+
22
+ private func setupLynxEnv() {
23
+ let env = LynxEnv.sharedInstance()
24
+ let globalConfig = LynxConfig(provider: env.config.templateProvider)
25
+
26
+ // GENERATED AUTOLINK START
27
+
28
+ // GENERATED AUTOLINK END
29
+
30
+ env.prepareConfig(globalConfig)
31
+ }
32
+
33
+ private func setupLynxService() {
34
+ let webPCoder = SDImageWebPCoder.shared
35
+ SDImageCodersManager.shared.addCoder(webPCoder)
36
+ }
37
+ }
@@ -0,0 +1,105 @@
1
+ import UIKit
2
+ import Lynx
3
+ import tamerinsets
4
+
5
+ class ProjectViewController: UIViewController {
6
+ private var lynxView: LynxView?
7
+ private var devClientManager: DevClientManager?
8
+
9
+ override func viewDidLoad() {
10
+ super.viewDidLoad()
11
+ view.backgroundColor = .black
12
+ edgesForExtendedLayout = .all
13
+ extendedLayoutIncludesOpaqueBars = true
14
+ additionalSafeAreaInsets = .zero
15
+ view.insetsLayoutMarginsFromSafeArea = false
16
+ view.preservesSuperviewLayoutMargins = false
17
+ viewRespectsSystemMinimumLayoutMargins = false
18
+ setupLynxView()
19
+ devClientManager = DevClientManager(onReload: { [weak self] in
20
+ self?.reloadLynxView()
21
+ })
22
+ devClientManager?.connect()
23
+ }
24
+
25
+ override func viewDidLayoutSubviews() {
26
+ super.viewDidLayoutSubviews()
27
+ if let lynxView = lynxView {
28
+ applyFullscreenLayout(to: lynxView)
29
+ }
30
+ }
31
+
32
+ override func viewSafeAreaInsetsDidChange() {
33
+ super.viewSafeAreaInsetsDidChange()
34
+ TamerInsetsModule.reRequestInsets()
35
+ }
36
+
37
+ override var preferredStatusBarStyle: UIStatusBarStyle { .lightContent }
38
+
39
+ private func buildLynxView() -> LynxView {
40
+ let size = fullscreenBounds().size
41
+ let lv = LynxView { builder in
42
+ builder.config = LynxConfig(provider: DevTemplateProvider())
43
+ builder.screenSize = size
44
+ builder.fontScale = 1.0
45
+ }
46
+ lv.autoresizingMask = [.flexibleWidth, .flexibleHeight]
47
+ lv.insetsLayoutMarginsFromSafeArea = false
48
+ lv.preservesSuperviewLayoutMargins = false
49
+ applyFullscreenLayout(to: lv)
50
+ return lv
51
+ }
52
+
53
+ private func setupLynxView() {
54
+ let lv = buildLynxView()
55
+ view.addSubview(lv)
56
+ lv.loadTemplate(fromURL: "main.lynx.bundle", initData: nil)
57
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { [weak self, weak lv] in
58
+ guard let self, let lv else { return }
59
+ self.logViewport("project post-load", lynxView: lv)
60
+ self.applyFullscreenLayout(to: lv)
61
+ }
62
+ self.lynxView = lv
63
+ }
64
+
65
+ private func reloadLynxView() {
66
+ lynxView?.removeFromSuperview()
67
+ lynxView = nil
68
+ setupLynxView()
69
+ }
70
+
71
+ private func applyFullscreenLayout(to lynxView: LynxView) {
72
+ let bounds = fullscreenBounds()
73
+ let size = bounds.size
74
+ lynxView.frame = bounds
75
+ lynxView.updateScreenMetrics(withWidth: size.width, height: size.height)
76
+ lynxView.updateViewport(withPreferredLayoutWidth: size.width, preferredLayoutHeight: size.height, needLayout: true)
77
+ lynxView.preferredLayoutWidth = size.width
78
+ lynxView.preferredLayoutHeight = size.height
79
+ lynxView.layoutWidthMode = .exact
80
+ lynxView.layoutHeightMode = .exact
81
+ logViewport("project apply", lynxView: lynxView)
82
+ }
83
+
84
+ private func fullscreenBounds() -> CGRect {
85
+ let bounds = view.bounds
86
+ if bounds.width > 0, bounds.height > 0 {
87
+ return bounds
88
+ }
89
+ return UIScreen.main.bounds
90
+ }
91
+
92
+ private func logViewport(_ label: String, lynxView: LynxView) {
93
+ let rootWidth = lynxView.rootWidth()
94
+ let rootHeight = lynxView.rootHeight()
95
+ let intrinsic = lynxView.intrinsicContentSize
96
+ NSLog("[ProjectVC] %@ view=%@ safe=%@ lynxFrame=%@ lynxBounds=%@ root=%0.2fx%0.2f intrinsic=%@", label, NSCoder.string(for: view.bounds), NSCoder.string(for: view.safeAreaInsets), NSCoder.string(for: lynxView.frame), NSCoder.string(for: lynxView.bounds), rootWidth, rootHeight, NSCoder.string(for: intrinsic))
97
+ }
98
+
99
+ override func viewWillDisappear(_ animated: Bool) {
100
+ super.viewWillDisappear(animated)
101
+ if isBeingDismissed || isMovingFromParent {
102
+ devClientManager?.disconnect()
103
+ }
104
+ }
105
+ }
@@ -0,0 +1,83 @@
1
+ import UIKit
2
+ import AVFoundation
3
+
4
+ class QRScannerViewController: UIViewController {
5
+ var onResult: ((String?) -> Void)?
6
+
7
+ private var captureSession: AVCaptureSession?
8
+ private var previewLayer: AVCaptureVideoPreviewLayer?
9
+
10
+ override func viewDidLoad() {
11
+ super.viewDidLoad()
12
+ view.backgroundColor = .black
13
+ setupCamera()
14
+ addCancelButton()
15
+ }
16
+
17
+ private func setupCamera() {
18
+ let session = AVCaptureSession()
19
+ guard let device = AVCaptureDevice.default(for: .video),
20
+ let input = try? AVCaptureDeviceInput(device: device) else {
21
+ onResult?(nil)
22
+ return
23
+ }
24
+
25
+ let output = AVCaptureMetadataOutput()
26
+ session.addInput(input)
27
+ session.addOutput(output)
28
+ output.setMetadataObjectsDelegate(self, queue: .main)
29
+ output.metadataObjectTypes = [.qr]
30
+
31
+ let preview = AVCaptureVideoPreviewLayer(session: session)
32
+ preview.frame = view.layer.bounds
33
+ preview.videoGravity = .resizeAspectFill
34
+ view.layer.insertSublayer(preview, at: 0)
35
+ previewLayer = preview
36
+
37
+ DispatchQueue.global(qos: .userInitiated).async { session.startRunning() }
38
+ captureSession = session
39
+ }
40
+
41
+ private func addCancelButton() {
42
+ let btn = UIButton(type: .system)
43
+ btn.setTitle("Cancel", for: .normal)
44
+ btn.setTitleColor(.white, for: .normal)
45
+ btn.titleLabel?.font = .systemFont(ofSize: 18, weight: .medium)
46
+ btn.addTarget(self, action: #selector(cancel), for: .touchUpInside)
47
+ btn.translatesAutoresizingMaskIntoConstraints = false
48
+ view.addSubview(btn)
49
+ NSLayoutConstraint.activate([
50
+ btn.centerXAnchor.constraint(equalTo: view.centerXAnchor),
51
+ btn.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -24),
52
+ ])
53
+ }
54
+
55
+ @objc private func cancel() {
56
+ captureSession?.stopRunning()
57
+ onResult?(nil)
58
+ }
59
+
60
+ override func viewWillAppear(_ animated: Bool) {
61
+ super.viewWillAppear(animated)
62
+ if captureSession?.isRunning == false {
63
+ DispatchQueue.global(qos: .userInitiated).async { self.captureSession?.startRunning() }
64
+ }
65
+ }
66
+
67
+ override func viewWillDisappear(_ animated: Bool) {
68
+ super.viewWillDisappear(animated)
69
+ captureSession?.stopRunning()
70
+ }
71
+ }
72
+
73
+ extension QRScannerViewController: AVCaptureMetadataOutputObjectsDelegate {
74
+ func metadataOutput(_ output: AVCaptureMetadataOutput,
75
+ didOutput objects: [AVMetadataObject],
76
+ from connection: AVCaptureConnection) {
77
+ captureSession?.stopRunning()
78
+ if let obj = objects.first as? AVMetadataMachineReadableCodeObject,
79
+ let value = obj.stringValue {
80
+ onResult?(value)
81
+ }
82
+ }
83
+ }
package/lynx.config.ts ADDED
@@ -0,0 +1,19 @@
1
+ import { fileURLToPath } from 'url'
2
+ import path from 'path'
3
+ import { pluginReactLynx } from '@lynx-js/react-rsbuild-plugin'
4
+ import { pluginTamer } from '@tamer4lynx/tamer-plugin'
5
+
6
+ const __dirname = path.dirname(fileURLToPath(import.meta.url))
7
+
8
+ export default {
9
+ source: {
10
+ entry: { 'dev-client': './src/index.tsx' },
11
+ alias: {
12
+ '@tamer4lynx/tamer-app-shell': path.resolve(__dirname, '../tamer-app-shell/src/index.tsx'),
13
+ '@tamer4lynx/tamer-screen': path.resolve(__dirname, '../tamer-screen/src/index.tsx'),
14
+ '@tamer4lynx/tamer-icons': path.resolve(__dirname, '../tamer-icons/src/index.tsx'),
15
+ '@tamer4lynx/tamer-insets': path.resolve(__dirname, '../tamer-insets/src/index.ts'),
16
+ },
17
+ },
18
+ plugins: [pluginTamer(), pluginReactLynx()],
19
+ }