Skip to content
Open
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 7.5.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
1 change: 1 addition & 0 deletions ios/Capacitor/Capacitor/Router.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ public protocol Router {
public struct CapacitorRouter: Router {
public init() {}
public var basePath: String = ""

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

Expand Down
26 changes: 26 additions & 0 deletions 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 Down Expand Up @@ -94,6 +99,27 @@ open class WebViewAssetHandler: NSObject, WKURLSchemeHandler {
}
urlSchemeTask.didReceive(data)
} catch let error as NSError {
// If routeWithFallback is enabled and the original request was for a file with an extension,
// try to serve index.html as a fallback for SPA routing
if routeWithFallback && stringToLoad.contains(".") && !stringToLoad.hasSuffix("/index.html") {
do {
let indexPath = router.basePath + "/index.html"
let indexUrl = URL(fileURLWithPath: indexPath)
let indexData = try Data(contentsOf: indexUrl)
let indexMimeType = "text/html"
let indexHeaders = [
"Content-Type": indexMimeType,
"Cache-Control": "no-cache"
]
let indexResponse = HTTPURLResponse(url: localUrl, statusCode: 200, httpVersion: nil, headerFields: indexHeaders)
urlSchemeTask.didReceive(indexResponse!)
urlSchemeTask.didReceive(indexData)
urlSchemeTask.didFinish()
return
} catch {
// If index.html also fails, fall through to original error
}
}
urlSchemeTask.didFailWithError(error)
return
}
Expand Down