@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.
- package/README.md +31 -0
- package/android/build.gradle.kts +34 -0
- package/android/src/main/AndroidManifest.xml +3 -0
- package/android/src/main/kotlin/com/nanofuxion/tamerdevclient/DevClientModule.kt +311 -0
- package/android/src/main/kotlin/com/nanofuxion/tamerdevclient/TamerRelogLogService.kt +141 -0
- package/android/src/main/kotlin/com/nanofuxion/tamerdevclient/nsd/NsdDiscovery.kt +138 -0
- package/android/src/main/kotlin/com/nanofuxion/tamerdevclient/nsd/NsdServiceInfoExtensions.kt +26 -0
- package/android/src/main/kotlin/com/nanofuxion/tamerdevclient/nsd/ResolveService.kt +28 -0
- package/android/templates/DevClientManager.kt +49 -0
- package/android/templates/DevServerPrefs.kt +44 -0
- package/android/templates/PortraitCaptureActivity.kt +5 -0
- package/android/templates/ProjectActivity.kt +112 -0
- package/dist/dev-client.lynx.bundle +0 -0
- package/ios/tamerdevclient/tamerdevclient/Classes/DevClientModule.swift +298 -0
- package/ios/tamerdevclient/tamerdevclient/Classes/TamerRelogLogService.swift +198 -0
- package/ios/tamerdevclient/tamerdevclient.podspec +16 -0
- package/ios/templates/DevClientManager.swift +51 -0
- package/ios/templates/DevLauncherViewController.swift +102 -0
- package/ios/templates/DevTemplateProvider.swift +66 -0
- package/ios/templates/LynxInitProcessor.swift +37 -0
- package/ios/templates/ProjectViewController.swift +105 -0
- package/ios/templates/QRScannerViewController.swift +83 -0
- package/lynx.config.ts +19 -0
- package/package.json +39 -0
|
@@ -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
|
+
}
|