Skip to content

Commit 3f659df

Browse files
committed
Install custom Next.js router for iOS to properly resolve extension-less paths, handling static and dynamic routes (NextExportRouter). Replace CAPBridgeViewController with NextExportViewController in Main.storyboard. Increment project version to 4.
1 parent 6c6ac5e commit 3f659df

5 files changed

Lines changed: 97 additions & 7 deletions

File tree

docs/ios.md

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -132,10 +132,6 @@ Neither blocks TestFlight, both should land before a public release:
132132
- [ ] **Filter blocked users' existing comments.** `blockedUserIdSet` (`web/hooks/use-user.ts`) exists
133133
and is unused; the thread builders in `profile-comments.tsx` and `vote-comments.tsx` still render
134134
them. This is the last place the block toast's promise is not kept — see §8.4.
135-
- [ ] **Account linking for `auth/account-exists-with-different-credential`.** Someone who signed up
136-
with Google and later taps Sign in with Apple on the same address gets a raw Firebase error;
137-
nothing calls `linkWithCredential`. Separately, anyone choosing Apple's **Hide My Email** gets a
138-
relay address and therefore a silent duplicate account, which no linking flow can detect.
139135
- [ ] **No workflow invokes the `symbolicate` lane.** `Fastfile:105` says to run it on the CI runner,
140136
but `cd-ios.yml`'s `workflow_dispatch` runs `beta`. The dSYMs are archived (`cd-ios.yml:113`);
141137
there is just no way to reach a Mac to use them.

ios/App/App.xcodeproj/project.pbxproj

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
2FAD9763203C412B000D30F8 /* config.xml in Resources */ = {isa = PBXBuildFile; fileRef = 2FAD9762203C412B000D30F8 /* config.xml */; };
1111
50379B232058CBB4000EE86E /* capacitor.config.json in Resources */ = {isa = PBXBuildFile; fileRef = 50379B222058CBB4000EE86E /* capacitor.config.json */; };
1212
504EC3081FED79650016851F /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 504EC3071FED79650016851F /* AppDelegate.swift */; };
13+
C0A1B2C3D4E5F60718293A01 /* NextExportViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A1B2C3D4E5F60718293A02 /* NextExportViewController.swift */; };
1314
504EC30D1FED79650016851F /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 504EC30B1FED79650016851F /* Main.storyboard */; };
1415
504EC30F1FED79650016851F /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 504EC30E1FED79650016851F /* Assets.xcassets */; };
1516
504EC3121FED79650016851F /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 504EC3101FED79650016851F /* LaunchScreen.storyboard */; };
@@ -23,6 +24,7 @@
2324
50379B222058CBB4000EE86E /* capacitor.config.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = capacitor.config.json; sourceTree = "<group>"; };
2425
504EC3041FED79650016851F /* App.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = App.app; sourceTree = BUILT_PRODUCTS_DIR; };
2526
504EC3071FED79650016851F /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
27+
C0A1B2C3D4E5F60718293A02 /* NextExportViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NextExportViewController.swift; sourceTree = "<group>"; };
2628
504EC30C1FED79650016851F /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = "<group>"; };
2729
504EC30E1FED79650016851F /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
2830
504EC3111FED79650016851F /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
@@ -78,6 +80,7 @@
7880
children = (
7981
50379B222058CBB4000EE86E /* capacitor.config.json */,
8082
504EC3071FED79650016851F /* AppDelegate.swift */,
83+
C0A1B2C3D4E5F60718293A02 /* NextExportViewController.swift */,
8184
504EC30B1FED79650016851F /* Main.storyboard */,
8285
504EC30E1FED79650016851F /* Assets.xcassets */,
8386
504EC3101FED79650016851F /* LaunchScreen.storyboard */,
@@ -214,6 +217,7 @@
214217
buildActionMask = 2147483647;
215218
files = (
216219
504EC3081FED79650016851F /* AppDelegate.swift in Sources */,
220+
C0A1B2C3D4E5F60718293A01 /* NextExportViewController.swift in Sources */,
217221
);
218222
runOnlyForDeploymentPostprocessing = 0;
219223
};
@@ -353,7 +357,7 @@
353357
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
354358
CODE_SIGN_ENTITLEMENTS = App/App.entitlements;
355359
CODE_SIGN_STYLE = Automatic;
356-
CURRENT_PROJECT_VERSION = 3;
360+
CURRENT_PROJECT_VERSION = 4;
357361
INFOPLIST_FILE = App/Info.plist;
358362
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
359363
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
@@ -374,7 +378,7 @@
374378
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
375379
CODE_SIGN_ENTITLEMENTS = App/App.entitlements;
376380
CODE_SIGN_STYLE = Automatic;
377-
CURRENT_PROJECT_VERSION = 3;
381+
CURRENT_PROJECT_VERSION = 4;
378382
INFOPLIST_FILE = App/Info.plist;
379383
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
380384
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";

