Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions android/capacitor/src/main/java/com/getcapacitor/CapConfig.java
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ public class CapConfig {
private String hostname = "localhost";
private String androidScheme = CAPACITOR_HTTPS_SCHEME;
private String[] allowNavigation;
private boolean routeWithFallback = false;

// Android Config
private String overriddenUserAgentString;
Expand Down Expand Up @@ -166,6 +167,7 @@ private CapConfig(Builder builder) {
}

this.allowNavigation = builder.allowNavigation;
this.routeWithFallback = builder.routeWithFallback;

// Android Config
this.overriddenUserAgentString = builder.overriddenUserAgentString;
Expand Down Expand Up @@ -252,6 +254,7 @@ private void deserializeConfig(@Nullable Context context) {
hostname = JSONUtils.getString(configJSON, "server.hostname", hostname);
errorPath = JSONUtils.getString(configJSON, "server.errorPath", null);
startPath = JSONUtils.getString(configJSON, "server.appStartPath", null);
routeWithFallback = JSONUtils.getBoolean(configJSON, "server.routeWithFallback", routeWithFallback);

String configSchema = JSONUtils.getString(configJSON, "server.androidScheme", androidScheme);
if (this.validateScheme(configSchema)) {
Expand Down Expand Up @@ -349,6 +352,10 @@ public String getHostname() {
return hostname;
}

public boolean isRouteWithFallback() {
return routeWithFallback;
}

public String getStartPath() {
return startPath;
}
Expand Down Expand Up @@ -574,6 +581,7 @@ public static class Builder {
private String hostname = "localhost";
private String androidScheme = CAPACITOR_HTTPS_SCHEME;
private String[] allowNavigation;
private boolean routeWithFallback = false;

// Android Config Values
private String overriddenUserAgentString;
Expand Down Expand Up @@ -644,6 +652,11 @@ public Builder setHostname(String hostname) {
return this;
}

public Builder setRouteWithFallback(boolean routeWithFallback) {
this.routeWithFallback = routeWithFallback;
return this;
}

public Builder setStartPath(String path) {
this.startPath = path;
return this;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -652,8 +652,20 @@ public InputStream handle(Uri url) {
stream = protocolHandler.openAsset(assetPath + path);
}
} catch (IOException e) {
Logger.error("Unable to open asset URL: " + url);
return null;
// If routeWithFallback is enabled and the path has an extension (contains a dot)
// fallback to index.html for SPA routing
if (bridge.getConfig().isRouteWithFallback() && path.contains(".")) {
try {
String indexPath = isAsset ? assetPath + "/index.html" : basePath + "/index.html";
stream = isAsset ? protocolHandler.openAsset(indexPath) : protocolHandler.openFile(indexPath);
} catch (IOException indexException) {
Logger.error("Unable to open asset URL: " + url);
return null;
}
} else {
Logger.error("Unable to open asset URL: " + url);
return null;
}
}

return stream;
Expand Down
11 changes: 11 additions & 0 deletions cli/src/declarations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -619,6 +619,17 @@ export interface CapacitorConfig {
* @default null
*/
appStartPath?: string;

/**
* Enable fallback to index.html for SPA routes with dots.
* When true, if a requested path contains a dot but the file doesn't exist,
* the server will serve index.html instead. This allows SPA routes like
* /@user.name or /file.json to work correctly when they're not actual files.
*
* @since 6.7.0
* @default false
*/
routeWithFallback?: boolean;
};

cordova?: {
Expand Down
1 change: 1 addition & 0 deletions ios/Capacitor/Capacitor/CAPBridgeViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ import Cordova
let assetHandler = WebViewAssetHandler(router: router())
assetHandler.setAssetPath(configuration.appLocation.path)
assetHandler.setServerUrl(configuration.serverURL)
assetHandler.setRouteWithFallback(configuration.routeWithFallback)
let delegationHandler = WebViewDelegationHandler()
prepareWebView(with: configuration, assetHandler: assetHandler, delegationHandler: delegationHandler)
view = webView
Expand Down
1 change: 1 addition & 0 deletions ios/Capacitor/Capacitor/CAPInstanceConfiguration.h
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ NS_SWIFT_NAME(InstanceConfiguration)
@property (nonatomic, readonly) UIScrollViewContentInsetAdjustmentBehavior contentInsetAdjustmentBehavior;
@property (nonatomic, readonly, nonnull) NSURL *appLocation;
@property (nonatomic, readonly, nullable) NSString *appStartPath;
@property (nonatomic, readonly) BOOL routeWithFallback;
@property (nonatomic, readonly) BOOL limitsNavigationsToAppBoundDomains;
@property (nonatomic, readonly, nullable) NSString *preferredContentMode;

Expand Down
2 changes: 2 additions & 0 deletions ios/Capacitor/Capacitor/CAPInstanceConfiguration.m
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ - (instancetype)initWithDescriptor:(CAPInstanceDescriptor *)descriptor isDebug:(
_contentInsetAdjustmentBehavior = descriptor.contentInsetAdjustmentBehavior;
_appLocation = descriptor.appLocation;
_appStartPath = descriptor.appStartPath;
_routeWithFallback = descriptor.routeWithFallback;
_limitsNavigationsToAppBoundDomains = descriptor.limitsNavigationsToAppBoundDomains;
_preferredContentMode = descriptor.preferredContentMode;
_pluginConfigurations = descriptor.pluginConfigurations;
Expand Down Expand Up @@ -81,6 +82,7 @@ - (instancetype)initWithConfiguration:(CAPInstanceConfiguration*)configuration a
_legacyConfig = [[configuration legacyConfig] copy];
#pragma clang diagnostic pop
_appStartPath = configuration.appStartPath;
_routeWithFallback = configuration.routeWithFallback;
_appLocation = [location copy];
}
return self;
Expand Down
5 changes: 5 additions & 0 deletions ios/Capacitor/Capacitor/CAPInstanceDescriptor.h
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,11 @@ NS_SWIFT_NAME(InstanceDescriptor)
@discussion Defaults to nil, in which case Capacitor will attempt to load @c index.html.
*/
@property (nonatomic, copy, nullable) NSString *appStartPath;
/**
@brief Whether to fallback to index.html for SPA routes with dots when file doesn't exist.
@discussion Defaults to @c false. Set by @c server.routeWithFallback in the configuration file.
*/
@property (nonatomic, assign) BOOL routeWithFallback;
/**
@brief Whether or not the Capacitor WebView will limit the navigation to @c WKAppBoundDomains listed in the Info.plist.
@discussion Defaults to @c false. Set by @c ios.limitsNavigationsToAppBoundDomains in the configuration file. Required to be @c true for plugins to work if the app includes @c WKAppBoundDomains in the Info.plist.
Expand Down
3 changes: 3 additions & 0 deletions ios/Capacitor/Capacitor/CAPInstanceDescriptor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,9 @@ internal extension InstanceDescriptor {
if let startPath = (config[keyPath: "server.appStartPath"] as? String) {
appStartPath = startPath
}
if let fallback = config[keyPath: "server.routeWithFallback"] as? Bool {
routeWithFallback = fallback
}
}
}
// swiftlint:enable cyclomatic_complexity
Expand Down
13 changes: 11 additions & 2 deletions ios/Capacitor/Capacitor/Router.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,20 +9,29 @@
import Foundation

public protocol Router {
func route(for path: String) -> String
func route(for path: String, checkFileExists: Bool) -> String
var basePath: String { get set }
}

public struct CapacitorRouter: Router {
public init() {}
public var basePath: String = ""
public func route(for path: String) -> String {

public func route(for path: String, checkFileExists: Bool = false) -> String {
let pathUrl = URL(fileURLWithPath: path)

// If there's no path extension it also means the path is empty or a SPA route
if pathUrl.pathExtension.isEmpty {
return basePath + "/index.html"
}

// If checkFileExists is enabled and file doesn't exist, fallback to index.html
if checkFileExists {
let fullPath = basePath + path
if !FileManager.default.fileExists(atPath: fullPath) {
return basePath + "/index.html"
}
}

return basePath + path
}
Expand Down
7 changes: 6 additions & 1 deletion ios/Capacitor/Capacitor/WebViewAssetHandler.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import MobileCoreServices
open class WebViewAssetHandler: NSObject, WKURLSchemeHandler {
private var router: Router
private var serverUrl: URL?
private var routeWithFallback: Bool = false

public init(router: Router) {
self.router = router
Expand All @@ -19,6 +20,10 @@ open class WebViewAssetHandler: NSObject, WKURLSchemeHandler {
open func setServerUrl(_ serverUrl: URL?) {
self.serverUrl = serverUrl
}

open func setRouteWithFallback(_ routeWithFallback: Bool) {
self.routeWithFallback = routeWithFallback
}

private func isUsingLiveReload(_ localUrl: URL) -> Bool {
return self.serverUrl != nil && self.serverUrl?.scheme != localUrl.scheme
Expand All @@ -38,7 +43,7 @@ open class WebViewAssetHandler: NSObject, WKURLSchemeHandler {
if stringToLoad.starts(with: CapacitorBridge.fileStartIdentifier) {
startPath = stringToLoad.replacingOccurrences(of: CapacitorBridge.fileStartIdentifier, with: "")
} else {
startPath = router.route(for: stringToLoad)
startPath = router.route(for: stringToLoad, checkFileExists: routeWithFallback)
}

let fileUrl = URL.init(fileURLWithPath: startPath)
Expand Down
19 changes: 17 additions & 2 deletions ios/Capacitor/CapacitorTests/RouterTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -27,12 +27,27 @@ class RouterTests: XCTestCase {
checkRouter(path: "/a/valid/file path.ext", expected: "/a/valid/file path.ext")
}

func testRouterWithFallbackReturnsIndexWhenFileDoesNotExist() {
XCTContext.runActivity(named: "router with fallback returns index.html for non-existent files") { _ in
var router = CapacitorRouter()
router.basePath = "/NonExistentPath"

// When checkFileExists is true and file doesn't exist, should return index.html
XCTAssertEqual(router.route(for: "/@user.name", checkFileExists: true), "/NonExistentPath/index.html")
XCTAssertEqual(router.route(for: "/api/data.json", checkFileExists: true), "/NonExistentPath/index.html")

// When checkFileExists is false, should return the path as-is
XCTAssertEqual(router.route(for: "/@user.name", checkFileExists: false), "/NonExistentPath/@user.name")
XCTAssertEqual(router.route(for: "/api/data.json", checkFileExists: false), "/NonExistentPath/api/data.json")
}
}

func checkRouter(path: String, expected: String) {
XCTContext.runActivity(named: "router creates route path correctly") { _ in
var router = CapacitorRouter()
XCTAssertEqual(router.route(for: path), expected)
XCTAssertEqual(router.route(for: path, checkFileExists: false), expected)
router.basePath = "/A/Route"
XCTAssertEqual(router.route(for: path), "/A/Route" + expected)
XCTAssertEqual(router.route(for: path, checkFileExists: false), "/A/Route" + expected)
}
}

Expand Down