diff --git a/android/capacitor/src/main/java/com/getcapacitor/CapConfig.java b/android/capacitor/src/main/java/com/getcapacitor/CapConfig.java index 781775ba5d..4c4f846eb9 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 a044bfbe6d..d627598a61 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 db8fe033f8..0705479d27 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 6.7.0 + * @default false + */ + routeWithFallback?: boolean; }; cordova?: { diff --git a/ios/Capacitor/Capacitor/CAPBridgeViewController.swift b/ios/Capacitor/Capacitor/CAPBridgeViewController.swift index 460af412cd..68054604ef 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 171dbb4634..49c9551b73 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 d6b7785e34..30c9c46f36 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 f477ef55be..f43d15bfb4 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 a7eff5f623..69d2387d4b 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 68328f8cda..c4ca78e787 100644 --- a/ios/Capacitor/Capacitor/Router.swift +++ b/ios/Capacitor/Capacitor/Router.swift @@ -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 } diff --git a/ios/Capacitor/Capacitor/WebViewAssetHandler.swift b/ios/Capacitor/Capacitor/WebViewAssetHandler.swift index 4571152485..3bba42c7e7 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 @@ -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) diff --git a/ios/Capacitor/CapacitorTests/RouterTests.swift b/ios/Capacitor/CapacitorTests/RouterTests.swift index 181551ee72..12f0dc6978 100644 --- a/ios/Capacitor/CapacitorTests/RouterTests.swift +++ b/ios/Capacitor/CapacitorTests/RouterTests.swift @@ -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) } }