ios/App/App/Base.lproj/Main.storyboard

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
<!--Bridge View Controller-->
1212
<scene sceneID="tne-QT-ifu">
1313
<objects>
14-
<viewController id="BYZ-38-t0r" customClass="CAPBridgeViewController" customModule="Capacitor" sceneMemberID="viewController"/>
14+
<viewController id="BYZ-38-t0r" customClass="NextExportViewController" customModule="App" customModuleProvider="target" sceneMemberID="viewController"/>
1515
<placeholder placeholderIdentifier="IBFirstResponder" id="dkx-z0-nzr" sceneMemberID="firstResponder"/>
1616
</objects>
1717
</scene>
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import Capacitor
2+
import Foundation
3+
4+
/// Resolves a URL path against the files Next.js's static export actually wrote.
5+
///
6+
/// Capacitor's stock `CapacitorRouter` maps **every** extension-less path to `index.html` — correct
7+
/// for a single-page app, wrong for us. `scripts/build_web_view.sh` strips
8+
/// `getStaticProps`/`getStaticPaths` so the export emits one file per route, including literal
9+
/// bracketed files for the dynamic ones: `[username].html`, `blog/[slug].html`, `vote/[id].html`,
10+
/// `alerts/[id].html`, `messages/[channelId].html`. Each reads its own route from `router.query` at
11+
/// runtime.
12+
///
13+
/// Under the stock router a *hard* navigation to `/someone` or `/blog/a-post` was served
14+
/// `index.html`, so the app silently showed the home page instead of the requested one. Client-side
15+
/// `next/link` navigation was unaffected, which is why sidebar links worked and profile and blog
16+
/// links did not — and why it looked like a crash rather than a routing bug.
17+
///
18+
/// Android never had this: `WebViewLocalServer` already resolves extension-less paths against
19+
/// `.html` files itself.
20+
struct NextExportRouter: Router {
21+
var basePath: String = ""
22+
23+
func route(for path: String) -> String {
24+
// Real assets (.js, .css, .png, .json, …) pass straight through.
25+
guard URL(fileURLWithPath: path).pathExtension.isEmpty else {
26+
return basePath + path
27+
}
28+
29+
let trimmed = path.hasSuffix("/") ? String(path.dropLast()) : path
30+
guard !trimmed.isEmpty else { return basePath + "/index.html" }
31+
32+
// /about -> /about.html
33+
if fileExists(trimmed + ".html") {
34+
return basePath + trimmed + ".html"
35+
}
36+
// /foo -> /foo/index.html
37+
if fileExists(trimmed + "/index.html") {
38+
return basePath + trimmed + "/index.html"
39+
}
40+
// /someone -> /[username].html ; /blog/a-post -> /blog/[slug].html
41+
if let bracketed = bracketedSibling(of: trimmed) {
42+
return basePath + bracketed
43+
}
44+
45+
// Genuinely unknown. index.html keeps the app usable rather than showing a blank WebView,
46+
// and matches what the stock router would have done.
47+
return basePath + "/index.html"
48+
}
49+
50+
private func fileExists(_ relativePath: String) -> Bool {
51+
FileManager.default.fileExists(atPath: basePath + relativePath)
52+
}
53+
54+
/// The dynamic-route template living beside the requested path, e.g. `blog/[slug].html` for
55+
/// `/blog/a-post`. Next guarantees at most one dynamic segment per directory level, so the first
56+
/// match is unambiguous; sorting only keeps the choice stable across runs.
57+
private func bracketedSibling(of path: String) -> String? {
58+
let directory = (path as NSString).deletingLastPathComponent
59+
let searchPath = basePath + (directory.isEmpty ? "" : directory)
60+
61+
guard let entries = try? FileManager.default.contentsOfDirectory(atPath: searchPath) else {
62+
return nil
63+
}
64+
65+
guard let match = entries
66+
.filter({ $0.hasPrefix("[") && $0.hasSuffix("].html") })
67+
.sorted()
68+
.first
69+
else { return nil }
70+
71+
return (directory.isEmpty ? "" : directory) + "/" + match
72+
}
73+
}
74+
75+
/// Exists only to install `NextExportRouter`. Referenced from `Base.lproj/Main.storyboard`, which
76+
/// otherwise instantiates `CAPBridgeViewController` directly.
77+
class NextExportViewController: CAPBridgeViewController {
78+
override func router() -> Router {
79+
return NextExportRouter()
80+
}
81+
}

ios/CLAUDE.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,15 @@ is in the [root CLAUDE.md](../CLAUDE.md).
1818

1919
## Things that are easy to get wrong
2020

21+
- **The root view controller is `NextExportViewController`, not `CAPBridgeViewController`.** It exists
22+
solely to install `NextExportRouter`. Capacitor's stock router maps every extension-less path to
23+
`index.html`, so a hard navigation to `/someone` or `/blog/a-post` silently served the home page
24+
while `next/link` navigation worked — which looks like a crash, not a routing bug. Our export emits
25+
`[username].html`, `blog/[slug].html`, `vote/[id].html`, `alerts/[id].html` and
26+
`messages/[channelId].html`, and the router resolves onto those. Android is unaffected: its
27+
`WebViewLocalServer` already resolves `.html` itself. If you add a dynamic route, no change is
28+
needed — the router finds the bracketed file by scanning the directory.
29+
2130
- **`packageClassList` must be in `App/App/capacitor.config.json`**, and `npx cap sync ios` does not
2231
put it there. `CapacitorBridge.registerPlugins()` decodes that file into a struct whose
2332
`packageClassList` is non-optional, so an absent key makes the decode throw, the bridge registers

0 commit comments

Comments
 (0)