Skip to content

Commit c67e601

Browse files
fix(lynx): harden native iOS demo
1 parent 59e4f5e commit c67e601

11 files changed

Lines changed: 322 additions & 78 deletions

File tree

.github/workflows/e2e-lynx.yml

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -74,12 +74,6 @@ jobs:
7474
if: steps.check-ci.outputs.run-e2e == 'true'
7575
run: pnpm --filter lynx-module-federation-demo run test:ios-project
7676

77-
- name: Prepare iOS native artifacts
78-
if: steps.check-ci.outputs.run-e2e == 'true'
79-
env:
80-
LYNX_REMOTE_ORIGIN: http://127.0.0.1:3000
81-
run: pnpm --filter lynx-module-federation-demo run build:native
82-
8377
- name: Upload native artifacts for iOS
8478
if: steps.check-ci.outputs.run-e2e == 'true'
8579
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
@@ -160,6 +154,7 @@ jobs:
160154
name: lynx-ios-e2e
161155
path: |
162156
apps/lynx-module-federation-demo/ios/build/OrbitControl.xcresult/
157+
apps/lynx-module-federation-demo/ios/build/OrbitControl-Release.xcresult/
163158
apps/lynx-module-federation-demo/ios/build/orbit-control.png
164159
apps/lynx-module-federation-demo/ios/build/requests.json
165160
if-no-files-found: ignore

apps/lynx-module-federation-demo/.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
dist/
2+
.lynx-e2e-*/
23
.turbo/
34
test/real-web/artifacts/
45
ios/.bundle/

apps/lynx-module-federation-demo/README.md

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,20 @@ environment variable to a LAN-reachable URL for a physical device. The shell
4949
injects both Lynx template and generic resource fetchers, so the root Bundle,
5050
federated container, and Lazy Bundles can arrive over HTTP(S).
5151

52+
For a physical device, use the same LAN origin for both the host bundle and
53+
the manifest URL compiled into it:
54+
55+
```sh
56+
export LYNX_REMOTE_ORIGIN=http://<your-lan-ip>:3000
57+
pnpm ios:prepare
58+
pnpm ios:device
59+
```
60+
61+
Set the Xcode scheme's `LYNX_BUNDLE_URL` to
62+
`http://<your-lan-ip>:3000/main.lynx.bundle`. `ios:device` rejects loopback
63+
origins and binds Rspeedy to `0.0.0.0`, so the phone can fetch the host,
64+
manifest, container, and lazy bundles from the same server.
65+
5266
Release builds embed `ios/Resources/main.lynx.bundle` and retain strict ATS
5367
defaults. Build the host with the production manifest origin before syncing it:
5468

@@ -61,7 +75,8 @@ Only the host bundle is embedded. The manifest, container, and lazy expose
6175
bundles remain separately deployable HTTP(S) artifacts. The iOS simulator E2E
6276
launches the app, taps **Load remote catalog**, verifies compiled imports,
6377
runtime `loadRemote()`, and shared singleton identity, then checks every native
64-
bundle request observed by the test server.
78+
bundle request observed by the test server. It also launches the Release app
79+
without a root URL override to prove the embedded host and strict ATS branch.
6580

6681
## Run with Lynx Explorer
6782

apps/lynx-module-federation-demo/ios/OrbitControl/OrbitResourceFetcher.m

Lines changed: 113 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -7,19 +7,48 @@
77
#import <Lynx/LynxViewBuilder.h>
88

99
static NSString *const OrbitResourceErrorDomain = @"org.modulefederation.lynx.resources";
10+
static NSUInteger const OrbitResourcePathCacheLimit = 64;
1011

1112
@interface OrbitResourceFetcher ()
1213

