diff --git a/android/capacitor/src/main/java/com/getcapacitor/CapConfig.java b/android/capacitor/src/main/java/com/getcapacitor/CapConfig.java index 781775ba5..4c4f846eb 100644 --- a/android/capacitor/src/main/java/com/getcapacitor/CapConfig.java +++ b/android/capacitor/src/main/java/com/getcapacitor/CapConfig.java @@ -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; @@ -166,6 +167,7 @@ private CapConfig(Builder builder) { } this.allowNavigation = builder.allowNavigation; + this.routeWithFallback = builder.routeWithFallback; // Android Config this.overriddenUserAgentString = builder.overriddenUserAgentString; @@ -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)) { @@ -349,6 +352,10 @@ public String getHostname() { return hostname; } + public boolean isRouteWithFallback() { + return routeWithFallback; + } + public String getStartPath() { return startPath; } @@ -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; @@ -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; diff --git a/android/capacitor/src/main/java/com/getcapacitor/WebViewLocalServer.java b/android/capacitor/src/main/java/com/getcapacitor/WebViewLocalServer.java index a044bfbe6..d627598a6 100755 --- a/android/capacitor/src/main/java/com/getcapacitor/WebViewLocalServer.java +++ b/android/capacitor/src/main/java/com/getcapacitor/WebViewLocalServer.java @@ -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; diff --git a/cli/src/declarations.ts b/cli/src/declarations.ts index db8fe033f..38dbc8641 100644 --- a/cli/src/declarations.ts +++ b/cli/src/declarations.ts @@ -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?: { diff --git a/ios/Capacitor/Capacitor/CAPBridgeViewController.swift b/ios/Capacitor/Capacitor/CAPBridgeViewController.swift index 460af412c..68054604e 100644 --- a/ios/Capacitor/Capacitor/CAPBridgeViewController.swift +++ b/ios/Capacitor/Capacitor/CAPBridgeViewController.swift @@ -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 diff --git a/ios/Capacitor/Capacitor/CAPInstanceConfiguration.h b/ios/Capacitor/Capacitor/CAPInstanceConfiguration.h index 171dbb463..49c9551b7 100644 --- a/ios/Capacitor/Capacitor/CAPInstanceConfiguration.h +++ b/ios/Capacitor/Capacitor/CAPInstanceConfiguration.h @@ -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; diff --git a/ios/Capacitor/Capacitor/CAPInstanceConfiguration.m b/ios/Capacitor/Capacitor/CAPInstanceConfiguration.m index d6b7785e3..30c9c46f3 100644 --- a/ios/Capacitor/Capacitor/CAPInstanceConfiguration.m +++ b/ios/Capacitor/Capacitor/CAPInstanceConfiguration.m @@ -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; @@ -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; diff --git a/ios/Capacitor/Capacitor/CAPInstanceDescriptor.h b/ios/Capacitor/Capacitor/CAPInstanceDescriptor.h index f477ef55b..f43d15bfb 100644 --- a/ios/Capacitor/Capacitor/CAPInstanceDescriptor.h +++ b/ios/Capacitor/Capacitor/CAPInstanceDescriptor.h @@ -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. diff --git a/ios/Capacitor/Capacitor/CAPInstanceDescriptor.swift b/ios/Capacitor/Capacitor/CAPInstanceDescriptor.swift index a7eff5f62..69d2387d4 100644 --- a/ios/Capacitor/Capacitor/CAPInstanceDescriptor.swift +++ b/ios/Capacitor/Capacitor/CAPInstanceDescriptor.swift @@ -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 diff --git a/ios/Capacitor/Capacitor/Router.swift b/ios/Capacitor/Capacitor/Router.swift index 68328f8cd..9550ea6a9 100644 --- a/ios/Capacitor/Capacitor/Router.swift +++ b/ios/Capacitor/Capacitor/Router.swift @@ -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) diff --git a/ios/Capacitor/Capacitor/WebViewAssetHandler.swift b/ios/Capacitor/Capacitor/WebViewAssetHandler.swift index 457115248..dd3de4ae0 100644 --- a/ios/Capacitor/Capacitor/WebViewAssetHandler.swift +++ b/ios/Capacitor/Capacitor/WebViewAssetHandler.swift @@ -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 @@ -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 @@ -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 }