@sigx/lynx-dev-client 0.1.0
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/LICENSE +21 -0
- package/android/src/main/kotlin/com/sigx/devclient/DevGenericResourceFetcher.kt +73 -0
- package/android/src/main/kotlin/com/sigx/devclient/DevHomeScreen.kt +163 -0
- package/android/src/main/kotlin/com/sigx/devclient/DevLynxScreen.kt +208 -0
- package/android/src/main/kotlin/com/sigx/devclient/DevMenu.kt +278 -0
- package/android/src/main/kotlin/com/sigx/devclient/DevQRScanner.kt +174 -0
- package/android/src/main/kotlin/com/sigx/devclient/DevSettings.kt +61 -0
- package/android/src/main/kotlin/com/sigx/devclient/DevTemplateProvider.kt +76 -0
- package/android/src/main/kotlin/com/sigx/devclient/DevTemplateResourceFetcher.kt +113 -0
- package/android/src/main/kotlin/com/sigx/devclient/ErrorOverlay.kt +86 -0
- package/android/src/main/kotlin/com/sigx/devclient/PerfHud.kt +100 -0
- package/android/src/main/kotlin/com/sigx/devclient/ShakeDetector.kt +97 -0
- package/android/src/main/kotlin/com/sigx/devclient/SigxDevClient.kt +91 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +11 -0
- package/ios/Sources/SigxDevClient/DevGenericResourceFetcher.swift +47 -0
- package/ios/Sources/SigxDevClient/DevHomeScreen.swift +128 -0
- package/ios/Sources/SigxDevClient/DevMenuView.swift +133 -0
- package/ios/Sources/SigxDevClient/DevQRScanner.swift +165 -0
- package/ios/Sources/SigxDevClient/DevTemplateProvider.swift +68 -0
- package/ios/Sources/SigxDevClient/DevTemplateResourceFetcher.swift +73 -0
- package/ios/Sources/SigxDevClient/ShakeDetector.swift +60 -0
- package/ios/Sources/SigxDevClient/SigxDevClient.swift +106 -0
- package/package.json +30 -0
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import SwiftUI
|
|
2
|
+
import UIKit
|
|
3
|
+
|
|
4
|
+
/// Action callbacks the dev menu invokes. Mirrors the shape of Android's
|
|
5
|
+
/// `DevMenuActions` so iOS templates wire up the same set of buttons.
|
|
6
|
+
public struct DevMenuActions {
|
|
7
|
+
public let onReload: () -> Void
|
|
8
|
+
public let onChangeUrl: (String) -> Void
|
|
9
|
+
public let onCopyUrl: () -> Void
|
|
10
|
+
/// Sandbox-host hook: when non-nil, the dev menu shows a "Back to Home"
|
|
11
|
+
/// button that disconnects from the current bundle and returns to
|
|
12
|
+
/// `DevHomeScreen` (so the user can pick a different URL). Nil for apps
|
|
13
|
+
/// with a baked bundle — there's no home to return to.
|
|
14
|
+
public let onDisconnect: (() -> Void)?
|
|
15
|
+
public let currentUrl: String
|
|
16
|
+
public let nativeModules: [String]
|
|
17
|
+
|
|
18
|
+
public init(
|
|
19
|
+
onReload: @escaping () -> Void,
|
|
20
|
+
onChangeUrl: @escaping (String) -> Void,
|
|
21
|
+
onCopyUrl: @escaping () -> Void,
|
|
22
|
+
onDisconnect: (() -> Void)? = nil,
|
|
23
|
+
currentUrl: String,
|
|
24
|
+
nativeModules: [String] = []
|
|
25
|
+
) {
|
|
26
|
+
self.onReload = onReload
|
|
27
|
+
self.onChangeUrl = onChangeUrl
|
|
28
|
+
self.onCopyUrl = onCopyUrl
|
|
29
|
+
self.onDisconnect = onDisconnect
|
|
30
|
+
self.currentUrl = currentUrl
|
|
31
|
+
self.nativeModules = nativeModules
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/// SwiftUI dev menu sheet. Present with `.sheet(isPresented:)`, then bind the
|
|
36
|
+
/// dismissal back through `isPresented`.
|
|
37
|
+
public struct DevMenuView: View {
|
|
38
|
+
@Binding var isPresented: Bool
|
|
39
|
+
let actions: DevMenuActions
|
|
40
|
+
|
|
41
|
+
@State private var newUrl: String = ""
|
|
42
|
+
@State private var showUrlInput: Bool = false
|
|
43
|
+
@State private var copyToast: String?
|
|
44
|
+
|
|
45
|
+
public init(isPresented: Binding<Bool>, actions: DevMenuActions) {
|
|
46
|
+
self._isPresented = isPresented
|
|
47
|
+
self.actions = actions
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
public var body: some View {
|
|
51
|
+
NavigationView {
|
|
52
|
+
List {
|
|
53
|
+
Section {
|
|
54
|
+
Button {
|
|
55
|
+
actions.onReload()
|
|
56
|
+
isPresented = false
|
|
57
|
+
} label: {
|
|
58
|
+
Label("Reload", systemImage: "arrow.clockwise")
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
DisclosureGroup(isExpanded: $showUrlInput) {
|
|
62
|
+
HStack {
|
|
63
|
+
TextField("Server URL", text: $newUrl)
|
|
64
|
+
.keyboardType(.URL)
|
|
65
|
+
.textInputAutocapitalization(.never)
|
|
66
|
+
.disableAutocorrection(true)
|
|
67
|
+
.submitLabel(.go)
|
|
68
|
+
.onSubmit { submitUrl() }
|
|
69
|
+
Button("Go") { submitUrl() }
|
|
70
|
+
.buttonStyle(.borderedProminent)
|
|
71
|
+
.disabled(newUrl.isEmpty)
|
|
72
|
+
}
|
|
73
|
+
} label: {
|
|
74
|
+
VStack(alignment: .leading, spacing: 2) {
|
|
75
|
+
Label("Change Dev Server", systemImage: "pencil")
|
|
76
|
+
if !showUrlInput {
|
|
77
|
+
Text(actions.currentUrl)
|
|
78
|
+
.font(.caption)
|
|
79
|
+
.foregroundColor(.secondary)
|
|
80
|
+
.padding(.leading, 28)
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
Button {
|
|
86
|
+
actions.onCopyUrl()
|
|
87
|
+
copyToast = "URL copied"
|
|
88
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + 1.2) {
|
|
89
|
+
copyToast = nil
|
|
90
|
+
isPresented = false
|
|
91
|
+
}
|
|
92
|
+
} label: {
|
|
93
|
+
Label(copyToast ?? "Copy URL", systemImage: "doc.on.doc")
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if let onDisconnect = actions.onDisconnect {
|
|
97
|
+
Button(role: .destructive) {
|
|
98
|
+
isPresented = false
|
|
99
|
+
onDisconnect()
|
|
100
|
+
} label: {
|
|
101
|
+
Label("Back to Home", systemImage: "house")
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if !actions.nativeModules.isEmpty {
|
|
107
|
+
Section("Native Modules (\(actions.nativeModules.count))") {
|
|
108
|
+
ForEach(actions.nativeModules, id: \.self) { name in
|
|
109
|
+
Text(name)
|
|
110
|
+
.font(.system(.caption, design: .monospaced))
|
|
111
|
+
.foregroundColor(.secondary)
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
.navigationTitle("sigx Dev Menu")
|
|
117
|
+
.navigationBarTitleDisplayMode(.inline)
|
|
118
|
+
.toolbar {
|
|
119
|
+
ToolbarItem(placement: .navigationBarTrailing) {
|
|
120
|
+
Button("Close") { isPresented = false }
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
.onAppear { newUrl = actions.currentUrl }
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
private func submitUrl() {
|
|
128
|
+
guard !newUrl.isEmpty else { return }
|
|
129
|
+
actions.onChangeUrl(newUrl)
|
|
130
|
+
showUrlInput = false
|
|
131
|
+
isPresented = false
|
|
132
|
+
}
|
|
133
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import SwiftUI
|
|
2
|
+
import AVFoundation
|
|
3
|
+
|
|
4
|
+
/// Camera-driven QR scanner for the dev home screen. Reports scanned codes
|
|
5
|
+
/// back via `onCodeScanned`. Caller is responsible for dismissing whatever
|
|
6
|
+
/// container presented this view.
|
|
7
|
+
public struct DevQRScanner: View {
|
|
8
|
+
let onCodeScanned: (String) -> Void
|
|
9
|
+
|
|
10
|
+
@State private var cameraPermissionGranted = false
|
|
11
|
+
@State private var showPermissionAlert = false
|
|
12
|
+
|
|
13
|
+
public init(onCodeScanned: @escaping (String) -> Void) {
|
|
14
|
+
self.onCodeScanned = onCodeScanned
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
public var body: some View {
|
|
18
|
+
ZStack {
|
|
19
|
+
if cameraPermissionGranted {
|
|
20
|
+
DevQRCameraPreview(onCodeScanned: onCodeScanned)
|
|
21
|
+
.ignoresSafeArea()
|
|
22
|
+
|
|
23
|
+
VStack {
|
|
24
|
+
Spacer()
|
|
25
|
+
Text("Point camera at QR code from dev server")
|
|
26
|
+
.padding()
|
|
27
|
+
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 8))
|
|
28
|
+
.padding(.bottom, 48)
|
|
29
|
+
}
|
|
30
|
+
} else {
|
|
31
|
+
VStack(spacing: 16) {
|
|
32
|
+
Image(systemName: "camera.fill")
|
|
33
|
+
.font(.largeTitle)
|
|
34
|
+
.foregroundColor(.secondary)
|
|
35
|
+
Text("Camera permission is required to scan QR codes.")
|
|
36
|
+
.multilineTextAlignment(.center)
|
|
37
|
+
.foregroundColor(.secondary)
|
|
38
|
+
Button("Grant Permission") {
|
|
39
|
+
requestCameraPermission()
|
|
40
|
+
}
|
|
41
|
+
.buttonStyle(.borderedProminent)
|
|
42
|
+
}
|
|
43
|
+
.padding()
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
.navigationTitle("Scan QR Code")
|
|
47
|
+
.navigationBarTitleDisplayMode(.inline)
|
|
48
|
+
.onAppear {
|
|
49
|
+
checkCameraPermission()
|
|
50
|
+
}
|
|
51
|
+
.alert("Camera Permission Required", isPresented: $showPermissionAlert) {
|
|
52
|
+
Button("Open Settings") {
|
|
53
|
+
if let url = URL(string: UIApplication.openSettingsURLString) {
|
|
54
|
+
UIApplication.shared.open(url)
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
Button("Cancel", role: .cancel) {}
|
|
58
|
+
} message: {
|
|
59
|
+
Text("Please enable camera access in Settings to scan QR codes.")
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
private func checkCameraPermission() {
|
|
64
|
+
switch AVCaptureDevice.authorizationStatus(for: .video) {
|
|
65
|
+
case .authorized:
|
|
66
|
+
cameraPermissionGranted = true
|
|
67
|
+
case .notDetermined:
|
|
68
|
+
requestCameraPermission()
|
|
69
|
+
default:
|
|
70
|
+
showPermissionAlert = true
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
private func requestCameraPermission() {
|
|
75
|
+
AVCaptureDevice.requestAccess(for: .video) { granted in
|
|
76
|
+
DispatchQueue.main.async {
|
|
77
|
+
cameraPermissionGranted = granted
|
|
78
|
+
if !granted {
|
|
79
|
+
showPermissionAlert = true
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
private struct DevQRCameraPreview: UIViewControllerRepresentable {
|
|
87
|
+
let onCodeScanned: (String) -> Void
|
|
88
|
+
|
|
89
|
+
func makeUIViewController(context: Context) -> DevQRScannerViewController {
|
|
90
|
+
let vc = DevQRScannerViewController()
|
|
91
|
+
vc.onCodeScanned = onCodeScanned
|
|
92
|
+
return vc
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
func updateUIViewController(_ uiViewController: DevQRScannerViewController, context: Context) {}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
private final class DevQRScannerViewController: UIViewController, AVCaptureMetadataOutputObjectsDelegate {
|
|
99
|
+
var onCodeScanned: ((String) -> Void)?
|
|
100
|
+
|
|
101
|
+
private var captureSession: AVCaptureSession?
|
|
102
|
+
private var previewLayer: AVCaptureVideoPreviewLayer?
|
|
103
|
+
private var hasScanned = false
|
|
104
|
+
|
|
105
|
+
override func viewDidLoad() {
|
|
106
|
+
super.viewDidLoad()
|
|
107
|
+
setupCamera()
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
override func viewDidLayoutSubviews() {
|
|
111
|
+
super.viewDidLayoutSubviews()
|
|
112
|
+
previewLayer?.frame = view.layer.bounds
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
private func setupCamera() {
|
|
116
|
+
let session = AVCaptureSession()
|
|
117
|
+
|
|
118
|
+
guard let device = AVCaptureDevice.default(for: .video),
|
|
119
|
+
let input = try? AVCaptureDeviceInput(device: device) else {
|
|
120
|
+
return
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if session.canAddInput(input) {
|
|
124
|
+
session.addInput(input)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
let output = AVCaptureMetadataOutput()
|
|
128
|
+
if session.canAddOutput(output) {
|
|
129
|
+
session.addOutput(output)
|
|
130
|
+
output.setMetadataObjectsDelegate(self, queue: DispatchQueue.main)
|
|
131
|
+
output.metadataObjectTypes = [.qr]
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
let previewLayer = AVCaptureVideoPreviewLayer(session: session)
|
|
135
|
+
previewLayer.frame = view.layer.bounds
|
|
136
|
+
previewLayer.videoGravity = .resizeAspectFill
|
|
137
|
+
view.layer.addSublayer(previewLayer)
|
|
138
|
+
self.previewLayer = previewLayer
|
|
139
|
+
|
|
140
|
+
self.captureSession = session
|
|
141
|
+
DispatchQueue.global(qos: .background).async {
|
|
142
|
+
session.startRunning()
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
func metadataOutput(
|
|
147
|
+
_ output: AVCaptureMetadataOutput,
|
|
148
|
+
didOutput metadataObjects: [AVMetadataObject],
|
|
149
|
+
from connection: AVCaptureConnection
|
|
150
|
+
) {
|
|
151
|
+
guard !hasScanned,
|
|
152
|
+
let metadataObject = metadataObjects.first as? AVMetadataMachineReadableCodeObject,
|
|
153
|
+
let value = metadataObject.stringValue else {
|
|
154
|
+
return
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
hasScanned = true
|
|
158
|
+
captureSession?.stopRunning()
|
|
159
|
+
|
|
160
|
+
let generator = UINotificationFeedbackGenerator()
|
|
161
|
+
generator.notificationOccurred(.success)
|
|
162
|
+
|
|
163
|
+
onCodeScanned?(value)
|
|
164
|
+
}
|
|
165
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
import Lynx
|
|
3
|
+
|
|
4
|
+
/// Loads Lynx bundles from URLs (dev server) or local assets.
|
|
5
|
+
/// Implements LynxTemplateProvider protocol.
|
|
6
|
+
class DevTemplateProvider: NSObject, LynxTemplateProvider {
|
|
7
|
+
|
|
8
|
+
func loadTemplate(withUrl url: String!, onComplete callback: ((Any?, Error?) -> Void)!) {
|
|
9
|
+
guard let callback = callback else { return }
|
|
10
|
+
|
|
11
|
+
if url.hasPrefix("http://") || url.hasPrefix("https://") {
|
|
12
|
+
loadFromURL(url, callback: callback)
|
|
13
|
+
} else {
|
|
14
|
+
loadFromAssets(url, callback: callback)
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
private func loadFromURL(_ urlString: String, callback: @escaping (Any?, Error?) -> Void) {
|
|
19
|
+
guard let url = URL(string: urlString) else {
|
|
20
|
+
let error = NSError(
|
|
21
|
+
domain: "com.sigx.devclient",
|
|
22
|
+
code: 400,
|
|
23
|
+
userInfo: [NSLocalizedDescriptionKey: "Invalid URL: \(urlString)"]
|
|
24
|
+
)
|
|
25
|
+
callback(nil, error)
|
|
26
|
+
return
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
var request = URLRequest(url: url)
|
|
30
|
+
request.timeoutInterval = 30
|
|
31
|
+
|
|
32
|
+
URLSession.shared.dataTask(with: request) { data, response, error in
|
|
33
|
+
if let error = error {
|
|
34
|
+
callback(nil, error)
|
|
35
|
+
return
|
|
36
|
+
}
|
|
37
|
+
guard let data = data else {
|
|
38
|
+
let error = NSError(
|
|
39
|
+
domain: "com.sigx.devclient",
|
|
40
|
+
code: 404,
|
|
41
|
+
userInfo: [NSLocalizedDescriptionKey: "No data received from \(urlString)"]
|
|
42
|
+
)
|
|
43
|
+
callback(nil, error)
|
|
44
|
+
return
|
|
45
|
+
}
|
|
46
|
+
callback(data, nil)
|
|
47
|
+
}.resume()
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
private func loadFromAssets(_ name: String, callback: @escaping (Any?, Error?) -> Void) {
|
|
51
|
+
guard let path = Bundle.main.path(forResource: name, ofType: nil) else {
|
|
52
|
+
let error = NSError(
|
|
53
|
+
domain: "com.sigx.devclient",
|
|
54
|
+
code: 404,
|
|
55
|
+
userInfo: [NSLocalizedDescriptionKey: "Asset not found: \(name)"]
|
|
56
|
+
)
|
|
57
|
+
callback(nil, error)
|
|
58
|
+
return
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
do {
|
|
62
|
+
let data = try Data(contentsOf: URL(fileURLWithPath: path))
|
|
63
|
+
callback(data, nil)
|
|
64
|
+
} catch {
|
|
65
|
+
callback(nil, error)
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
import Lynx
|
|
3
|
+
|
|
4
|
+
/// Handles template and HMR hot-update fetching for LynxView.
|
|
5
|
+
/// Required for HMR — the template provider only handles initial loads.
|
|
6
|
+
class DevTemplateResourceFetcher: NSObject, LynxTemplateResourceFetcher {
|
|
7
|
+
|
|
8
|
+
private let session: URLSession = {
|
|
9
|
+
let config = URLSessionConfiguration.default
|
|
10
|
+
config.timeoutIntervalForRequest = 30
|
|
11
|
+
config.timeoutIntervalForResource = 60
|
|
12
|
+
return URLSession(configuration: config)
|
|
13
|
+
}()
|
|
14
|
+
|
|
15
|
+
func fetchTemplate(
|
|
16
|
+
_ request: LynxResourceRequest,
|
|
17
|
+
onComplete callback: @escaping (LynxTemplateResource?, Error?) -> Void
|
|
18
|
+
) {
|
|
19
|
+
let url = request.url ?? ""
|
|
20
|
+
|
|
21
|
+
if url.hasPrefix("file://") || (!url.hasPrefix("http://") && !url.hasPrefix("https://")) {
|
|
22
|
+
fetchFromAssets(url.replacingOccurrences(of: "file://", with: ""), callback: callback)
|
|
23
|
+
return
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
guard let requestURL = URL(string: url) else {
|
|
27
|
+
callback(nil, NSError(domain: "com.sigx.devclient", code: 400,
|
|
28
|
+
userInfo: [NSLocalizedDescriptionKey: "Invalid URL: \(url)"]))
|
|
29
|
+
return
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
session.dataTask(with: requestURL) { data, response, error in
|
|
33
|
+
if let error = error {
|
|
34
|
+
callback(nil, error)
|
|
35
|
+
return
|
|
36
|
+
}
|
|
37
|
+
guard let data = data else {
|
|
38
|
+
callback(nil, NSError(domain: "com.sigx.devclient", code: 404,
|
|
39
|
+
userInfo: [NSLocalizedDescriptionKey: "No data received"]))
|
|
40
|
+
return
|
|
41
|
+
}
|
|
42
|
+
let resource = LynxTemplateResource(nsData: data)
|
|
43
|
+
callback(resource, nil)
|
|
44
|
+
}.resume()
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
func fetchSSRData(
|
|
48
|
+
_ request: LynxResourceRequest,
|
|
49
|
+
onComplete callback: @escaping (Data?, Error?) -> Void
|
|
50
|
+
) {
|
|
51
|
+
// SSR not used in dev mode; return nil
|
|
52
|
+
callback(nil, nil)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
private func fetchFromAssets(
|
|
56
|
+
_ path: String,
|
|
57
|
+
callback: @escaping (LynxTemplateResource?, Error?) -> Void
|
|
58
|
+
) {
|
|
59
|
+
guard let bundlePath = Bundle.main.path(forResource: path, ofType: nil) else {
|
|
60
|
+
callback(nil, NSError(domain: "com.sigx.devclient", code: 404,
|
|
61
|
+
userInfo: [NSLocalizedDescriptionKey: "Asset not found: \(path)"]))
|
|
62
|
+
return
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
do {
|
|
66
|
+
let data = try Data(contentsOf: URL(fileURLWithPath: bundlePath))
|
|
67
|
+
let resource = LynxTemplateResource(nsData: data)
|
|
68
|
+
callback(resource, nil)
|
|
69
|
+
} catch {
|
|
70
|
+
callback(nil, error)
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import UIKit
|
|
2
|
+
import SwiftUI
|
|
3
|
+
import ObjectiveC.runtime
|
|
4
|
+
|
|
5
|
+
public extension Notification.Name {
|
|
6
|
+
static let sigxDeviceDidShake = Notification.Name("sigxDeviceDidShake")
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/// Global shake detector for SwiftUI apps.
|
|
10
|
+
///
|
|
11
|
+
/// SwiftUI doesn't expose `UIWindow.motionEnded(_:with:)`, and Swift extensions
|
|
12
|
+
/// can't `override` inherited Obj-C methods. So we install a runtime swizzle:
|
|
13
|
+
/// `class_replaceMethod` adds our own `motionEnded:withEvent:` IMP onto
|
|
14
|
+
/// `UIWindow`, and our IMP forwards to the original `UIResponder` impl before
|
|
15
|
+
/// posting `.sigxDeviceDidShake` on the default notification center.
|
|
16
|
+
///
|
|
17
|
+
/// Idempotent — `enable()` is safe to call repeatedly; the swizzle runs once
|
|
18
|
+
/// thanks to Swift static-let initialisation semantics.
|
|
19
|
+
public enum SigxShakeDetector {
|
|
20
|
+
private static let installed: Void = {
|
|
21
|
+
installSwizzle()
|
|
22
|
+
}()
|
|
23
|
+
|
|
24
|
+
public static func enable() {
|
|
25
|
+
_ = installed
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
private static func installSwizzle() {
|
|
29
|
+
let cls: AnyClass = UIWindow.self
|
|
30
|
+
let selector = #selector(UIResponder.motionEnded(_:with:))
|
|
31
|
+
guard let method = class_getInstanceMethod(cls, selector) else { return }
|
|
32
|
+
|
|
33
|
+
let originalIMP = method_getImplementation(method)
|
|
34
|
+
let typeEncoding = method_getTypeEncoding(method)
|
|
35
|
+
|
|
36
|
+
typealias Function = @convention(c) (UIWindow, Selector, UIEvent.EventSubtype, UIEvent?) -> Void
|
|
37
|
+
let originalFunc = unsafeBitCast(originalIMP, to: Function.self)
|
|
38
|
+
|
|
39
|
+
let block: @convention(block) (UIWindow, UIEvent.EventSubtype, UIEvent?) -> Void = { window, motion, event in
|
|
40
|
+
originalFunc(window, selector, motion, event)
|
|
41
|
+
if motion == .motionShake {
|
|
42
|
+
NotificationCenter.default.post(name: .sigxDeviceDidShake, object: nil)
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
let newIMP = imp_implementationWithBlock(block)
|
|
46
|
+
class_replaceMethod(cls, selector, newIMP, typeEncoding)
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
public extension View {
|
|
51
|
+
/// Triggers `action` when the device is shaken. Installs a global UIWindow
|
|
52
|
+
/// `motionEnded:` swizzle on first use so the event fires regardless of
|
|
53
|
+
/// first-responder state.
|
|
54
|
+
func onShake(perform action: @escaping () -> Void) -> some View {
|
|
55
|
+
SigxShakeDetector.enable()
|
|
56
|
+
return self.onReceive(NotificationCenter.default.publisher(for: .sigxDeviceDidShake)) { _ in
|
|
57
|
+
action()
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
import Lynx
|
|
3
|
+
import ObjectiveC.runtime
|
|
4
|
+
|
|
5
|
+
/// Facade API for sigx-lynx iOS dev client.
|
|
6
|
+
///
|
|
7
|
+
/// Templates call these methods in order:
|
|
8
|
+
/// 1. `SigxDevClient.registerServices()` — BEFORE LynxSetupService.initialize(), enables debug preset
|
|
9
|
+
/// 2. `SigxDevClient.enableDevMode()` — AFTER LynxSetupService.initialize(), enables devtool/logbox
|
|
10
|
+
/// 3. `SigxDevClient.configureForDev(builder:)` — in LynxView setup for dev mode
|
|
11
|
+
class SigxDevClient {
|
|
12
|
+
|
|
13
|
+
/// Enable lynxDebug on LynxEnv so that prepareConfig can initialize devtool env.
|
|
14
|
+
/// Call BEFORE LynxSetupService.shared.initialize() in `#if DEBUG`.
|
|
15
|
+
static func registerServices() {
|
|
16
|
+
let env = LynxEnv.sharedInstance()
|
|
17
|
+
env.lynxDebugEnabled = true
|
|
18
|
+
|
|
19
|
+
// Mirror Android's setLogBoxPresetValue(true): LynxEnv.logBoxEnabled
|
|
20
|
+
// ANDs in `[LynxService(LynxServiceDevToolProtocol) logBoxPresetValue]`,
|
|
21
|
+
// which is initialised to NO. Without this flip, errors give a silent
|
|
22
|
+
// white screen on iOS instead of the native Lynx logbox overlay.
|
|
23
|
+
// We dispatch through the Obj-C runtime (rather than the typed Swift
|
|
24
|
+
// import) because the Lynx pods don't ship a Swift-friendly facade
|
|
25
|
+
// for `+[LynxServices getInstanceWithProtocol:]`.
|
|
26
|
+
setDevToolPresetValue(true, forKey: "logBoxPresetValue")
|
|
27
|
+
|
|
28
|
+
print("[SigxDevClient] Set lynxDebugEnabled and logBoxPresetValue (before prepareConfig)")
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/// Flip a BOOL `@property` on the registered `LynxServiceDevToolProtocol`
|
|
32
|
+
/// service instance via Obj-C runtime + KVC.
|
|
33
|
+
private static func setDevToolPresetValue(_ value: Bool, forKey key: String) {
|
|
34
|
+
guard let servicesCls = NSClassFromString("LynxServices") else { return }
|
|
35
|
+
guard let proto = NSProtocolFromString("LynxServiceDevToolProtocol") else { return }
|
|
36
|
+
let sel = NSSelectorFromString("getInstanceWithProtocol:")
|
|
37
|
+
guard let method = class_getClassMethod(servicesCls, sel) else { return }
|
|
38
|
+
|
|
39
|
+
typealias GetInstanceFn = @convention(c) (AnyClass, Selector, Protocol) -> NSObject?
|
|
40
|
+
let fn = unsafeBitCast(method_getImplementation(method), to: GetInstanceFn.self)
|
|
41
|
+
if let svc = fn(servicesCls, sel, proto) {
|
|
42
|
+
svc.setValue(value, forKey: key)
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/// Enable devtool and logbox flags on LynxEnv.
|
|
47
|
+
/// MUST be called AFTER LynxSetupService.shared.initialize() (i.e. after prepareConfig).
|
|
48
|
+
static func enableDevMode() {
|
|
49
|
+
let env = LynxEnv.sharedInstance()
|
|
50
|
+
env.devtoolEnabled = true
|
|
51
|
+
env.logBoxEnabled = true
|
|
52
|
+
print("[SigxDevClient] Enabled devtool and logbox on LynxEnv (after init)")
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/// Configure a LynxView builder for dev mode (HTTP resource fetching + HMR).
|
|
56
|
+
/// Call when launching with a dev server URL.
|
|
57
|
+
static func configureForDev(builder: LynxViewBuilder) {
|
|
58
|
+
builder.templateResourceFetcher = DevTemplateResourceFetcher()
|
|
59
|
+
builder.enableGenericResourceFetcher = .true
|
|
60
|
+
builder.genericResourceFetcher = DevGenericResourceFetcher()
|
|
61
|
+
print("[SigxDevClient] Configured LynxViewBuilder for dev mode (resource fetchers)")
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
private static let lastConnectedUrlKey = "sigx_dev_client.last_connected_url"
|
|
65
|
+
private static let recentUrlsKey = "sigx_dev_client.recent_urls"
|
|
66
|
+
private static let recentUrlsMax = 20
|
|
67
|
+
|
|
68
|
+
/// Most recently used dev-server URL, persisted across launches so the app
|
|
69
|
+
/// can reconnect after a warm restart (icon-tap with no `--sigx_dev_url`
|
|
70
|
+
/// launch argument). Mirror of Android's `DevSettings.lastConnectedUrl`.
|
|
71
|
+
static var lastConnectedUrl: String? {
|
|
72
|
+
get {
|
|
73
|
+
let value = UserDefaults.standard.string(forKey: lastConnectedUrlKey)
|
|
74
|
+
return (value?.isEmpty ?? true) ? nil : value
|
|
75
|
+
}
|
|
76
|
+
set {
|
|
77
|
+
UserDefaults.standard.set(newValue, forKey: lastConnectedUrlKey)
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/// History of dev-server URLs the user has connected to from
|
|
82
|
+
/// `DevHomeScreen`, most-recent first. Capped at 20 entries.
|
|
83
|
+
public static var recentUrls: [String] {
|
|
84
|
+
UserDefaults.standard.stringArray(forKey: recentUrlsKey) ?? []
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/// Insert `url` at the front of the recent-URLs list, dedup, and trim to
|
|
88
|
+
/// the cap. Also updates `lastConnectedUrl` so warm restarts reconnect.
|
|
89
|
+
public static func addRecentUrl(_ url: String) {
|
|
90
|
+
let trimmed = url.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
91
|
+
guard !trimmed.isEmpty else { return }
|
|
92
|
+
var urls = recentUrls.filter { $0 != trimmed }
|
|
93
|
+
urls.insert(trimmed, at: 0)
|
|
94
|
+
UserDefaults.standard.set(Array(urls.prefix(recentUrlsMax)), forKey: recentUrlsKey)
|
|
95
|
+
lastConnectedUrl = trimmed
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
public static func removeRecentUrl(_ url: String) {
|
|
99
|
+
let urls = recentUrls.filter { $0 != url }
|
|
100
|
+
UserDefaults.standard.set(urls, forKey: recentUrlsKey)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
public static func clearRecentUrls() {
|
|
104
|
+
UserDefaults.standard.removeObject(forKey: recentUrlsKey)
|
|
105
|
+
}
|
|
106
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@sigx/lynx-dev-client",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Dev client for sigx-lynx — resource fetchers, template provider, and devtool integration",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"import": "./dist/index.js",
|
|
11
|
+
"types": "./dist/index.d.ts"
|
|
12
|
+
},
|
|
13
|
+
"./sigx-module.json": "./sigx-module.json",
|
|
14
|
+
"./package.json": "./package.json"
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"dist",
|
|
18
|
+
"android",
|
|
19
|
+
"ios"
|
|
20
|
+
],
|
|
21
|
+
"devDependencies": {
|
|
22
|
+
"typescript": "^5.9.3"
|
|
23
|
+
},
|
|
24
|
+
"author": "Andreas Ekdahl",
|
|
25
|
+
"license": "MIT",
|
|
26
|
+
"scripts": {
|
|
27
|
+
"build": "tsc",
|
|
28
|
+
"dev": "tsc --watch"
|
|
29
|
+
}
|
|
30
|
+
}
|