14+
@property(nonatomic, strong) NSMutableDictionary<NSString *, NSString *> *resourcePathCache;
15+
@property(nonatomic, strong) NSMutableArray<NSString *> *resourcePathCacheOrder;
16+
@property(nonatomic, strong) NSURL *resourceCacheDirectory;
17+
1318
- (dispatch_block_t)loadDataForURLString:(NSString *)urlString
1419
completion:(void (^)(NSData *_Nullable,
1520
NSError *_Nullable))completion;
1621
- (NSURL *_Nullable)localURLForString:(NSString *)urlString;
22+
- (BOOL)isAllowedLocalURL:(NSURL *)url;
23+
- (NSString *_Nullable)cachedResourcePathForURLString:(NSString *)urlString;
24+
- (NSString *_Nullable)storeResourceData:(NSData *)data
25+
forURLString:(NSString *)urlString
26+
error:(NSError **)error;
1727
- (NSError *)errorWithMessage:(NSString *)message;
1828

1929
@end
2030

2131
@implementation OrbitResourceFetcher
2232

33+
- (instancetype)init {
34+
self = [super init];
35+
if (self) {
36+
_resourcePathCache = [NSMutableDictionary dictionary];
37+
_resourcePathCacheOrder = [NSMutableArray array];
38+
_resourceCacheDirectory = [[[NSURL fileURLWithPath:NSTemporaryDirectory()
39+
isDirectory:YES]
40+
URLByAppendingPathComponent:@"OrbitResources"
41+
isDirectory:YES]
42+
URLByAppendingPathComponent:NSUUID.UUID.UUIDString
43+
isDirectory:YES];
44+
}
45+
return self;
46+
}
47+
48+
- (void)dealloc {
49+
[[NSFileManager defaultManager] removeItemAtURL:self.resourceCacheDirectory error:nil];
50+
}
51+
2352
- (void)configureBuilder:(LynxViewBuilder *)builder {
2453
builder.enableGenericResourceFetcher = LynxBooleanOptionTrue;
2554
builder.genericResourceFetcher = self;
@@ -61,33 +90,73 @@ - (dispatch_block_t)fetchResourcePath:(LynxResourceRequest *)request
6190
return ^{};
6291
}
6392

93+
NSString *cachedPath = [self cachedResourcePathForURLString:request.url];
94+
if (cachedPath) {
95+
callback(cachedPath, nil);
96+
return ^{};
97+
}
98+
6499
return [self loadDataForURLString:request.url
65100
completion:^(NSData *data, NSError *error) {
66101
if (!data) {
67102
callback(nil, error);
68103
return;
69104
}
70105

71-
NSURL *directory = [NSURL fileURLWithPath:NSTemporaryDirectory()
72-
isDirectory:YES];
73-
directory = [directory URLByAppendingPathComponent:@"OrbitResources"
74-
isDirectory:YES];
75106
NSError *writeError = nil;
76-
[[NSFileManager defaultManager] createDirectoryAtURL:directory
77-
withIntermediateDirectories:YES
78-
attributes:nil
79-
error:&writeError];
80-
NSURL *fileURL = [directory URLByAppendingPathComponent:
81-
[NSUUID UUID].UUIDString];
82-
if (!writeError && [data writeToURL:fileURL options:NSDataWritingAtomic
83-
error:&writeError]) {
84-
callback(fileURL.path, nil);
85-
} else {
86-
callback(nil, writeError);
87-
}
107+
NSString *path = [self storeResourceData:data
108+
forURLString:request.url
109+
error:&writeError];
110+
callback(path, writeError);
88111
}];
89112
}
90113

