@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.
Files changed (24) hide show
  1. package/LICENSE +21 -0
  2. package/android/src/main/kotlin/com/sigx/devclient/DevGenericResourceFetcher.kt +73 -0
  3. package/android/src/main/kotlin/com/sigx/devclient/DevHomeScreen.kt +163 -0
  4. package/android/src/main/kotlin/com/sigx/devclient/DevLynxScreen.kt +208 -0
  5. package/android/src/main/kotlin/com/sigx/devclient/DevMenu.kt +278 -0
  6. package/android/src/main/kotlin/com/sigx/devclient/DevQRScanner.kt +174 -0
  7. package/android/src/main/kotlin/com/sigx/devclient/DevSettings.kt +61 -0
  8. package/android/src/main/kotlin/com/sigx/devclient/DevTemplateProvider.kt +76 -0
  9. package/android/src/main/kotlin/com/sigx/devclient/DevTemplateResourceFetcher.kt +113 -0
  10. package/android/src/main/kotlin/com/sigx/devclient/ErrorOverlay.kt +86 -0
  11. package/android/src/main/kotlin/com/sigx/devclient/PerfHud.kt +100 -0
  12. package/android/src/main/kotlin/com/sigx/devclient/ShakeDetector.kt +97 -0
  13. package/android/src/main/kotlin/com/sigx/devclient/SigxDevClient.kt +91 -0
  14. package/dist/index.d.ts +11 -0
  15. package/dist/index.js +11 -0
  16. package/ios/Sources/SigxDevClient/DevGenericResourceFetcher.swift +47 -0
  17. package/ios/Sources/SigxDevClient/DevHomeScreen.swift +128 -0
  18. package/ios/Sources/SigxDevClient/DevMenuView.swift +133 -0
  19. package/ios/Sources/SigxDevClient/DevQRScanner.swift +165 -0
  20. package/ios/Sources/SigxDevClient/DevTemplateProvider.swift +68 -0
  21. package/ios/Sources/SigxDevClient/DevTemplateResourceFetcher.swift +73 -0
  22. package/ios/Sources/SigxDevClient/ShakeDetector.swift +60 -0
  23. package/ios/Sources/SigxDevClient/SigxDevClient.swift +106 -0
  24. 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
+ }