114+
- (NSString *_Nullable)cachedResourcePathForURLString:(NSString *)urlString {
115+
@synchronized(self) {
116+
return self.resourcePathCache[urlString];
117+
}
118+
}
119+
120+
- (NSString *_Nullable)storeResourceData:(NSData *)data
121+
forURLString:(NSString *)urlString
122+
error:(NSError **)error {
123+
NSString *cachedPath = [self cachedResourcePathForURLString:urlString];
124+
if (cachedPath) return cachedPath;
125+
126+
[[NSFileManager defaultManager] createDirectoryAtURL:self.resourceCacheDirectory
127+
withIntermediateDirectories:YES
128+
attributes:nil
129+
error:error];
130+
if (*error) return nil;
131+
132+
NSString *extension = [NSURL URLWithString:urlString].pathExtension;
133+
NSString *filename = NSUUID.UUID.UUIDString;
134+
if (extension.length > 0) {
135+
filename = [filename stringByAppendingPathExtension:extension];
136+
}
137+
NSURL *fileURL = [self.resourceCacheDirectory URLByAppendingPathComponent:filename];
138+
if (![data writeToURL:fileURL options:NSDataWritingAtomic error:error]) return nil;
139+
140+
@synchronized(self) {
141+
NSString *existingPath = self.resourcePathCache[urlString];
142+
if (existingPath) {
143+
[[NSFileManager defaultManager] removeItemAtURL:fileURL error:nil];
144+
return existingPath;
145+
}
146+
147+
self.resourcePathCache[urlString] = fileURL.path;
148+
[self.resourcePathCacheOrder addObject:urlString];
149+
if (self.resourcePathCacheOrder.count > OrbitResourcePathCacheLimit) {
150+
NSString *expiredURL = self.resourcePathCacheOrder.firstObject;
151+
NSString *expiredPath = self.resourcePathCache[expiredURL];
152+
[self.resourcePathCacheOrder removeObjectAtIndex:0];
153+
[self.resourcePathCache removeObjectForKey:expiredURL];
154+
[[NSFileManager defaultManager] removeItemAtPath:expiredPath error:nil];
155+
}
156+
}
157+
return fileURL.path;
158+
}
159+
91160
- (dispatch_block_t)loadDataForURLString:(NSString *)urlString
92161
completion:(void (^)(NSData *_Nullable,
93162
NSError *_Nullable))completion {
@@ -139,23 +208,47 @@ - (dispatch_block_t)loadDataForURLString:(NSString *)urlString
139208
- (NSURL *_Nullable)localURLForString:(NSString *)urlString {
140209
NSURL *url = [NSURL URLWithString:urlString];
141210
if ([url.scheme isEqualToString:@"file"]) {
142-
return url;
211+
return [self isAllowedLocalURL:url] ? url : nil;
143212
}
144213
if ([url.scheme isEqualToString:@"http"] ||
145214
[url.scheme isEqualToString:@"https"]) {
146215
return nil;
147216
}
148217
if ([urlString isAbsolutePath]) {
149-
return [NSURL fileURLWithPath:urlString];
218+
NSURL *fileURL = [NSURL fileURLWithPath:urlString];
219+
return [self isAllowedLocalURL:fileURL] ? fileURL : nil;
150220
}
151221

152-
NSString *filename = urlString.lastPathComponent;
222+
NSString *relativePath = url.path.length > 0 ? url.path : urlString;
223+
relativePath = relativePath.stringByStandardizingPath;
224+
if (relativePath.length == 0 || [relativePath isEqualToString:@"."] ||
225+
[relativePath isEqualToString:@".."] ||
226+
[relativePath hasPrefix:@"../"]) {
227+
return nil;
228+
}
229+
NSString *filename = relativePath.lastPathComponent;
230+
NSString *subdirectory = relativePath.stringByDeletingLastPathComponent;
153231
NSString *extension = filename.pathExtension;
154232
NSString *name = extension.length > 0
155233
? [filename stringByDeletingPathExtension]
156234
: filename;
157235
return [[NSBundle mainBundle] URLForResource:name
158-
withExtension:extension.length > 0 ? extension : nil];
236+
withExtension:extension.length > 0 ? extension : nil
237+
subdirectory:[subdirectory isEqualToString:@"."]
238+
? nil
239+
: subdirectory];
240+
}
241+
242+
- (BOOL)isAllowedLocalURL:(NSURL *)url {
243+
NSString *path = url.URLByStandardizingPath.URLByResolvingSymlinksInPath.path;
244+
for (NSURL *directory in @[[NSBundle mainBundle].bundleURL,
245+
self.resourceCacheDirectory]) {
246+
NSString *directoryPath =
247+
directory.URLByStandardizingPath.URLByResolvingSymlinksInPath.path;
248+
NSString *prefix = [directoryPath stringByAppendingString:@"/"];
249+
if ([path hasPrefix:prefix]) return YES;
250+
}
251+
return NO;
159252
}
160253

161254
- (NSError *)errorWithMessage:(NSString *)message {

apps/lynx-module-federation-demo/ios/OrbitControl/ViewController.swift

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,15 +12,14 @@ final class ViewController: UIViewController {
1212

1313
let lynxView = LynxView { builder in
1414
builder.config = LynxConfig(provider: self.resourceFetcher)
15-
builder.screenSize = self.view.bounds.size
15+
builder.screenSize = self.contentFrame.size
1616
builder.fontScale = 1.0
1717
self.resourceFetcher.configure(builder)
1818
}
1919

20-
lynxView.frame = view.bounds
21-
lynxView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
22-
lynxView.preferredLayoutWidth = view.bounds.width
23-
lynxView.preferredLayoutHeight = view.bounds.height
20+
lynxView.frame = contentFrame
21+
lynxView.preferredLayoutWidth = contentFrame.width
22+
lynxView.preferredLayoutHeight = contentFrame.height
2423
lynxView.layoutWidthMode = .exact
2524
lynxView.layoutHeightMode = .exact
2625
view.addSubview(lynxView)
@@ -44,8 +43,15 @@ final class ViewController: UIViewController {
4443

4544
override func viewDidLayoutSubviews() {
4645
super.viewDidLayoutSubviews()
47-
lynxView?.preferredLayoutWidth = view.bounds.width
48-
lynxView?.preferredLayoutHeight = view.bounds.height
46+
guard let lynxView else { return }
47+
let frame = contentFrame
48+
lynxView.frame = frame
49+
lynxView.updateScreenMetrics(withWidth: frame.width, height: frame.height)
50+
lynxView.updateViewport(
51+
withPreferredLayoutWidth: frame.width,
52+
preferredLayoutHeight: frame.height,
53+
needLayout: true
54+
)
4955
}
5056

5157
deinit {
@@ -72,4 +78,9 @@ final class ViewController: UIViewController {
7278
return "main.lynx.bundle"
7379
#endif
7480
}
81+
82+
private var contentFrame: CGRect {
83+
let frame = view.safeAreaLayoutGuide.layoutFrame
84+
return frame.isEmpty ? view.bounds : frame
85+
}
7586
}

apps/lynx-module-federation-demo/ios/OrbitControlUITests/OrbitControlUITests.swift

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,21 @@
11
import XCTest
22

33
final class OrbitControlUITests: XCTestCase {
4+
func testEmbeddedReleaseHostLaunches() throws {
5+
try XCTSkipUnless(
6+
ProcessInfo.processInfo.environment["ORBIT_RELEASE_SMOKE"] == "1"
7+
)
8+
9+
let app = XCUIApplication()
10+
app.launchEnvironment["LYNX_BUNDLE_URL"] = ""
11+
app.launch()
12+
13+
let loadButton = app.descendants(matching: .any)
14+
.matching(NSPredicate(format: "label == %@", "Load remote catalog"))
15+
.firstMatch
16+
XCTAssertTrue(loadButton.waitForExistence(timeout: 30))
17+
}
18+
419
func testFederatedImportsRuntimeLoadingAndSingleton() {
520
addUIInterruptionMonitor(withDescription: "Local network permission") { alert in
621
guard alert.buttons["Allow"].exists else { return false }

apps/lynx-module-federation-demo/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,8 @@
2020
"e2e:web": "pnpm run build:web && node test/real-web/run.mjs",
2121
"ios:open": "open ios/OrbitControl.xcworkspace",
2222
"ios:pods": "cd ios && bundle install && bundle exec pod install --deployment --repo-update",
23-
"ios:prepare": "LYNX_REMOTE_ORIGIN=http://localhost:3000 pnpm run build:native && pnpm run ios:sync",
23+
"ios:device": "node scripts/dev-ios-device.mjs",
24+
"ios:prepare": "pnpm run build:native && pnpm run ios:sync",
2425
"ios:sync": "node scripts/sync-ios-bundle.mjs",
2526
"preview": "node rspack-canary-rspeedy.mjs preview -c lynx.config.mjs",
2627
"preview:web": "node rspack-canary-rspeedy.mjs preview -c lynx.web.config.mjs",
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import assert from 'node:assert/strict';
2+
import { spawn } from 'node:child_process';
3+
4+
const origin = process.env.LYNX_REMOTE_ORIGIN;
5+
assert.ok(
6+
origin,
7+
'Set LYNX_REMOTE_ORIGIN to the phone-reachable LAN origin, for example http://192.168.1.10:3000.',
8+
);
9+
const url = new URL(origin);
10+
assert.ok(
11+
url.protocol === 'http:' || url.protocol === 'https:',
12+
'LYNX_REMOTE_ORIGIN must use HTTP(S).',
13+
);
14+
assert.ok(
15+
!['localhost', '127.0.0.1', '::1'].includes(url.hostname),
16+
'LYNX_REMOTE_ORIGIN must be reachable from the phone, not a loopback address.',
17+
);
18+
19+
const child = spawn('pnpm', ['run', 'dev'], {
20+
env: {
21+
...process.env,
22+
LYNX_DEV_HOST: '0.0.0.0',
23+
},
24+
stdio: 'inherit',
25+
});
26+
child.once('error', (error) => {
27+
throw error;
28+
});
29+
child.once('exit', (code, signal) => {
30+
process.exitCode = code ?? (signal ? 1 : 0);
31+
});

apps/lynx-module-federation-demo/test/ios-project.mjs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ const [
2727
debugInfo,
2828
project,
2929
uiTest,
30+
packageJson,
31+
deviceServer,
3032
] = await Promise.all([
3133
read('ios/Podfile'),
3234
read('ios/Podfile.lock'),
@@ -36,6 +38,8 @@ const [
3638
read('ios/OrbitControl/Info.Debug.plist'),
3739
read('ios/OrbitControl.xcodeproj/project.pbxproj'),
3840
read('ios/OrbitControlUITests/OrbitControlUITests.swift'),
41+
read('package.json'),
42+
read('scripts/dev-ios-device.mjs'),
3943
]);
4044

4145
for (const pod of ['Lynx', 'LynxService', 'XElement']) {
@@ -49,6 +53,9 @@ assert.match(provenance, /f8230ca6aa1c9e629e30272971d0c03450b13e8e/);
4953
assert.match(fetcher, /LynxBooleanOptionTrue/);
5054
assert.match(fetcher, /builder\.templateResourceFetcher = self/);
5155
assert.match(fetcher, /builder\.genericResourceFetcher = self/);
56+
assert.match(fetcher, /isAllowedLocalURL/);
57+
assert.match(fetcher, /resourcePathCache/);
58+
assert.match(fetcher, /URLByResolvingSymlinksInPath/);
5259
assert.doesNotMatch(releaseInfo, /NSAllowsArbitraryLoads/);
5360
assert.doesNotMatch(releaseInfo, /NSExceptionAllowsInsecureHTTPLoads/);
5461
assert.match(debugInfo, /NSAllowsLocalNetworking/);
@@ -58,6 +65,10 @@ assert.doesNotMatch(debugInfo, /NSAllowsArbitraryLoads/);
5865
assert.doesNotMatch(project, /DEVELOPMENT_TEAM/);
5966
assert.match(project, /OrbitControlUITests/);
6067
assert.match(uiTest, /Shared singleton verified/);
68+
assert.match(uiTest, /testEmbeddedReleaseHostLaunches/);
69+
assert.match(packageJson, /"ios:device": "node scripts\/dev-ios-device\.mjs"/);
70+
assert.match(deviceServer, /LYNX_DEV_HOST: '0\.0\.0\.0'/);
71+
assert.match(deviceServer, /not a loopback address/);
6172
process.stdout.write(
6273
'Standalone official Lynx iOS project policy validated.\n',
6374
);

0 commit comments

Comments
 (0)