diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 80a064c68a..ea338db0f9 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -21,7 +21,7 @@ concurrency:
jobs:
setup:
runs-on: ubuntu-latest
- timeout-minutes: 10
+ timeout-minutes: 30
steps:
- name: Get Latest
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
@@ -38,7 +38,7 @@ jobs:
key: ${{ runner.OS }}-bun-dependencies-cache-${{ hashFiles('bun.lock') }}
lint:
runs-on: macos-15
- timeout-minutes: 10
+ timeout-minutes: 30
steps:
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
@@ -57,7 +57,7 @@ jobs:
- run: bun run lint
test-cli:
runs-on: macos-15
- timeout-minutes: 10
+ timeout-minutes: 30
needs:
- setup
- lint
@@ -81,7 +81,7 @@ jobs:
working-directory: ./cli
test-core:
runs-on: ubuntu-latest
- timeout-minutes: 10
+ timeout-minutes: 30
needs:
- setup
- lint
@@ -105,7 +105,7 @@ jobs:
working-directory: ./core
test-ios:
runs-on: macos-15
- timeout-minutes: 10
+ timeout-minutes: 30
needs:
- setup
- lint
@@ -136,7 +136,7 @@ jobs:
run: sh ./scripts/native-podspec.sh lint
test-android:
runs-on: ubuntu-latest
- timeout-minutes: 10
+ timeout-minutes: 30
needs:
- setup
- lint
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5064d193a9..78febc3d00 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -3,33 +3,29 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
-## [8.5.2](https://github.com/Cap-go/capacitor-plus/compare/8.5.1...8.5.2) (2026-08-26)
-
-**Note:** Version bump only for package capacitor
-
-
-
-
-
-## [8.5.1](https://github.com/Cap-go/capacitor-plus/compare/8.3.12...8.5.1) (2026-08-25)
-
+## [8.5.2](https://github.com/ionic-team/capacitor/compare/8.5.1...8.5.2) (2026-09-11)
### Bug Fixes
-* **ci:** fetch upstream objects so plus branch sync works ([#109](https://github.com/Cap-go/capacitor-plus/issues/109)) ([1fa7eee](https://github.com/Cap-go/capacitor-plus/commit/1fa7eeeff9494e9b32afb20a9850389b79d6bcec))
-* **ci:** resolve modify/delete conflicts in sync conflict PRs ([#110](https://github.com/Cap-go/capacitor-plus/issues/110)) ([5c29fb9](https://github.com/Cap-go/capacitor-plus/commit/5c29fb9af001b9aafdcde7b7054585e093f44431))
-
+- **android:** add null checks for plugin annotation when retrieving permissions ([#8400](https://github.com/ionic-team/capacitor/issues/8400)) ([035b16a](https://github.com/ionic-team/capacitor/commit/035b16aca15eab136efc49eaec64b45987cfefeb))
+- **ios:** do not forward scene lifecycle events to the page before it has loaded ([#8595](https://github.com/ionic-team/capacitor/issues/8595)) ([c567328](https://github.com/ionic-team/capacitor/commit/c567328d0feb90c306ea824edc5a799bdfe26542))
+- resolve issues with safe area / systembars plugin ([#8535](https://github.com/ionic-team/capacitor/issues/8535)) ([e37d9c6](https://github.com/ionic-team/capacitor/commit/e37d9c60785368acf2a83cfd4c20f47076672bdd))
+## [8.5.1](https://github.com/ionic-team/capacitor/compare/8.5.0...8.5.1) (2026-08-31)
+### Bug Fixes
+- block navigation to the internal HTTP proxy path ([ee586ae](https://github.com/ionic-team/capacitor/commit/ee586ae680887ba99d066616f976db149542d922))
+- **cli:** use POSIX paths in CapApp-SPM Package.swift ([#8549](https://github.com/ionic-team/capacitor/issues/8549)) ([5e5bb3b](https://github.com/ionic-team/capacitor/commit/5e5bb3befc312477900252ab07e23b596f8cb0d1))
+- **core:** prevent removeListener from removing wrong listener ([#8271](https://github.com/ionic-team/capacitor/issues/8271)) ([5ac4dd6](https://github.com/ionic-team/capacitor/commit/5ac4dd613ae989d8dc8738ea25b77efbd4fa21fe))
# [8.5.0](https://github.com/ionic-team/capacitor/compare/8.4.2...8.5.0) (2026-07-31)
-## Bug Fixes
+### Bug Fixes
- **cli:** support TypeScript 7 when loading capacitor.config.ts ([#8534](https://github.com/ionic-team/capacitor/issues/8534)) ([4c1c870](https://github.com/ionic-team/capacitor/commit/4c1c8709413b9c19b008c99122ca330cc3c90e6f))
-## Features
+### Features
- **cli:** add migrator functionality for adopting UIScene ([#8544](https://github.com/ionic-team/capacitor/issues/8544)) ([984fa85](https://github.com/ionic-team/capacitor/commit/984fa85ba0adab0aacf895aed6323bf4b503dccb))
- **ios:** UIScene Support ([#8536](https://github.com/ionic-team/capacitor/issues/8536)) ([3fa04a3](https://github.com/ionic-team/capacitor/commit/3fa04a357c92af34cd6fccb8124791963804a9dc))
@@ -49,14 +45,14 @@ See [Conventional Commits](https://conventionalcommits.org) for commit guideline
# [8.4.0](https://github.com/ionic-team/capacitor/compare/8.3.4...8.4.0) (2026-06-02)
-## Bug Fixes
+### Bug Fixes
- **android:** show only the requested system bar ([#8480](https://github.com/ionic-team/capacitor/issues/8480)) ([4c6c321](https://github.com/ionic-team/capacitor/commit/4c6c3219afb5223211e857457e46283c37eb9424))
- **cli:** revert live reload config on failure ([#8485](https://github.com/ionic-team/capacitor/issues/8485)) ([1d031a4](https://github.com/ionic-team/capacitor/commit/1d031a4abec2c793079ba8897ad2e40c4cc6c7f9))
- **SystemBars:** make `safe-area-inset-x` available on API <= 34 ([#8424](https://github.com/ionic-team/capacitor/issues/8424)) ([e456de0](https://github.com/ionic-team/capacitor/commit/e456de083e19644f484bec5a5359cb67960ac8bc))
- **SystemBars:** respect `insetsHandling` disable ([#8481](https://github.com/ionic-team/capacitor/issues/8481)) ([d4ad7ff](https://github.com/ionic-team/capacitor/commit/d4ad7ffe39daf66e0cfc63af9028d5c05543bde7))
-## Features
+### Features
- add method getDouble to plugin config ([#7638](https://github.com/ionic-team/capacitor/issues/7638)) ([93c72de](https://github.com/ionic-team/capacitor/commit/93c72de40a2ec4c78b33659250cb08340083088e))
- **cli:** add experimental packageOptions ([#8471](https://github.com/ionic-team/capacitor/issues/8471)) ([258867b](https://github.com/ionic-team/capacitor/commit/258867b7bf37b1837b99b02ec9638e5a6df08d97))
diff --git a/android/CHANGELOG.md b/android/CHANGELOG.md
index a6ec97e3ba..7c7202743b 100644
--- a/android/CHANGELOG.md
+++ b/android/CHANGELOG.md
@@ -3,21 +3,18 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
-## [8.5.2](https://github.com/Cap-go/capacitor-plus/compare/8.5.1...8.5.2) (2026-08-26)
+## [8.5.2](https://github.com/ionic-team/capacitor/compare/8.5.1...8.5.2) (2026-09-11)
-**Note:** Version bump only for package @capacitor-plus/android
-
-
-
-
-
-## [8.5.1](https://github.com/Cap-go/capacitor-plus/compare/8.3.12...8.5.1) (2026-08-25)
-
-**Note:** Version bump only for package @capacitor-plus/android
+### Bug Fixes
+- **android:** add null checks for plugin annotation when retrieving permissions ([#8400](https://github.com/ionic-team/capacitor/issues/8400)) ([035b16a](https://github.com/ionic-team/capacitor/commit/035b16aca15eab136efc49eaec64b45987cfefeb))
+- resolve issues with safe area / systembars plugin ([#8535](https://github.com/ionic-team/capacitor/issues/8535)) ([e37d9c6](https://github.com/ionic-team/capacitor/commit/e37d9c60785368acf2a83cfd4c20f47076672bdd))
+## [8.5.1](https://github.com/ionic-team/capacitor/compare/8.5.0...8.5.1) (2026-08-31)
+### Bug Fixes
+- block navigation to the internal HTTP proxy path ([ee586ae](https://github.com/ionic-team/capacitor/commit/ee586ae680887ba99d066616f976db149542d922))
# [8.5.0](https://github.com/ionic-team/capacitor/compare/8.4.2...8.5.0) (2026-07-31)
diff --git a/android/capacitor/src/androidTest/AndroidManifest.xml b/android/capacitor/src/androidTest/AndroidManifest.xml
new file mode 100644
index 0000000000..33952f2e02
--- /dev/null
+++ b/android/capacitor/src/androidTest/AndroidManifest.xml
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
diff --git a/android/capacitor/src/androidTest/java/com/getcapacitor/android/HttpInterceptorNavigationTest.java b/android/capacitor/src/androidTest/java/com/getcapacitor/android/HttpInterceptorNavigationTest.java
new file mode 100644
index 0000000000..1cde86b4ba
--- /dev/null
+++ b/android/capacitor/src/androidTest/java/com/getcapacitor/android/HttpInterceptorNavigationTest.java
@@ -0,0 +1,115 @@
+package com.getcapacitor.android;
+
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+
+import android.net.Uri;
+import android.webkit.WebResourceRequest;
+import androidx.test.core.app.ActivityScenario;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import com.getcapacitor.Bridge;
+import java.util.HashMap;
+import java.util.Map;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+/**
+ * The proxy path shares the app's host and scheme, so the host/scheme guard alone would let it
+ * load in the WebView.
+ */
+@RunWith(AndroidJUnit4.class)
+public class HttpInterceptorNavigationTest {
+
+ private static final String INTERCEPTOR_URL =
+ "https://localhost" + Bridge.CAPACITOR_HTTP_INTERCEPTOR_START + "?u=https://example.com/payload.html";
+ private static final String IN_APP_URL = "https://localhost/index.html";
+ private static final String EXTERNAL_URL = "https://example.com/";
+
+ /** A plugin registered by the test host returns "allow" for the proxy path; it must not win. */
+ @Test
+ public void blocksNavigationToInterceptorPath() {
+ try (ActivityScenario scenario = ActivityScenario.launch(TestHostActivity.class)) {
+ scenario.onActivity((activity) -> {
+ Bridge bridge = activity.getBridge();
+ assertNotNull(bridge);
+ assertTrue("interceptor navigation must be blocked", bridge.launchIntent(Uri.parse(INTERCEPTOR_URL)));
+ assertFalse("in-app navigation must stay in the WebView", bridge.launchIntent(Uri.parse(IN_APP_URL)));
+ assertTrue("external navigation must leave the WebView", bridge.launchIntent(Uri.parse(EXTERNAL_URL)));
+ });
+ }
+ }
+
+ /** An iframe looks like a fetch to isForMainFrame(), so subframe documents must be refused too. */
+ @Test
+ public void refusesProxyForDocumentRequests() {
+ try (ActivityScenario scenario = ActivityScenario.launch(TestHostActivity.class)) {
+ scenario.onActivity((activity) -> {
+ Bridge bridge = activity.getBridge();
+ assertNotNull(bridge);
+ // Without this the proxy refuses everything and the assertions below prove nothing.
+ assertTrue(
+ "CapacitorHttp must be enabled for this test to mean anything",
+ bridge.getConfig().getPluginConfiguration("CapacitorHttp").getBoolean("enabled", false)
+ );
+
+ Map navHeaders = new HashMap<>();
+ navHeaders.put("Accept", "text/html,application/xhtml+xml");
+ navHeaders.put("Upgrade-Insecure-Requests", "1");
+
+ assertNull(
+ "main frame document must be refused",
+ bridge.getLocalServer().shouldInterceptRequest(new FakeRequest(INTERCEPTOR_URL, true, navHeaders))
+ );
+ assertNull(
+ "iframe document must be refused",
+ bridge.getLocalServer().shouldInterceptRequest(new FakeRequest(INTERCEPTOR_URL, false, navHeaders))
+ );
+ });
+ }
+ }
+
+ private static class FakeRequest implements WebResourceRequest {
+
+ private final Uri url;
+ private final boolean mainFrame;
+ private final Map headers;
+
+ FakeRequest(String url, boolean mainFrame, Map headers) {
+ this.url = Uri.parse(url);
+ this.mainFrame = mainFrame;
+ this.headers = headers;
+ }
+
+ @Override
+ public Uri getUrl() {
+ return url;
+ }
+
+ @Override
+ public boolean isForMainFrame() {
+ return mainFrame;
+ }
+
+ @Override
+ public boolean isRedirect() {
+ return false;
+ }
+
+ @Override
+ public boolean hasGesture() {
+ return false;
+ }
+
+ @Override
+ public String getMethod() {
+ return "GET";
+ }
+
+ @Override
+ public Map getRequestHeaders() {
+ return headers;
+ }
+ }
+}
diff --git a/android/capacitor/src/androidTest/java/com/getcapacitor/android/InterceptorAllowingPlugin.java b/android/capacitor/src/androidTest/java/com/getcapacitor/android/InterceptorAllowingPlugin.java
new file mode 100644
index 0000000000..49be9f906a
--- /dev/null
+++ b/android/capacitor/src/androidTest/java/com/getcapacitor/android/InterceptorAllowingPlugin.java
@@ -0,0 +1,20 @@
+package com.getcapacitor.android;
+
+import android.net.Uri;
+import com.getcapacitor.Bridge;
+import com.getcapacitor.Plugin;
+import com.getcapacitor.annotation.CapacitorPlugin;
+
+/** A plugin that tries to allow the proxy path. The navigation guard must ignore it. */
+@CapacitorPlugin(name = "InterceptorAllowingPlugin")
+public class InterceptorAllowingPlugin extends Plugin {
+
+ @Override
+ public Boolean shouldOverrideLoad(Uri url) {
+ String path = url.getPath();
+ if (path != null && path.startsWith(Bridge.CAPACITOR_HTTP_INTERCEPTOR_START)) {
+ return false; // "allow this navigation"
+ }
+ return null;
+ }
+}
diff --git a/android/capacitor/src/androidTest/java/com/getcapacitor/android/TestHostActivity.java b/android/capacitor/src/androidTest/java/com/getcapacitor/android/TestHostActivity.java
new file mode 100644
index 0000000000..05d7a5d7c5
--- /dev/null
+++ b/android/capacitor/src/androidTest/java/com/getcapacitor/android/TestHostActivity.java
@@ -0,0 +1,27 @@
+package com.getcapacitor.android;
+
+import com.getcapacitor.BridgeActivity;
+import com.getcapacitor.CapConfig;
+import org.json.JSONException;
+import org.json.JSONObject;
+
+/**
+ * Host for instrumented tests. CapacitorHttp is on so the proxy is reachable, and a plugin that
+ * tries to allow the proxy path is registered so tests can prove the guard still wins.
+ */
+public class TestHostActivity extends BridgeActivity {
+
+ @Override
+ protected void load() {
+ registerPlugin(InterceptorAllowingPlugin.class);
+
+ try {
+ JSONObject plugins = new JSONObject("{\"CapacitorHttp\":{\"enabled\":true}}");
+ config = new CapConfig.Builder(this).setPluginsConfiguration(plugins).create();
+ } catch (JSONException e) {
+ throw new IllegalStateException("bad test plugin config", e);
+ }
+
+ super.load();
+ }
+}
diff --git a/android/capacitor/src/main/assets/native-bridge.js b/android/capacitor/src/main/assets/native-bridge.js
index 5a26f66e06..2485098ca0 100644
--- a/android/capacitor/src/main/assets/native-bridge.js
+++ b/android/capacitor/src/main/assets/native-bridge.js
@@ -417,12 +417,6 @@ var nativeBridge = (function (exports) {
}
};
const platform = getPlatformId(win);
- if (platform == 'android' && typeof win.CapacitorSystemBarsAndroidInterface !== 'undefined') {
- // add DOM ready listener for System Bars
- document.addEventListener('DOMContentLoaded', function () {
- win.CapacitorSystemBarsAndroidInterface.onDOMReady();
- });
- }
if (platform == 'android' || platform == 'ios') {
// patch document.cookie on Android/iOS
win.CapacitorCookiesDescriptor =
diff --git a/android/capacitor/src/main/java/com/getcapacitor/Bridge.java b/android/capacitor/src/main/java/com/getcapacitor/Bridge.java
index ca6fd9c8fc..4d16996c86 100644
--- a/android/capacitor/src/main/java/com/getcapacitor/Bridge.java
+++ b/android/capacitor/src/main/java/com/getcapacitor/Bridge.java
@@ -394,6 +394,12 @@ private int extractWebViewMajorVersion(final PackageManager pm, final String web
}
public boolean launchIntent(Uri url) {
+ // The proxy returns a remote body at the app origin, so block it before plugins can allow it.
+ String path = url.getPath();
+ if (path != null && path.startsWith(CAPACITOR_HTTP_INTERCEPTOR_START)) {
+ return true;
+ }
+
/*
* Give plugins the chance to handle the url
*/
@@ -1262,6 +1268,10 @@ protected Map getPermissionStates(Plugin plugin) {
}
}
}
+ } else {
+ Logger.warn(
+ String.format("getPermissionStates: missing @CapacitorPlugin annotation for plugin %s", plugin.getPluginHandle().getId())
+ );
}
return permissionsResults;
diff --git a/android/capacitor/src/main/java/com/getcapacitor/BridgeWebChromeClient.java b/android/capacitor/src/main/java/com/getcapacitor/BridgeWebChromeClient.java
index c2075811a0..bac57209fa 100644
--- a/android/capacitor/src/main/java/com/getcapacitor/BridgeWebChromeClient.java
+++ b/android/capacitor/src/main/java/com/getcapacitor/BridgeWebChromeClient.java
@@ -412,12 +412,6 @@ private boolean showImageCapturePicker(final ValueCallback filePathCallba
}
takePictureIntent.putExtra(MediaStore.EXTRA_OUTPUT, imageFileUri);
takePictureIntent.addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION | Intent.FLAG_GRANT_READ_URI_PERMISSION);
-
- // Store in static variables to survive activity recreation
- pendingFilePathCallback = filePathCallback;
- pendingImageFileUri = imageFileUri;
- pendingFileChooserType = FileChooserType.IMAGE_CAPTURE;
-
activityListener = (activityResult) -> {
Uri[] result = null;
if (activityResult.getResultCode() == Activity.RESULT_OK) {
diff --git a/android/capacitor/src/main/java/com/getcapacitor/Plugin.java b/android/capacitor/src/main/java/com/getcapacitor/Plugin.java
index 19c8b8b3a6..f70cc4101d 100644
--- a/android/capacitor/src/main/java/com/getcapacitor/Plugin.java
+++ b/android/capacitor/src/main/java/com/getcapacitor/Plugin.java
@@ -502,10 +502,14 @@ protected void requestPermissionForAliases(@NonNull String[] aliases, @NonNull P
private String[] getPermissionStringsForAliases(@NonNull String[] aliases) {
CapacitorPlugin annotation = handle.getPluginAnnotation();
HashSet perms = new HashSet<>();
- for (Permission perm : annotation.permissions()) {
- if (Arrays.asList(aliases).contains(perm.alias())) {
- perms.addAll(Arrays.asList(perm.strings()));
+ if (annotation != null) {
+ for (Permission perm : annotation.permissions()) {
+ if (Arrays.asList(aliases).contains(perm.alias())) {
+ perms.addAll(Arrays.asList(perm.strings()));
+ }
}
+ } else {
+ Logger.warn(String.format("getPermissionStringsForAliases: missing @CapacitorPlugin annotation for plugin %s", handle.getId()));
}
return perms.toArray(new String[0]);
@@ -1003,7 +1007,8 @@ protected void handleOnDestroy() {}
* Give the plugins a chance to take control when a URL is about to be loaded in the WebView.
* Returning true causes the WebView to abort loading the URL.
* Returning false causes the WebView to continue loading the URL.
- * Returning null will defer to the default Capacitor policy
+ * Returning null will defer to the default Capacitor policy.
+ * Not called for Capacitor's internal HTTP proxy path, which is always blocked.
*/
@SuppressWarnings("unused")
public Boolean shouldOverrideLoad(Uri url) {
diff --git a/android/capacitor/src/main/java/com/getcapacitor/WebViewLocalServer.java b/android/capacitor/src/main/java/com/getcapacitor/WebViewLocalServer.java
index 016609ce3a..8974173a23 100755
--- a/android/capacitor/src/main/java/com/getcapacitor/WebViewLocalServer.java
+++ b/android/capacitor/src/main/java/com/getcapacitor/WebViewLocalServer.java
@@ -185,6 +185,11 @@ public WebResourceResponse shouldInterceptRequest(WebResourceRequest request) {
Uri loadingUrl = request.getUrl();
if (null != loadingUrl.getPath() && loadingUrl.getPath().startsWith(Bridge.CAPACITOR_HTTP_INTERCEPTOR_START)) {
+ // Only fetch/XHR should reach the proxy; a document would run remote content at the app origin.
+ boolean httpEnabled = bridge.getConfig().getPluginConfiguration("CapacitorHttp").getBoolean("enabled", false);
+ if (!httpEnabled || isDocumentRequest(request)) {
+ return null;
+ }
Logger.debug("Handling CapacitorHttp request: " + loadingUrl);
try {
return handleCapacitorHttpRequest(request);
@@ -210,6 +215,24 @@ public WebResourceResponse shouldInterceptRequest(WebResourceRequest request) {
}
}
+ /** isForMainFrame() is false for an iframe and a fetch alike; only navigations send this header. */
+ private boolean isDocumentRequest(WebResourceRequest request) {
+ if (request.isForMainFrame()) {
+ return true;
+ }
+ Map headers = request.getRequestHeaders();
+ if (headers == null) {
+ // The proxy needs the headers too, so the request fails there anyway.
+ return false;
+ }
+ for (String header : headers.keySet()) {
+ if ("Upgrade-Insecure-Requests".equalsIgnoreCase(header)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
private boolean isLocalFile(Uri uri) {
String path = uri.getPath();
return path.startsWith(capacitorContentStart) || path.startsWith(capacitorFileStart);
@@ -333,6 +356,9 @@ private WebResourceResponse handleCapacitorHttpRequest(WebResourceRequest reques
int responseCode = connection.getResponseCode();
String reasonPhrase = getReasonPhraseFromResponseCode(responseCode);
+ // Nothing should render this. If anything does, sandbox keeps it inert and off the app origin.
+ responseHeaders.put("Content-Security-Policy", "sandbox; frame-ancestors 'none'");
+
return new WebResourceResponse(mimeType, encoding, responseCode, reasonPhrase, responseHeaders, inputStream);
}
@@ -756,48 +782,6 @@ public long skip(long n) throws IOException {
}
}
- /**
- * An InputStream wrapper that limits the number of bytes that can be read.
- */
- static class BoundedInputStream extends InputStream {
-
- private final InputStream in;
- private long remaining;
-
- public BoundedInputStream(InputStream in, long limit) {
- this.in = in;
- this.remaining = limit;
- }
-
- @Override
- public int available() throws IOException {
- int available = in.available();
- return (int) Math.min(available, remaining);
- }
-
- @Override
- public int read() throws IOException {
- if (remaining <= 0) return -1;
- int result = in.read();
- if (result != -1) remaining--;
- return result;
- }
-
- @Override
- public int read(byte[] b, int off, int len) throws IOException {
- if (remaining <= 0) return -1;
- int toRead = (int) Math.min(len, remaining);
- int result = in.read(b, off, toRead);
- if (result > 0) remaining -= result;
- return result;
- }
-
- @Override
- public void close() throws IOException {
- in.close();
- }
- }
-
// For L and above.
private static class LollipopLazyInputStream extends LazyInputStream {
diff --git a/android/capacitor/src/main/java/com/getcapacitor/cordova/MockCordovaWebViewImpl.java b/android/capacitor/src/main/java/com/getcapacitor/cordova/MockCordovaWebViewImpl.java
index 1115429d7b..020c1457a1 100644
--- a/android/capacitor/src/main/java/com/getcapacitor/cordova/MockCordovaWebViewImpl.java
+++ b/android/capacitor/src/main/java/com/getcapacitor/cordova/MockCordovaWebViewImpl.java
@@ -70,14 +70,12 @@ public CapacitorEvalBridgeMode(WebView webView, CordovaInterface cordova) {
@Override
public void onNativeToJsMessageAvailable(final NativeToJsMessageQueue queue) {
- cordova
- .getActivity()
- .runOnUiThread(() -> {
- String js = queue.popAndEncodeAsJs();
- if (js != null) {
- webView.evaluateJavascript(js, null);
- }
- });
+ cordova.getActivity().runOnUiThread(() -> {
+ String js = queue.popAndEncodeAsJs();
+ if (js != null) {
+ webView.evaluateJavascript(js, null);
+ }
+ });
}
}
diff --git a/android/capacitor/src/main/java/com/getcapacitor/plugin/SystemBars.java b/android/capacitor/src/main/java/com/getcapacitor/plugin/SystemBars.java
index 84a4bfa8a3..3d40d2097f 100644
--- a/android/capacitor/src/main/java/com/getcapacitor/plugin/SystemBars.java
+++ b/android/capacitor/src/main/java/com/getcapacitor/plugin/SystemBars.java
@@ -9,7 +9,6 @@
import android.util.TypedValue;
import android.view.View;
import android.view.Window;
-import android.webkit.JavascriptInterface;
import android.webkit.WebView;
import androidx.core.graphics.Insets;
import androidx.core.view.ViewCompat;
@@ -37,6 +36,7 @@ public class SystemBars extends Plugin {
// TODO: In Cap 9, add an additional option "full"
static final String INSETS_HANDLING_CSS = "css";
static final String INSETS_HANDLING_DISABLE = "disable";
+ static final String INSETS_HANDLING_NATIVE = "native";
// https://issues.chromium.org/issues/40699457
private static final int WEBVIEW_VERSION_WITH_SAFE_AREA_FIX = 140;
@@ -44,17 +44,17 @@ public class SystemBars extends Plugin {
private static final int WEBVIEW_VERSION_WITH_SAFE_AREA_KEYBOARD_FIX = 144;
static final String viewportMetaJSFunction = """
- function capacitorSystemBarsCheckMetaViewport() {
- const meta = document.querySelectorAll("meta[name=viewport]");
- if (meta.length == 0) {
- return false;
- }
- // get the last found meta viewport tag
- const metaContent = meta[meta.length - 1].content;
- return metaContent.includes("viewport-fit=cover");
+ function capacitorSystemBarsCheckMetaViewport() {
+ const meta = document.querySelectorAll("meta[name=viewport]");
+ if (meta.length == 0) {
+ return false;
}
- capacitorSystemBarsCheckMetaViewport();
- """;
+ // get the last found meta viewport tag
+ const metaContent = meta[meta.length - 1].content;
+ return metaContent.includes("viewport-fit=cover");
+ }
+ capacitorSystemBarsCheckMetaViewport();
+ """;
private String insetsHandling = INSETS_HANDLING_CSS;
private boolean hasViewportCover = false;
@@ -62,11 +62,22 @@ function capacitorSystemBarsCheckMetaViewport() {
private String currentStatusBarStyle = STYLE_DEFAULT;
private String currentGestureBarStyle = STYLE_DEFAULT;
- private boolean navBarVisible = true;
+ // Declare variable at this scope to help prevent adding multiple listeners.
+ private WebViewListener webViewListener;
+
+ private void warnAboutUnsupportedConfigurationValues() {
+ boolean keyboardResizeOnFullScreen = bridge.getConfig().getPluginConfiguration("Keyboard").getBoolean("resizeOnFullScreen", false);
+
+ if (!INSETS_HANDLING_DISABLE.equals(insetsHandling) && keyboardResizeOnFullScreen) {
+ Logger.warn(
+ "SystemBars",
+ "You should omit `Keyboard.resizeOnFullScreen` in your `capacitor.config.json`. Other values can lead to unexpected behavior."
+ );
+ }
+ }
@Override
public void load() {
- getBridge().getWebView().addJavascriptInterface(this, "CapacitorSystemBarsAndroidInterface");
super.load();
initSystemBars();
@@ -76,23 +87,25 @@ public void load() {
protected void handleOnStart() {
super.handleOnStart();
- this.getBridge().addWebViewListener(
- new WebViewListener() {
+ if (INSETS_HANDLING_DISABLE.equals(insetsHandling)) {
+ return;
+ }
+
+ if (webViewListener == null) {
+ webViewListener = new WebViewListener() {
@Override
public void onPageCommitVisible(WebView view, String url) {
super.onPageCommitVisible(view, url);
- View parentView = (View) getBridge().getWebView().getParent();
- ViewCompat.requestApplyInsets(parentView);
- if (INSETS_HANDLING_CSS.equals(insetsHandling)) {
- WindowInsetsCompat rootInsets = ViewCompat.getRootWindowInsets(parentView);
- if (rootInsets != null) {
- Insets safeArea = calcSafeAreaInsets(rootInsets);
- injectSafeAreaCSS(safeArea.top, safeArea.right, safeArea.bottom, safeArea.left);
- }
- }
+ bridge.getWebView().evaluateJavascript(viewportMetaJSFunction, (res) -> {
+ hasViewportCover = res.equals("true");
+
+ // Request new execution tree of `setOnApplyWindowInsetsListener`
+ bridge.getWebView().requestApplyInsets();
+ });
}
- }
- );
+ };
+ this.getBridge().addWebViewListener(webViewListener);
+ }
}
@Override
@@ -104,11 +117,20 @@ protected void handleOnConfigurationChanged(Configuration newConfig) {
}
private void initSystemBars() {
+ // If you already know what the value of the `viewport-fit=` meta tag is going to be,
+ // passing it here through `initialViewportFitValueHint` can help prevent layout shifting.
+ String configuredInitialViewportFitValueHint = getConfig().getString("initialViewportFitValueHint", "");
+ hasViewportCover = "cover".equals(configuredInitialViewportFitValueHint);
+
String style = getConfig().getString("style", STYLE_DEFAULT).toUpperCase(Locale.US);
boolean hidden = getConfig().getBoolean("hidden", false);
String configuredInsetsHandling = getConfig().getString("insetsHandling", INSETS_HANDLING_CSS);
- if (INSETS_HANDLING_CSS.equals(configuredInsetsHandling) || INSETS_HANDLING_DISABLE.equals(configuredInsetsHandling)) {
+ if (
+ INSETS_HANDLING_CSS.equals(configuredInsetsHandling) ||
+ INSETS_HANDLING_DISABLE.equals(configuredInsetsHandling) ||
+ INSETS_HANDLING_NATIVE.equals(configuredInsetsHandling)
+ ) {
insetsHandling = configuredInsetsHandling;
} else {
Logger.warn(
@@ -118,8 +140,9 @@ private void initSystemBars() {
insetsHandling = INSETS_HANDLING_CSS;
}
+ warnAboutUnsupportedConfigurationValues();
+
initWindowInsetsListener();
- initSafeAreaCSSVariables();
getBridge().executeOnMainThread(() -> {
Window window = getActivity().getWindow();
@@ -172,110 +195,29 @@ public void setAnimation(final PluginCall call) {
call.resolve();
}
- @Override
- protected void handleOnResume() {
- super.handleOnResume();
- getBridge().executeOnMainThread(() -> {
- Window window = getActivity().getWindow();
- WindowCompat.setDecorFitsSystemWindows(window, false);
- if (Build.VERSION.SDK_INT < Build.VERSION_CODES.VANILLA_ICE_CREAM) {
- window.setNavigationBarColor(android.graphics.Color.TRANSPARENT);
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
- window.setNavigationBarContrastEnforced(false);
- }
- }
- setStyle(currentGestureBarStyle, BAR_GESTURE_BAR);
- setStyle(currentStatusBarStyle, BAR_STATUS_BAR);
- ViewCompat.requestApplyInsets((View) getBridge().getWebView().getParent());
- });
- }
-
- @JavascriptInterface
- public void onDOMReady() {
- if (INSETS_HANDLING_CSS.equals(insetsHandling)) {
- getActivity().runOnUiThread(() -> {
- this.bridge.getWebView().evaluateJavascript(viewportMetaJSFunction, (res) -> {
- hasViewportCover = res.equals("true");
-
- getBridge().getWebView().requestApplyInsets();
- });
- });
- }
- }
-
- private Insets calcSafeAreaInsets(WindowInsetsCompat insets) {
- Insets safeArea = insets.getInsets(WindowInsetsCompat.Type.systemBars() | WindowInsetsCompat.Type.displayCutout());
-
- int bottom = safeArea.bottom;
-
- if (bottom == 0 && Build.VERSION.SDK_INT < Build.VERSION_CODES.R && safeArea.left == 0 && safeArea.right == 0) {
- // skip if nav bar is on a side (landscape)
- bottom = getNavBarHeightFromResources();
- }
-
- boolean imeVisible = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O
- ? insets.isVisible(WindowInsetsCompat.Type.ime())
- : insets.getInsets(WindowInsetsCompat.Type.ime()).bottom > 0;
-
- if (imeVisible) {
- return Insets.of(safeArea.left, safeArea.top, safeArea.right, 0);
- }
- return Insets.of(safeArea.left, safeArea.top, safeArea.right, bottom);
- }
-
- // Only reached on API < 30 when the window reports no inset on any edge, so the
- // choice is this approximation or a hardcoded 0. Lint rejects the internal
- // resource because OEMs may change it; a wrong height still beats no inset.
- @SuppressLint({ "InternalInsetResource", "DiscouragedApi" })
- private int getNavBarHeightFromResources() {
- if (!navBarVisible) return 0;
- android.content.res.Resources res = getActivity().getResources();
- int heightId = res.getIdentifier("navigation_bar_height", "dimen", "android");
- return heightId > 0 ? res.getDimensionPixelSize(heightId) : 0;
- }
-
- private void initSafeAreaCSSVariables() {
- if (INSETS_HANDLING_CSS.equals(insetsHandling)) {
- WindowInsetsCompat insets;
-
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM) {
- View v = (View) this.getBridge().getWebView().getParent();
- insets = ViewCompat.getRootWindowInsets(v);
- } else {
- insets = WindowInsetsCompat.CONSUMED;
- }
-
- if (insets != null) {
- Insets safeAreaInsets = calcSafeAreaInsets(insets);
- injectSafeAreaCSS(safeAreaInsets.top, safeAreaInsets.right, safeAreaInsets.bottom, safeAreaInsets.left);
- }
- }
- }
-
private void initWindowInsetsListener() {
if (INSETS_HANDLING_DISABLE.equals(insetsHandling)) {
return;
}
- ViewCompat.setOnApplyWindowInsetsListener((View) getBridge().getWebView().getParent(), (v, insets) -> {
- // getRootWindowInsets() bypasses AppCompat's intermediate view consuming the bottom inset on API < 30
- WindowInsetsCompat rawInsets = ViewCompat.getRootWindowInsets(v);
- WindowInsetsCompat safeAreaSource = (rawInsets != null) ? rawInsets : insets;
+ View view = getActivity().getWindow().getDecorView();
+ ViewCompat.setOnApplyWindowInsetsListener(view, (v, insets) -> {
boolean shouldPassthroughInsets = getWebViewMajorVersion() >= WEBVIEW_VERSION_WITH_SAFE_AREA_FIX && hasViewportCover;
Insets systemBarsInsets = insets.getInsets(WindowInsetsCompat.Type.systemBars() | WindowInsetsCompat.Type.displayCutout());
Insets imeInsets = insets.getInsets(WindowInsetsCompat.Type.ime());
boolean keyboardVisible = insets.isVisible(WindowInsetsCompat.Type.ime());
+ // Below API 30 the window is already resized by the system for the IME (adjustResize) and
+ // WindowInsetsCompat can only approximate the IME inset, so padding for it as well would
+ // shrink the WebView twice. See https://github.com/ionic-team/capacitor/issues/8601
+ boolean padForIme = keyboardVisible && Build.VERSION.SDK_INT >= Build.VERSION_CODES.R;
if (shouldPassthroughInsets) {
// We need to correct for a possible shown IME
- v.setPadding(0, 0, 0, keyboardVisible ? imeInsets.bottom : 0);
-
- Insets safeAreaInsets = calcSafeAreaInsets(safeAreaSource);
- injectSafeAreaCSS(safeAreaInsets.top, safeAreaInsets.right, safeAreaInsets.bottom, safeAreaInsets.left);
+ v.setPadding(0, 0, 0, padForIme ? imeInsets.bottom : 0);
- return new WindowInsetsCompat.Builder(insets)
+ WindowInsetsCompat newInsets = new WindowInsetsCompat.Builder(insets)
.setInsets(
WindowInsetsCompat.Type.systemBars() | WindowInsetsCompat.Type.displayCutout(),
Insets.of(
@@ -286,9 +228,19 @@ private void initWindowInsetsListener() {
)
)
.build();
+
+ injectSafeAreaCSS(newInsets);
+
+ return newInsets;
}
- v.setPadding(0, 0, 0, keyboardVisible ? imeInsets.bottom : 0);
+ // We need to correct for a possible shown IME
+ v.setPadding(
+ systemBarsInsets.left,
+ systemBarsInsets.top,
+ systemBarsInsets.right,
+ padForIme ? imeInsets.bottom : systemBarsInsets.bottom
+ );
// Returning `WindowInsetsCompat.CONSUMED` breaks recalculation of safe area insets
// So we have to explicitly set insets to `0`
@@ -297,20 +249,28 @@ private void initWindowInsetsListener() {
.setInsets(WindowInsetsCompat.Type.systemBars() | WindowInsetsCompat.Type.displayCutout(), Insets.of(0, 0, 0, 0))
.build();
- Insets safeAreaInsets = calcSafeAreaInsets(safeAreaSource);
- injectSafeAreaCSS(safeAreaInsets.top, safeAreaInsets.right, safeAreaInsets.bottom, safeAreaInsets.left);
+ injectSafeAreaCSS(newInsets);
return newInsets;
});
}
- private void injectSafeAreaCSS(int top, int right, int bottom, int left) {
+ private void injectSafeAreaCSS(WindowInsetsCompat insets) {
+ if (!INSETS_HANDLING_CSS.equals(insetsHandling)) {
+ return;
+ }
+
+ Insets systemBarsInsets = insets.getInsets(WindowInsetsCompat.Type.systemBars() | WindowInsetsCompat.Type.displayCutout());
+ boolean keyboardVisible = insets.isVisible(WindowInsetsCompat.Type.ime());
+
// Convert pixels to density-independent pixels
float density = getActivity().getResources().getDisplayMetrics().density;
- float topPx = top / density;
- float rightPx = right / density;
- float bottomPx = bottom / density;
- float leftPx = left / density;
+ float topPx = systemBarsInsets.top / density;
+ float rightPx = systemBarsInsets.right / density;
+ // For native insets the value gets automatically corrected when the IME is visible (in newer WebView versions),
+ // but for these injected values we have to handle that manually (for all WebView versions).
+ float bottomPx = (keyboardVisible ? 0 : systemBarsInsets.bottom) / density;
+ float leftPx = systemBarsInsets.left / density;
// Execute JavaScript to inject the CSS
getBridge().executeOnMainThread(() -> {
@@ -364,7 +324,6 @@ private void setHidden(boolean hide, String bar) {
if (hide) {
if (bar.isEmpty()) {
windowInsetsControllerCompat.hide(WindowInsetsCompat.Type.systemBars());
- navBarVisible = false;
} else if (bar.equals(BAR_STATUS_BAR)) {
windowInsetsControllerCompat.hide(WindowInsetsCompat.Type.statusBars());
} else if (bar.equals(BAR_GESTURE_BAR)) {
@@ -376,7 +335,6 @@ private void setHidden(boolean hide, String bar) {
if (bar.isEmpty()) {
windowInsetsControllerCompat.show(WindowInsetsCompat.Type.systemBars());
- navBarVisible = true;
} else if (bar.equals(BAR_STATUS_BAR)) {
windowInsetsControllerCompat.show(WindowInsetsCompat.Type.statusBars());
} else if (bar.equals(BAR_GESTURE_BAR)) {
diff --git a/android/capacitor/src/test/java/com/getcapacitor/plugin/SystemBarsTest.java b/android/capacitor/src/test/java/com/getcapacitor/plugin/SystemBarsTest.java
index 0ea955ae88..f72373a88e 100644
--- a/android/capacitor/src/test/java/com/getcapacitor/plugin/SystemBarsTest.java
+++ b/android/capacitor/src/test/java/com/getcapacitor/plugin/SystemBarsTest.java
@@ -1,7 +1,5 @@
package com.getcapacitor.plugin;
-import static org.junit.Assert.assertFalse;
-import static org.junit.Assert.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.never;
@@ -15,7 +13,6 @@
import androidx.core.view.WindowInsetsCompat;
import androidx.core.view.WindowInsetsControllerCompat;
import com.getcapacitor.Bridge;
-import java.lang.reflect.Field;
import java.lang.reflect.Method;
import org.junit.Test;
import org.mockito.MockedStatic;
@@ -49,66 +46,8 @@ public void showWithNavigationBarShowsOnlyNavigationBars() throws Exception {
verify(controller, never()).show(WindowInsetsCompat.Type.statusBars());
}
- @Test
- public void hideWithEmptyBarHidesSystemBars() throws Exception {
- WindowInsetsControllerCompat controller = invokeSetHidden(new SystemBars(), true, "");
-
- verify(controller).hide(WindowInsetsCompat.Type.systemBars());
- verify(controller, never()).hide(WindowInsetsCompat.Type.statusBars());
- verify(controller, never()).hide(WindowInsetsCompat.Type.navigationBars());
- }
-
- @Test
- public void hideWithStatusBarHidesOnlyStatusBars() throws Exception {
- WindowInsetsControllerCompat controller = invokeSetHidden(new SystemBars(), true, "StatusBar");
-
- verify(controller).hide(WindowInsetsCompat.Type.statusBars());
- verify(controller, never()).hide(WindowInsetsCompat.Type.systemBars());
- verify(controller, never()).hide(WindowInsetsCompat.Type.navigationBars());
- }
-
- @Test
- public void togglingNavigationBarTracksNavBarVisible() throws Exception {
- SystemBars plugin = new SystemBars();
-
- invokeSetHidden(plugin, true, "NavigationBar");
- assertFalse(navBarVisible(plugin));
-
- invokeSetHidden(plugin, false, "NavigationBar");
- assertTrue(navBarVisible(plugin));
- }
-
- @Test
- public void togglingAllBarsTracksNavBarVisible() throws Exception {
- SystemBars plugin = new SystemBars();
-
- invokeSetHidden(plugin, true, "");
- assertFalse(navBarVisible(plugin));
-
- invokeSetHidden(plugin, false, "");
- assertTrue(navBarVisible(plugin));
- }
-
- @Test
- public void hidingOnlyStatusBarLeavesNavBarVisible() throws Exception {
- SystemBars plugin = new SystemBars();
-
- invokeSetHidden(plugin, true, "StatusBar");
-
- assertTrue(navBarVisible(plugin));
- }
-
- private boolean navBarVisible(SystemBars plugin) throws Exception {
- Field field = SystemBars.class.getDeclaredField("navBarVisible");
- field.setAccessible(true);
- return field.getBoolean(plugin);
- }
-
private WindowInsetsControllerCompat invokeSetHidden(String bar) throws Exception {
- return invokeSetHidden(new SystemBars(), false, bar);
- }
-
- private WindowInsetsControllerCompat invokeSetHidden(SystemBars plugin, boolean hide, String bar) throws Exception {
+ SystemBars plugin = new SystemBars();
Bridge bridge = mock(Bridge.class);
AppCompatActivity activity = mock(AppCompatActivity.class);
Window window = mock(Window.class);
@@ -126,7 +65,7 @@ private WindowInsetsControllerCompat invokeSetHidden(SystemBars plugin, boolean
Method setHidden = SystemBars.class.getDeclaredMethod("setHidden", boolean.class, String.class);
setHidden.setAccessible(true);
- setHidden.invoke(plugin, hide, bar);
+ setHidden.invoke(plugin, false, bar);
}
return controller;
diff --git a/android/capacitor/src/test/java/com/getcapacitor/plugin/util/HttpRequestHandlerTest.java b/android/capacitor/src/test/java/com/getcapacitor/plugin/util/HttpRequestHandlerTest.java
index fd8b686ee6..1006721235 100644
--- a/android/capacitor/src/test/java/com/getcapacitor/plugin/util/HttpRequestHandlerTest.java
+++ b/android/capacitor/src/test/java/com/getcapacitor/plugin/util/HttpRequestHandlerTest.java
@@ -11,8 +11,8 @@ public class HttpRequestHandlerTest {
static final String BASE_URL = "https://httpbin.org/get";
static final String PARAMS_JSON = """
- {"k": "a&b"}
- """;
+ {"k": "a&b"}
+ """;
@Test
public void testHttpURLConnectionBuilderSetUrlParamsEncoded() throws Exception {
diff --git a/android/package.json b/android/package.json
index f42e65913d..31ca28b6fb 100644
--- a/android/package.json
+++ b/android/package.json
@@ -1,9 +1,9 @@
{
- "name": "@capacitor-plus/android",
+ "name": "@capacitor/android",
"version": "8.5.2",
- "description": "Capacitor+: Enhanced Capacitor with automated upstream sync - Cross-platform apps with JavaScript and the web",
- "homepage": "https://capgo.app/docs/plugins/capacitor-plus/",
- "author": "Capgo Team (https://capgo.app)",
+ "description": "Capacitor: Cross-platform apps with JavaScript and the web",
+ "homepage": "https://capacitorjs.com",
+ "author": "Ionic Team (https://ionic.io)",
"license": "MIT",
"repository": {
"type": "git",
@@ -23,7 +23,6 @@
"verify": "./gradlew clean lint build test -b capacitor/build.gradle"
},
"peerDependencies": {
- "@capacitor-plus/core": "^8.5.0",
"@capacitor/core": "^8.5.0"
},
"publishConfig": {
diff --git a/cli/CHANGELOG.md b/cli/CHANGELOG.md
index 64ffed5668..6c2a49faea 100644
--- a/cli/CHANGELOG.md
+++ b/cli/CHANGELOG.md
@@ -3,29 +3,25 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
-## [8.5.2](https://github.com/Cap-go/capacitor-plus/compare/8.5.1...8.5.2) (2026-08-26)
+## [8.5.2](https://github.com/ionic-team/capacitor/compare/8.5.1...8.5.2) (2026-09-11)
-**Note:** Version bump only for package @capacitor-plus/cli
-
-
-
-
-
-## [8.5.1](https://github.com/Cap-go/capacitor-plus/compare/8.3.12...8.5.1) (2026-08-25)
-
-**Note:** Version bump only for package @capacitor-plus/cli
+### Bug Fixes
+- resolve issues with safe area / systembars plugin ([#8535](https://github.com/ionic-team/capacitor/issues/8535)) ([e37d9c6](https://github.com/ionic-team/capacitor/commit/e37d9c60785368acf2a83cfd4c20f47076672bdd))
+## [8.5.1](https://github.com/ionic-team/capacitor/compare/8.5.0...8.5.1) (2026-08-31)
+### Bug Fixes
+- **cli:** use POSIX paths in CapApp-SPM Package.swift ([#8549](https://github.com/ionic-team/capacitor/issues/8549)) ([5e5bb3b](https://github.com/ionic-team/capacitor/commit/5e5bb3befc312477900252ab07e23b596f8cb0d1))
# [8.5.0](https://github.com/ionic-team/capacitor/compare/8.4.2...8.5.0) (2026-07-31)
-## Bug Fixes
+### Bug Fixes
- **cli:** support TypeScript 7 when loading capacitor.config.ts ([#8534](https://github.com/ionic-team/capacitor/issues/8534)) ([4c1c870](https://github.com/ionic-team/capacitor/commit/4c1c8709413b9c19b008c99122ca330cc3c90e6f))
-## Features
+### Features
- **cli:** add migrator functionality for adopting UIScene ([#8544](https://github.com/ionic-team/capacitor/issues/8544)) ([984fa85](https://github.com/ionic-team/capacitor/commit/984fa85ba0adab0aacf895aed6323bf4b503dccb))
@@ -42,11 +38,11 @@ See [Conventional Commits](https://conventionalcommits.org) for commit guideline
# [8.4.0](https://github.com/ionic-team/capacitor/compare/8.3.4...8.4.0) (2026-06-02)
-## Bug Fixes
+### Bug Fixes
- **cli:** revert live reload config on failure ([#8485](https://github.com/ionic-team/capacitor/issues/8485)) ([1d031a4](https://github.com/ionic-team/capacitor/commit/1d031a4abec2c793079ba8897ad2e40c4cc6c7f9))
-## Features
+### Features
- **cli:** add experimental packageOptions ([#8471](https://github.com/ionic-team/capacitor/issues/8471)) ([258867b](https://github.com/ionic-team/capacitor/commit/258867b7bf37b1837b99b02ec9638e5a6df08d97))
- **cli:** capture ios_package_manager in telemetry ([#8482](https://github.com/ionic-team/capacitor/issues/8482)) ([b4b297a](https://github.com/ionic-team/capacitor/commit/b4b297a52f8732659662d5e5aaeff81c0f7d9835))
diff --git a/cli/package.json b/cli/package.json
index f7ee82153a..38a2618d67 100644
--- a/cli/package.json
+++ b/cli/package.json
@@ -1,9 +1,9 @@
{
- "name": "@capacitor-plus/cli",
+ "name": "@capacitor/cli",
"version": "8.5.2",
- "description": "Capacitor+: Enhanced Capacitor with automated upstream sync - Cross-platform apps with JavaScript and the web",
- "homepage": "https://capgo.app/docs/plugins/capacitor-plus/",
- "author": "Capgo Team (https://capgo.app)",
+ "description": "Capacitor: Cross-platform apps with JavaScript and the web",
+ "homepage": "https://capacitorjs.com",
+ "author": "Ionic Team (https://ionic.io)",
"license": "MIT",
"repository": {
"type": "git",
@@ -60,7 +60,6 @@
"semver": "^7.6.3",
"tar": "^7.5.3",
"tslib": "^2.8.1",
- "typescript": "~5.0.2",
"xcode": "^3.0.1",
"xml2js": "^0.6.2"
},
diff --git a/cli/src/declarations.ts b/cli/src/declarations.ts
index 1103b65850..2cee84996b 100644
--- a/cli/src/declarations.ts
+++ b/cli/src/declarations.ts
@@ -772,13 +772,33 @@ export interface PluginsConfig {
*
* This option is only supported on Android.
*
- * `css` = Injects CSS variables (`--safe-area-inset-*`) containing correct safe area inset values into the webview.
+ * `native` = (recommended) For older Chromium versions (< v140) this embeds the webview with padding and sets the `env(safe-area-inset-*)` variables to `0px`. For newer Chromium versions (>= v140) this makes sure the webview adheres to the `viewport-fit` meta tag. If set to `viewport-fit="cover"` this will make the webview edge-to-edge and the `env(safe-area-inset-*)` variables will contain the correct values. With those values you could set padding for example so make sure the webview is shown correctly.
*
- * `disable` = Disable CSS variables injection.
+ * `css` = This is the same as `native`, but it also injects CSS variables (`--safe-area-inset-*`) containing correct safe area inset values into the webview.
+ *
+ * `disable` = (not recommended) Disable safe area insets handling completely.
+ * This shifts the responsibility from Capacitor to your own code to handle the insets.
+ * Be aware that this might result in a visually broken UI if your native app code and the content loaded into the webview do not correctly handle safe area insets.
*
* @default "css"
*/
- insetsHandling?: 'css' | 'disable';
+ insetsHandling?: 'native' | 'css' | 'disable';
+
+ /**
+ * Set an initial value for the to be detected `viewport-fit=` meta tag value.
+ * For most apps that support edge-to-edge this value will eventually be `cover`.
+ * Therefore you might want to set this value to `cover` to help prevent layout jumps and glitches.
+ * If you know the value to be `cover` initially, you can set it here.
+ * The value will always end up correctly, no matter what you set here,
+ * as long as `insetsHandling` is set to `native` or `css`.
+ * It only exists to help prevent layout jumps and glitches.
+ *
+ * This option is only supported on Android.
+ *
+ * @default undefined
+ */
+ initialViewportFitValueHint?: 'auto' | 'contain' | 'cover';
+
/**
* The style of the text and icons of the system bars.
*
diff --git a/cli/src/ios/update.ts b/cli/src/ios/update.ts
index 8d28888330..8790c9ca35 100644
--- a/cli/src/ios/update.ts
+++ b/cli/src/ios/update.ts
@@ -1,6 +1,6 @@
import { copy, remove, pathExists, readFile, realpath, writeFile } from 'fs-extra';
import { basename, dirname, join, relative } from 'path';
-import { major, prerelease, valid } from 'semver';
+import { major, prerelease } from 'semver';
import c from '../colors';
import { checkPlatformVersions, getCapacitorPackageVersion, runTask } from '../common';
@@ -59,6 +59,30 @@ async function updatePluginFiles(config: Config, plugins: Plugin[], deployment:
await generateCordovaPackageFiles(cordovaPlugins, config);
const validSPMPackages = await checkPluginsForPackageSwift(config, plugins);
+ await Promise.all(
+ validSPMPackages.map(async (plugin) => {
+ const iosPlatformVersion = await getCapacitorPackageVersion(config, config.ios.name);
+ const packageSwiftPath = join(plugin.rootPath, 'Package.swift');
+ let content = await readFile(packageSwiftPath, { encoding: 'utf-8' });
+ const regex = new RegExp(
+ 'url:\\s*"https://github.com/ionic-team/capacitor-swift-pm\\.git",\\s*from:\\s*"([^"]+)"',
+ );
+ const version = content.match(regex)?.[1];
+ const majorCapVersion = major(iosPlatformVersion);
+ if (version && major(version) != majorCapVersion) {
+ const preCapVersion = prerelease(iosPlatformVersion);
+ const forceVersion = preCapVersion ? iosPlatformVersion : `${majorCapVersion}.0.0`;
+ content = setAllStringIn(
+ content,
+ `url: "https://github.com/ionic-team/capacitor-swift-pm.git",`,
+ `)`,
+ ` from: "${forceVersion}"`,
+ );
+ await writeFile(packageSwiftPath, content);
+ logger.warn(`${plugin.id} is built for Capacitor ${major(version)}, it might cause issues`);
+ }
+ }),
+ );
await generatePackageFile(config, validSPMPackages.concat(cordovaPlugins));
diff --git a/cli/src/tasks/migrate-uiscene.ts b/cli/src/tasks/migrate-uiscene.ts
index 40b0bc4200..9f6dfa2789 100644
--- a/cli/src/tasks/migrate-uiscene.ts
+++ b/cli/src/tasks/migrate-uiscene.ts
@@ -134,125 +134,21 @@ async function scanAndWarn(config: Config): Promise {
}
}
-function findMatchingBrace(source: string, openIdx: number): number | null {
+function hasCustomDelegateBody(source: string, sigRegex: RegExp): boolean {
+ const match = source.match(sigRegex);
+ if (!match || match.index === undefined) return false;
+ const openIdx = source.indexOf('{', match.index);
+ if (openIdx === -1) return false;
let depth = 1;
let i = openIdx + 1;
- let inLineComment = false;
- let blockCommentDepth = 0;
- let inString: '"' | '"""' | null = null;
- let stringHashes = 0;
-
while (i < source.length && depth > 0) {
const ch = source[i];
- const next = source[i + 1];
-
- if (inLineComment) {
- if (ch === '\n') inLineComment = false;
- i++;
- continue;
- }
-
- if (blockCommentDepth > 0) {
- if (ch === '*' && next === '/') {
- blockCommentDepth--;
- i += 2;
- continue;
- }
- if (ch === '/' && next === '*') {
- blockCommentDepth++;
- i += 2;
- continue;
- }
- i++;
- continue;
- }
-
- if (inString === '"') {
- if (stringHashes === 0 && ch === '\\') {
- i += 2;
- continue;
- }
- if (ch === '"') {
- let closingHashes = 0;
- while (source[i + 1 + closingHashes] === '#') {
- closingHashes++;
- }
- if (closingHashes === stringHashes) {
- i += 1 + closingHashes;
- inString = null;
- stringHashes = 0;
- continue;
- }
- }
- i++;
- continue;
- }
-
- if (inString === '"""') {
- if (ch === '"' && source[i + 1] === '"' && source[i + 2] === '"') {
- let closingHashes = 0;
- while (source[i + 3 + closingHashes] === '#') {
- closingHashes++;
- }
- if (closingHashes === stringHashes) {
- i += 3 + closingHashes;
- inString = null;
- stringHashes = 0;
- continue;
- }
- }
- i++;
- continue;
- }
-
- if (ch === '/' && next === '/') {
- inLineComment = true;
- i += 2;
- continue;
- }
-
- if (ch === '/' && next === '*') {
- blockCommentDepth++;
- i += 2;
- continue;
- }
-
- if (ch === '#' || ch === '"') {
- let hashes = 0;
- while (source[i + hashes] === '#') {
- hashes++;
- }
- const quoteIdx = i + hashes;
- if (source[quoteIdx] === '"') {
- if (source[quoteIdx + 1] === '"' && source[quoteIdx + 2] === '"') {
- inString = '"""';
- stringHashes = hashes;
- i = quoteIdx + 3;
- continue;
- }
- inString = '"';
- stringHashes = hashes;
- i = quoteIdx + 1;
- continue;
- }
- }
-
if (ch === '{') depth++;
else if (ch === '}') depth--;
i++;
}
-
- return depth === 0 ? i - 1 : null;
-}
-
-function hasCustomDelegateBody(source: string, sigRegex: RegExp): boolean {
- const match = source.match(sigRegex);
- if (!match || match.index === undefined) return false;
- const openIdx = source.indexOf('{', match.index);
- if (openIdx === -1) return false;
- const closeIdx = findMatchingBrace(source, openIdx);
- if (closeIdx === null) return false;
- const body = source.slice(openIdx + 1, closeIdx);
+ if (depth !== 0) return false;
+ const body = source.slice(openIdx + 1, i - 1);
const codeLines = body
.split('\n')
.map((l) => l.trim())
@@ -329,11 +225,18 @@ function extractConfigurationForConnecting(appDelegateSource: string): string |
if (openIdx === -1) {
return null;
}
- const closeIdx = findMatchingBrace(appDelegateSource, openIdx);
- if (closeIdx === null) {
+ let depth = 1;
+ let i = openIdx + 1;
+ while (i < appDelegateSource.length && depth > 0) {
+ const ch = appDelegateSource[i];
+ if (ch === '{') depth++;
+ else if (ch === '}') depth--;
+ i++;
+ }
+ if (depth !== 0) {
return null;
}
- return '\n' + appDelegateSource.slice(sigMatch.index, closeIdx + 1) + '\n';
+ return '\n' + appDelegateSource.slice(sigMatch.index, i) + '\n';
}
function insertBeforeAppDelegateClassEnd(source: string, snippet: string): string | null {
@@ -343,10 +246,18 @@ function insertBeforeAppDelegateClassEnd(source: string, snippet: string): strin
return null;
}
const openIdx = source.indexOf('{', match.index);
- const closeIdx = findMatchingBrace(source, openIdx);
- if (closeIdx === null) {
+ let depth = 1;
+ let i = openIdx + 1;
+ while (i < source.length && depth > 0) {
+ const ch = source[i];
+ if (ch === '{') depth++;
+ else if (ch === '}') depth--;
+ i++;
+ }
+ if (depth !== 0) {
return null;
}
+ const closeIdx = i - 1;
return source.slice(0, closeIdx) + snippet + source.slice(closeIdx);
}
@@ -387,7 +298,6 @@ function describeSignals({
export const __testables = {
classify,
describeSignals,
- findMatchingBrace,
insertBeforeAppDelegateClassEnd,
extractConfigurationForConnecting,
hasCustomDelegateBody,
diff --git a/cli/src/tasks/migrate.ts b/cli/src/tasks/migrate.ts
index ca6f1e7f0c..8631897294 100644
--- a/cli/src/tasks/migrate.ts
+++ b/cli/src/tasks/migrate.ts
@@ -446,7 +446,7 @@ async function writeBreakingChanges() {
)}.`,
);
}
- if (allDependencies['@capacitor/ios'] || allDependencies['@capacitor-plus/ios']) {
+ if (allDependencies['@capacitor/ios']) {
logger.info(
'IMPORTANT: Capacitor 8.5 adopts UIScene on iOS. ' +
'See https://capacitorjs.com/docs/updating/8-5 for the full 8.4 → 8.5 migration guide.',
diff --git a/cli/src/tasks/run.ts b/cli/src/tasks/run.ts
index 780c8130e0..2fef2989f7 100644
--- a/cli/src/tasks/run.ts
+++ b/cli/src/tasks/run.ts
@@ -121,9 +121,6 @@ export async function runCommand(
} catch (e: any) {
if (options.liveReload) {
await CapLiveReloadHelper.revertCapConfigForLiveReload();
- if (liveReloadManifestUpdated && platformName === config.android.name && cordovaPlugins) {
- await writeCordovaAndroidManifest(cordovaPlugins, config, platformName, false);
- }
}
if (!isFatal(e)) {
fatal(e.stack ?? e);
diff --git a/cli/src/util/node.ts b/cli/src/util/node.ts
index 5600b5cad5..2967816113 100644
--- a/cli/src/util/node.ts
+++ b/cli/src/util/node.ts
@@ -30,7 +30,27 @@ const dynamicImport: (specifier: string) => Promise = new Function('specifi
/**
* @see https://github.com/ionic-team/stencil/blob/HEAD/src/compiler/sys/node-require.ts
*/
-function loadWithClassicCompiler(ts: typeof typescript, id: string): unknown {
+export const requireTS = async (ts: typeof typescript, p: string): Promise => {
+ const id = resolve(p);
+
+ if (!hasClassicCompilerAPI(ts)) {
+ // Node has its own built-in TypeScript syntax stripping (stable since Node 23.6, and
+ // available behind --experimental-strip-types since Node 22.6), so we can load the file
+ // directly via the native ESM loader instead of transpiling it ourselves.
+ try {
+ return await dynamicImport(pathToFileURL(id).href);
+ } catch (e: any) {
+ if (e?.code === 'ERR_UNKNOWN_FILE_EXTENSION') {
+ throw new Error(
+ `Your installed version of TypeScript (${ts.version}) no longer provides the compiler API Capacitor previously used to load .ts config files, ` +
+ `and your Node.js runtime (${process.version}) doesn't support loading them natively either.\n` +
+ 'Upgrade to Node.js 22.6+ (running with --experimental-strip-types), or Node.js 23.6+, to continue using capacitor.config.ts.',
+ );
+ }
+ throw e;
+ }
+ }
+
delete require.cache[id];
require.extensions['.ts'] = (module: NodeModuleWithCompile, fileName: string) => {
diff --git a/cli/src/util/spm.ts b/cli/src/util/spm.ts
index 8c0ee028cd..9f623b7e0f 100644
--- a/cli/src/util/spm.ts
+++ b/cli/src/util/spm.ts
@@ -137,9 +137,9 @@ let package = Package(
const options = packageOptions[plugin.id];
const symlink = options?.symlink;
const symlinkFolder = join('symlinks', plugin.name);
- const relPath = convertToUnixPath(
- symlink ? symlinkFolder : relative(config.ios.nativeXcodeProjDirAbs, plugin.rootPath),
- );
+ const relPath = symlink
+ ? symlinkFolder
+ : convertToUnixPath(relative(config.ios.nativeXcodeProjDirAbs, plugin.rootPath));
if (symlink) {
await ensureSymlink(plugin.rootPath, resolve(config.ios.nativeProjectDirAbs, 'CapApp-SPM', symlinkFolder));
}
@@ -275,6 +275,7 @@ export async function addSceneManifestIfNeeded(config: Config): Promise {
{
UISceneConfigurationName: 'Default Configuration',
UISceneDelegateClassName: '$(PRODUCT_MODULE_NAME).SceneDelegate',
+ UISceneStoryboardFile: 'Main',
},
],
},
diff --git a/cli/src/util/xcode.ts b/cli/src/util/xcode.ts
index 86a9707e9e..64ab836a34 100644
--- a/cli/src/util/xcode.ts
+++ b/cli/src/util/xcode.ts
@@ -1,5 +1,4 @@
import { writeFileSync } from 'fs-extra';
-import { basename } from 'path';
import { project as loadXcodeProject } from 'xcode';
import type { XcodeProject } from 'xcode';
@@ -21,8 +20,7 @@ export function addSwiftFileToAppTarget(
const project = loadXcodeProject(pbxprojPath);
project.parseSync();
- const targetUuid = project.getFirstTarget().uuid;
- if (project.hasFile(fileRelPath) && isSwiftFileInTargetSources(project, fileRelPath, targetUuid)) {
+ if (project.hasFile(fileRelPath)) {
return { added: false };
}
@@ -31,31 +29,7 @@ export function addSwiftFileToAppTarget(
throw new Error(`Could not find PBXGroup with comment "${groupName}" in ${pbxprojPath}`);
}
- if (project.hasFile(fileRelPath) && !isSwiftFileInTargetSources(project, fileRelPath, targetUuid)) {
- const fileRefUuid = findFileRefUuid(project, fileRelPath);
- if (!fileRefUuid) {
- throw new Error(`Could not find PBXFileReference for ${fileRelPath} in ${pbxprojPath}`);
- }
-
- const group = project.getPBXGroupByKey(groupUuid);
- if (group && !group.children.some((child: { comment?: string }) => child.comment === fileRelPath)) {
- group.children.push({ value: fileRefUuid, comment: fileRelPath });
- }
-
- const file = {
- fileRef: fileRefUuid,
- path: fileRelPath,
- basename: basename(fileRelPath),
- group: 'Sources',
- target: targetUuid,
- uuid: project.generateUuid(),
- };
- (project as XcodeProject & { addToPbxBuildFileSection: (file: unknown) => void }).addToPbxBuildFileSection(file);
- (project as XcodeProject & { addToPbxSourcesBuildPhase: (file: unknown) => void }).addToPbxSourcesBuildPhase(file);
- writeFileSync(pbxprojPath, project.writeSync(), 'utf-8');
- return { added: true };
- }
-
+ const targetUuid = project.getFirstTarget().uuid;
const result = project.addSourceFile(fileRelPath, { target: targetUuid }, groupUuid);
if (!result) {
throw new Error(`Failed to register ${fileRelPath} in ${pbxprojPath}`);
@@ -65,38 +39,6 @@ export function addSwiftFileToAppTarget(
return { added: true };
}
-function fileReferenceMatchesPath(ref: { path?: string }, fileRelPath: string): boolean {
- const path = ref.path;
- return path === fileRelPath || path === `"${fileRelPath}"`;
-}
-
-function findFileRefUuid(project: XcodeProject, fileRelPath: string): string | null {
- const objects = project.hash.project.objects;
- const fileRefs = Object.entries(objects.PBXFileReference).filter(([k]) => !k.endsWith('_comment'));
- const fileRefEntry = fileRefs.find(
- ([, ref]) => typeof ref === 'object' && fileReferenceMatchesPath(ref as { path?: string }, fileRelPath),
- );
- return fileRefEntry?.[0] ?? null;
-}
-
-function isSwiftFileInTargetSources(project: XcodeProject, fileRelPath: string, targetUuid: string): boolean {
- const fileRefUuid = findFileRefUuid(project, fileRelPath);
- if (!fileRefUuid) {
- return false;
- }
-
- const objects = project.hash.project.objects;
- const sourcesPhase = project.pbxSourcesBuildPhaseObj(targetUuid);
- if (!sourcesPhase?.files) {
- return false;
- }
-
- return sourcesPhase.files.some((file: { value?: string }) => {
- const buildFile = objects.PBXBuildFile[file.value ?? ''];
- return typeof buildFile === 'object' && buildFile?.fileRef === fileRefUuid;
- });
-}
-
// Exported for tests.
export function findGroupUuidByComment(project: XcodeProject, comment: string): string | null {
const groups = project.hash.project.objects.PBXGroup;
diff --git a/cli/test/migrate-uiscene-plist.spec.ts b/cli/test/migrate-uiscene-plist.spec.ts
index f07dded8c0..dc3bd25135 100644
--- a/cli/test/migrate-uiscene-plist.spec.ts
+++ b/cli/test/migrate-uiscene-plist.spec.ts
@@ -81,7 +81,7 @@ describe('addSceneManifestIfNeeded', () => {
expect(roleArray).toHaveLength(1);
expect(roleArray[0]['UISceneConfigurationName']).toBe('Default Configuration');
expect(roleArray[0]['UISceneDelegateClassName']).toBe('$(PRODUCT_MODULE_NAME).SceneDelegate');
- expect(roleArray[0]['UISceneStoryboardFile']).toBeUndefined();
+ expect(roleArray[0]['UISceneStoryboardFile']).toBe('Main');
});
it('preserves sibling keys', async () => {
diff --git a/cli/test/migrate-uiscene-scan.spec.ts b/cli/test/migrate-uiscene-scan.spec.ts
index 7fa3f84e34..c0d7964ddf 100644
--- a/cli/test/migrate-uiscene-scan.spec.ts
+++ b/cli/test/migrate-uiscene-scan.spec.ts
@@ -7,7 +7,7 @@ import { __testables } from '../src/tasks/migrate-uiscene';
import { mktmp } from './util';
-const { hasCustomDelegateBody, scanAndWarn, findMatchingBrace } = __testables;
+const { hasCustomDelegateBody, scanAndWarn } = __testables;
const OPEN_URL_SIG = /func application\([^)]*\bopen url:/;
const CONTINUE_SIG = /func application\([^)]*\bcontinue userActivity:/;
@@ -82,26 +82,6 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
});
});
-describe('findMatchingBrace', () => {
- it('ignores braces inside ordinary string literals', () => {
- const source = 'class AppDelegate {\n let payload = "{\\"a\\":1}"\n}';
- const openIdx = source.indexOf('{');
- expect(findMatchingBrace(source, openIdx)).toBe(source.length - 1);
- });
-
- it('ignores braces inside raw string literals', () => {
- const source = 'class AppDelegate {\n let payload = #"}"#\n}';
- const openIdx = source.indexOf('{');
- expect(findMatchingBrace(source, openIdx)).toBe(source.length - 1);
- });
-
- it('ignores braces inside multiline string literals', () => {
- const source = 'class AppDelegate {\n let payload = """\n }\n """\n}';
- const openIdx = source.indexOf('{');
- expect(findMatchingBrace(source, openIdx)).toBe(source.length - 1);
- });
-});
-
describe('scanAndWarn', () => {
let tmpDir: any;
let iosDir: string;
@@ -172,19 +152,13 @@ describe('scanAndWarn', () => {
expect(warnSpy).not.toHaveBeenCalled();
});
- it('skips Pods/, build/, DerivedData/, and .build/ directories', async () => {
+ it('skips Pods/, build/, and DerivedData/ directories', async () => {
const podsDir = join(iosDir, 'App', 'Pods');
const buildDir = join(iosDir, 'App', 'build');
- const derivedDataDir = join(iosDir, 'App', 'DerivedData');
- const dotBuildDir = join(iosDir, 'App', '.build');
await mkdirp(podsDir);
await mkdirp(buildDir);
- await mkdirp(derivedDataDir);
- await mkdirp(dotBuildDir);
writeFileSync(join(podsDir, 'ThirdParty.swift'), `let x = UIApplication.shared.applicationState\n`);
writeFileSync(join(buildDir, 'Generated.swift'), `class TmpViewController {}\n`);
- writeFileSync(join(derivedDataDir, 'BuildOutput.swift'), `class TmpViewController {}\n`);
- writeFileSync(join(dotBuildDir, 'Artifact.swift'), `class TmpViewController {}\n`);
await scanAndWarn(makeConfig());
diff --git a/cli/test/xcode.spec.ts b/cli/test/xcode.spec.ts
index 6071945ab4..e6aa083570 100644
--- a/cli/test/xcode.spec.ts
+++ b/cli/test/xcode.spec.ts
@@ -23,11 +23,8 @@ describe('findGroupUuidByComment', () => {
const uuid = findGroupUuidByComment(project, 'App');
- if (!uuid) {
- throw new Error('Expected App group UUID');
- }
expect(uuid).toMatch(/^[A-F0-9]{24}$/);
- const group = project.getPBXGroupByKey(uuid);
+ const group = project.getPBXGroupByKey(uuid!);
expect(group).toBeDefined();
expect(group?.path).toBe('App');
});
@@ -41,7 +38,7 @@ describe('findGroupUuidByComment', () => {
});
describe('addSwiftFileToAppTarget', () => {
- let tmpDir: Awaited> | undefined;
+ let tmpDir: any;
let pbxprojPath: string;
beforeEach(async () => {
@@ -50,8 +47,7 @@ describe('addSwiftFileToAppTarget', () => {
});
afterEach(() => {
- const cleanup = tmpDir?.cleanupCallback as unknown as (() => void) | undefined;
- cleanup?.();
+ tmpDir.cleanupCallback();
});
it('registers a new Swift file in all four pbxproj sections', () => {
@@ -78,14 +74,11 @@ describe('addSwiftFileToAppTarget', () => {
buildFiles.some(([k]) => (objects.PBXBuildFile as any)[`${k}_comment`]?.includes('SceneDelegate.swift')),
).toBe(true);
- const appGroupUuid = findGroupUuidByComment(project, 'App');
- if (!appGroupUuid) {
- throw new Error('Expected App group UUID');
- }
- const appGroup = project.getPBXGroupByKey(appGroupUuid);
- expect(appGroup?.children.some((c: any) => c.comment === 'SceneDelegate.swift')).toBe(true);
+ const appGroupUuid = findGroupUuidByComment(project, 'App')!;
+ const appGroup = project.getPBXGroupByKey(appGroupUuid)!;
+ expect(appGroup.children.some((c: any) => c.comment === 'SceneDelegate.swift')).toBe(true);
- const sourcesPhase = objects.PBXSourcesBuildPhase ?? {};
+ const sourcesPhase = objects.PBXSourcesBuildPhase!;
const sourcesEntries = Object.entries(sourcesPhase).filter(([k]) => !k.endsWith('_comment'));
const [, sourcesObj] = sourcesEntries[0];
expect((sourcesObj as any).files.some((f: any) => f.comment?.includes('SceneDelegate.swift'))).toBe(true);
diff --git a/core/CHANGELOG.md b/core/CHANGELOG.md
index 067d0aa2ee..a3acb20411 100644
--- a/core/CHANGELOG.md
+++ b/core/CHANGELOG.md
@@ -3,21 +3,17 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
-## [8.5.2](https://github.com/Cap-go/capacitor-plus/compare/8.5.1...8.5.2) (2026-08-26)
+## [8.5.2](https://github.com/ionic-team/capacitor/compare/8.5.1...8.5.2) (2026-09-11)
-**Note:** Version bump only for package @capacitor-plus/core
-
-
-
-
-
-## [8.5.1](https://github.com/Cap-go/capacitor-plus/compare/8.3.12...8.5.1) (2026-08-25)
-
-**Note:** Version bump only for package @capacitor-plus/core
+### Bug Fixes
+- resolve issues with safe area / systembars plugin ([#8535](https://github.com/ionic-team/capacitor/issues/8535)) ([e37d9c6](https://github.com/ionic-team/capacitor/commit/e37d9c60785368acf2a83cfd4c20f47076672bdd))
+## [8.5.1](https://github.com/ionic-team/capacitor/compare/8.5.0...8.5.1) (2026-08-31)
+### Bug Fixes
+- **core:** prevent removeListener from removing wrong listener ([#8271](https://github.com/ionic-team/capacitor/issues/8271)) ([5ac4dd6](https://github.com/ionic-team/capacitor/commit/5ac4dd613ae989d8dc8738ea25b77efbd4fa21fe))
# [8.5.0](https://github.com/ionic-team/capacitor/compare/8.4.2...8.5.0) (2026-07-31)
diff --git a/core/native-bridge.ts b/core/native-bridge.ts
index 5b9e9443c8..9299bd7057 100644
--- a/core/native-bridge.ts
+++ b/core/native-bridge.ts
@@ -438,13 +438,6 @@ const initBridge = (w: any): void => {
const platform = getPlatformId(win);
- if (platform == 'android' && typeof win.CapacitorSystemBarsAndroidInterface !== 'undefined') {
- // add DOM ready listener for System Bars
- document.addEventListener('DOMContentLoaded', function () {
- win.CapacitorSystemBarsAndroidInterface.onDOMReady();
- });
- }
-
if (platform == 'android' || platform == 'ios') {
// patch document.cookie on Android/iOS
win.CapacitorCookiesDescriptor =
diff --git a/core/package.json b/core/package.json
index 48d3934ba6..36c8e65f36 100644
--- a/core/package.json
+++ b/core/package.json
@@ -1,9 +1,9 @@
{
- "name": "@capacitor-plus/core",
+ "name": "@capacitor/core",
"version": "8.5.2",
- "description": "Capacitor+: Enhanced Capacitor with automated upstream sync - Cross-platform apps with JavaScript and the web",
- "homepage": "https://capgo.app/docs/plugins/capacitor-plus/",
- "author": "Capgo Team (https://capgo.app)",
+ "description": "Capacitor: Cross-platform apps with JavaScript and the web",
+ "homepage": "https://capacitorjs.com",
+ "author": "Ionic Team (https://ionic.io)",
"license": "MIT",
"repository": {
"type": "git",
diff --git a/core/system-bars.md b/core/system-bars.md
index 67275077af..921cc55f10 100644
--- a/core/system-bars.md
+++ b/core/system-bars.md
@@ -24,17 +24,9 @@ The status bar visibility defaults to visible and the style defaults to
## Android Note
-Due to a [bug](https://issues.chromium.org/issues/40699457) in some older versions of Android WebView (< 140), correct safe area values are not available via the `safe-area-inset-x` CSS `env` variables. This plugin will inject the correct inset values into a new CSS variable(s) named `--safe-area-inset-x` that you can use as a fallback in your frontend styles:
-
-```css
-html {
- padding-top: var(--safe-area-inset-top, env(safe-area-inset-top, 0px));
- padding-bottom: var(--safe-area-inset-bottom, env(safe-area-inset-bottom, 0px));
- padding-left: var(--safe-area-inset-left, env(safe-area-inset-left, 0px));
- padding-right: var(--safe-area-inset-right, env(safe-area-inset-right, 0px));
-}
-```
-To control this behavior, use the `insetsHandling` configuration setting.
+Due to a [bug](https://issues.chromium.org/issues/40699457) in some older versions of Android WebView (< 140), correct safe area values are not available via the `safe-area-inset-x` CSS `env` variables. This plugin has two ways to workaround this. To control this behavior, use the `insetsHandling` configuration setting.
+
+You should also consider calling `EdgeToEdge.enable(this);` inside your application's `BridgeActivity.onCreate` if `insetsHandling` is not set to `disable`. Starting in Capacitor 9, `insetsHandling` will default to `native` and this will be called for you.
## Example
@@ -73,7 +65,8 @@ const setStatusBarAnimation = async () => {
## Configuration
| Prop | Type | Description | Default |
| ------------- | -------------------- | ------------------------------------------------------------------------- | ------------------ |
-| **`insetsHandling`** | string | Specifies how to handle problematic insets on Android. This option is only supported on Android.
`css` = Injects CSS variables (`--safe-area-inset-*`) containing correct safe area inset values into the webview.
`disable` = Disable CSS variables injection. | css |
+| **`insetsHandling`** | string | Specifies how to handle problematic insets on Android.
This option is only supported on Android.
`native` = (recommended) For older Chromium versions (< v140) this embeds the webview with padding and sets the `env(safe-area-inset-*)` variables to `0px`. For newer Chromium versions (>= v140) this makes sure the webview adheres to the `viewport-fit` meta tag. If set to `viewport-fit="cover"` this will make the webview edge-to-edge and the `env(safe-area-inset-*)` variables will contain the correct values. With those values you could set padding for example so make sure the webview is shown correctly.
`css` = This is the same as `native`, but it also injects CSS variables (`--safe-area-inset-*`) containing correct safe area inset values into the webview.
`disable` = (not recommended) Disable safe area insets handling completely.
This shifts the responsibility from Capacitor to your own code to handle the insets.
Be aware that this might result in a visually broken UI if your native app code and the content loaded into the webview do not correctly handle safe area insets.
| css |
+| **`initialViewportFitValueHint`** | string | Set an initial value for the to be detected `viewport-fit=` meta tag value.
For most apps that support edge-to-edge this value will eventually be `cover`.
Therefore you might want to set this value to `cover` to help prevent layout jumps and glitches.
If you know the value to be `cover` initially, you can set it here.
The value will always end up correctly, no matter what you set here,
as long as `insetsHandling` is set to `native` or `css`.
It only exists to help prevent layout jumps and glitches.
This option is only supported on Android. | undefined |
| **`style`** | string | The style of the text and icons of the system bars. | DEFAULT |
| **`hidden`** | boolean | Hide the system bars on start. | false |
| **`animation`** | string | The type of status bar animation used when showing or hiding. This option is only supported on iOS. | FADE |
diff --git a/ios-pods-template/App/App/Info.plist b/ios-pods-template/App/App/Info.plist
index 485f40e314..f8c8a57659 100644
--- a/ios-pods-template/App/App/Info.plist
+++ b/ios-pods-template/App/App/Info.plist
@@ -25,19 +25,21 @@
UIApplicationSceneManifest
UIApplicationSupportsMultipleScenes
-
- UISceneConfigurations
-
- UIWindowSceneSessionRoleApplication
-
-
- UISceneConfigurationName
- Default Configuration
- UISceneDelegateClassName
- $(PRODUCT_MODULE_NAME).SceneDelegate
-
-
-
+
+ UISceneConfigurations
+
+ UIWindowSceneSessionRoleApplication
+
+
+ UISceneConfigurationName
+ Default Configuration
+ UISceneDelegateClassName
+ $(PRODUCT_MODULE_NAME).SceneDelegate
+ UISceneStoryboardFile
+ Main
+
+
+
UILaunchStoryboardName
LaunchScreen
diff --git a/ios-spm-template/App/App/Info.plist b/ios-spm-template/App/App/Info.plist
index bdf2461d53..fc41ce69bc 100644
--- a/ios-spm-template/App/App/Info.plist
+++ b/ios-spm-template/App/App/Info.plist
@@ -27,19 +27,21 @@
UIApplicationSceneManifest
UIApplicationSupportsMultipleScenes
-
- UISceneConfigurations
-
- UIWindowSceneSessionRoleApplication
-
-
- UISceneConfigurationName
- Default Configuration
- UISceneDelegateClassName
- $(PRODUCT_MODULE_NAME).SceneDelegate
-
-
-
+
+ UISceneConfigurations
+
+ UIWindowSceneSessionRoleApplication
+
+
+ UISceneConfigurationName
+ Default Configuration
+ UISceneDelegateClassName
+ $(PRODUCT_MODULE_NAME).SceneDelegate
+ UISceneStoryboardFile
+ Main
+
+
+
UILaunchStoryboardName
LaunchScreen
diff --git a/ios/CHANGELOG.md b/ios/CHANGELOG.md
index 7886796ba7..01ff4173df 100644
--- a/ios/CHANGELOG.md
+++ b/ios/CHANGELOG.md
@@ -3,25 +3,22 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
-## [8.5.2](https://github.com/Cap-go/capacitor-plus/compare/8.5.1...8.5.2) (2026-08-26)
+## [8.5.2](https://github.com/ionic-team/capacitor/compare/8.5.1...8.5.2) (2026-09-11)
-**Note:** Version bump only for package @capacitor-plus/ios
-
-
-
-
-
-## [8.5.1](https://github.com/Cap-go/capacitor-plus/compare/8.3.12...8.5.1) (2026-08-25)
-
-**Note:** Version bump only for package @capacitor-plus/ios
+### Bug Fixes
+- **ios:** do not forward scene lifecycle events to the page before it has loaded ([#8595](https://github.com/ionic-team/capacitor/issues/8595)) ([c567328](https://github.com/ionic-team/capacitor/commit/c567328d0feb90c306ea824edc5a799bdfe26542))
+- resolve issues with safe area / systembars plugin ([#8535](https://github.com/ionic-team/capacitor/issues/8535)) ([e37d9c6](https://github.com/ionic-team/capacitor/commit/e37d9c60785368acf2a83cfd4c20f47076672bdd))
+## [8.5.1](https://github.com/ionic-team/capacitor/compare/8.5.0...8.5.1) (2026-08-31)
+### Bug Fixes
+- block navigation to the internal HTTP proxy path ([ee586ae](https://github.com/ionic-team/capacitor/commit/ee586ae680887ba99d066616f976db149542d922))
# [8.5.0](https://github.com/ionic-team/capacitor/compare/8.4.2...8.5.0) (2026-07-31)
-## Features
+### Features
- **ios:** UIScene Support ([#8536](https://github.com/ionic-team/capacitor/issues/8536)) ([3fa04a3](https://github.com/ionic-team/capacitor/commit/3fa04a357c92af34cd6fccb8124791963804a9dc))
@@ -35,7 +32,7 @@ See [Conventional Commits](https://conventionalcommits.org) for commit guideline
# [8.4.0](https://github.com/ionic-team/capacitor/compare/8.3.4...8.4.0) (2026-06-02)
-## Features
+### Features
- add method getDouble to plugin config ([#7638](https://github.com/ionic-team/capacitor/issues/7638)) ([93c72de](https://github.com/ionic-team/capacitor/commit/93c72de40a2ec4c78b33659250cb08340083088e))
diff --git a/ios/Capacitor/Capacitor.xcodeproj/project.pbxproj b/ios/Capacitor/Capacitor.xcodeproj/project.pbxproj
index 573e90cd9a..1bfc57d944 100644
--- a/ios/Capacitor/Capacitor.xcodeproj/project.pbxproj
+++ b/ios/Capacitor/Capacitor.xcodeproj/project.pbxproj
@@ -89,6 +89,7 @@
A38C3D7B2848BE6F004B3680 /* CapacitorCookieManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A38C3D7A2848BE6F004B3680 /* CapacitorCookieManager.swift */; };
A71289E627F380A500DADDF3 /* Router.swift in Sources */ = {isa = PBXBuildFile; fileRef = A71289E527F380A500DADDF3 /* Router.swift */; };
A71289EB27F380FD00DADDF3 /* RouterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A71289EA27F380FD00DADDF3 /* RouterTests.swift */; };
+ AA01F00D0000000000000002 /* HttpInterceptorNavigationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA01F00D0000000000000001 /* HttpInterceptorNavigationTests.swift */; };
A7187FD22BD1CB7D00093C45 /* CAPPluginMethod.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7187FD12BD1CB7D00093C45 /* CAPPluginMethod.swift */; };
A76739792B98E09700795F7B /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = A76739782B98E09700795F7B /* PrivacyInfo.xcprivacy */; };
A771ADEE2C8B845000AF234D /* DateCodableTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A771ADED2C8B845000AF234D /* DateCodableTests.swift */; };
@@ -248,6 +249,7 @@
A38C3D7A2848BE6F004B3680 /* CapacitorCookieManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CapacitorCookieManager.swift; sourceTree = ""; };
A71289E527F380A500DADDF3 /* Router.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Router.swift; sourceTree = ""; };
A71289EA27F380FD00DADDF3 /* RouterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RouterTests.swift; sourceTree = ""; };
+ AA01F00D0000000000000001 /* HttpInterceptorNavigationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HttpInterceptorNavigationTests.swift; sourceTree = ""; };
A7187FD12BD1CB7D00093C45 /* CAPPluginMethod.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CAPPluginMethod.swift; sourceTree = ""; };
A76739782B98E09700795F7B /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = ""; };
A771ADED2C8B845000AF234D /* DateCodableTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DateCodableTests.swift; sourceTree = ""; };
@@ -350,6 +352,7 @@
62E79C712638B23300414164 /* JSExportTests.swift */,
62959BBD2526510200A3D7F1 /* Info.plist */,
A71289EA27F380FD00DADDF3 /* RouterTests.swift */,
+ AA01F00D0000000000000001 /* HttpInterceptorNavigationTests.swift */,
);
path = CapacitorTests;
sourceTree = "";
@@ -777,6 +780,7 @@
62FABD2325AE60BA007B3814 /* BridgedTypesTests.m in Sources */,
621ECCC3254204B700D3D615 /* BridgedTypesTests.swift in Sources */,
A71289EB27F380FD00DADDF3 /* RouterTests.swift in Sources */,
+ AA01F00D0000000000000002 /* HttpInterceptorNavigationTests.swift in Sources */,
6263686025F6EC0100576C1C /* PluginCallAccessorTests.m in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
diff --git a/ios/Capacitor/Capacitor/CAPSceneDelegateProxy.swift b/ios/Capacitor/Capacitor/CAPSceneDelegateProxy.swift
index 6ef6235e92..782efe4cff 100644
--- a/ios/Capacitor/Capacitor/CAPSceneDelegateProxy.swift
+++ b/ios/Capacitor/Capacitor/CAPSceneDelegateProxy.swift
@@ -7,7 +7,6 @@
//
import Foundation
-import UIKit
@objc(CAPSceneDelegateProxy)
public class SceneDelegateProxy: NSObject, UISceneDelegate {
@@ -22,12 +21,10 @@ public class SceneDelegateProxy: NSObject, UISceneDelegate {
// missed. Deliver them on the first capacitorViewDidAppear, once plugins are
// registered.
var token: NSObjectProtocol?
- token = NotificationCenter.default.addObserver(forName: .capacitorViewDidAppear, object: nil, queue: .main) { [weak self] _ in
- guard let self, Self.isBridgeReady(for: scene) else { return }
+ token = NotificationCenter.default.addObserver(forName: .capacitorViewDidAppear, object: nil, queue: .main) { _ in
if let token {
NotificationCenter.default.removeObserver(token)
}
- token = nil
if !connectionOptions.urlContexts.isEmpty {
self.scene(scene, openURLContexts: connectionOptions.urlContexts)
}
@@ -37,41 +34,6 @@ public class SceneDelegateProxy: NSObject, UISceneDelegate {
}
}
- private static func isBridgeReady(for scene: UIScene) -> Bool {
- guard let windowScene = scene as? UIWindowScene else {
- return false
- }
-
- for window in windowScene.windows {
- guard let bridge = findBridge(in: window.rootViewController) else {
- continue
- }
- if bridge.isViewLoaded && bridge.view.window != nil {
- return true
- }
- }
-
- return false
- }
-
- private static func findBridge(in viewController: UIViewController?) -> CAPBridgeViewController? {
- if let bridge = viewController as? CAPBridgeViewController {
- return bridge
- }
- if let navigationController = viewController as? UINavigationController {
- return findBridge(in: navigationController.visibleViewController)
- }
- if let tabBarController = viewController as? UITabBarController {
- return findBridge(in: tabBarController.selectedViewController)
- }
- for child in viewController?.children ?? [] {
- if let bridge = findBridge(in: child) {
- return bridge
- }
- }
- return nil
- }
-
public func scene(_ scene: UIScene, openURLContexts URLContexts: Set) {
for context in URLContexts {
lastURL = context.url
diff --git a/ios/Capacitor/Capacitor/CapacitorBridge.swift b/ios/Capacitor/Capacitor/CapacitorBridge.swift
index 4251102e4b..9f4f76b76c 100644
--- a/ios/Capacitor/Capacitor/CapacitorBridge.swift
+++ b/ios/Capacitor/Capacitor/CapacitorBridge.swift
@@ -213,6 +213,7 @@ open class CapacitorBridge: NSObject, CAPBridgeProtocol {
super.init()
self.webViewDelegationHandler.bridge = self
+ self.webViewAssetHandler.setConfiguration(configuration)
exportCoreJS(localUrl: configuration.localURL.absoluteString)
registerPlugins()
@@ -263,13 +264,11 @@ open class CapacitorBridge: NSObject, CAPBridgeProtocol {
exportCordovaJS()
registerCordovaPlugins()
} else {
- observers.append(NotificationCenter.default.addObserver(forName: UIApplication.willEnterForegroundNotification, object: nil, queue: OperationQueue.main) { [weak self] _ in
- guard self?.viewController?.view.window?.windowScene == nil else { return }
- self?.triggerDocumentJSEvent(eventName: "resume")
+ observers.append(NotificationCenter.default.addObserver(forName: UIScene.willEnterForegroundNotification, object: nil, queue: OperationQueue.main) { [weak self] notification in
+ self?.triggerSceneLifecycleJSEvent("resume", for: notification)
})
- observers.append(NotificationCenter.default.addObserver(forName: UIApplication.didEnterBackgroundNotification, object: nil, queue: OperationQueue.main) { [weak self] _ in
- guard self?.viewController?.view.window?.windowScene == nil else { return }
- self?.triggerDocumentJSEvent(eventName: "pause")
+ observers.append(NotificationCenter.default.addObserver(forName: UIScene.didEnterBackgroundNotification, object: nil, queue: OperationQueue.main) { [weak self] notification in
+ self?.triggerSceneLifecycleJSEvent("pause", for: notification)
})
observers.append(NotificationCenter.default.addObserver(forName: UIScene.willEnterForegroundNotification, object: nil, queue: OperationQueue.main) { [weak self] notification in
if let scene = notification.object as? UIWindowScene, scene === self?.viewController?.view.window?.windowScene {
@@ -285,6 +284,30 @@ open class CapacitorBridge: NSObject, CAPBridgeProtocol {
}
}
+ /**
+ Forward a scene lifecycle transition to the page as a document event, but only once
+ the page exists to receive it.
+
+ On a cold start `UIScene.willEnterForegroundNotification` is posted while the initial
+ load is still in flight, before `window.Capacitor` has been defined. Evaluating
+ `triggerEvent` at that point throws inside the web view and surfaces as a
+ "JS Eval error" in the log. The same happens when the web content process was
+ terminated and the page is being reloaded. In both cases the page is about to load
+ from scratch, so there is nothing for it to resume or pause; the event is dropped
+ rather than deferred, because a "resume" delivered right after a fresh load would
+ make the page react to a transition it never went through.
+ */
+ private func triggerSceneLifecycleJSEvent(_ eventName: String, for notification: Notification) {
+ guard let scene = notification.object as? UIWindowScene,
+ scene === viewController?.view.window?.windowScene else {
+ return
+ }
+ guard case .subsequentLoad = webViewDelegationHandler.webViewLoadingState, webView?.isLoading == false else {
+ return
+ }
+ triggerDocumentJSEvent(eventName: eventName)
+ }
+
/**
Export the core Cordova JS runtime
*/
diff --git a/ios/Capacitor/Capacitor/WebViewAssetHandler.swift b/ios/Capacitor/Capacitor/WebViewAssetHandler.swift
index acc8effb74..2e5961dc2c 100644
--- a/ios/Capacitor/Capacitor/WebViewAssetHandler.swift
+++ b/ios/Capacitor/Capacitor/WebViewAssetHandler.swift
@@ -6,6 +6,7 @@ import UniformTypeIdentifiers
open class WebViewAssetHandler: NSObject, WKURLSchemeHandler {
private var router: Router
private var serverUrl: URL?
+ private var configuration: InstanceConfiguration?
public init(router: Router) {
self.router = router
@@ -20,6 +21,10 @@ open class WebViewAssetHandler: NSObject, WKURLSchemeHandler {
self.serverUrl = serverUrl
}
+ open func setConfiguration(_ configuration: InstanceConfiguration?) {
+ self.configuration = configuration
+ }
+
private func isUsingLiveReload(_ localUrl: URL) -> Bool {
return self.serverUrl != nil && self.serverUrl?.scheme != localUrl.scheme
}
@@ -31,7 +36,13 @@ open class WebViewAssetHandler: NSObject, WKURLSchemeHandler {
let localUrl = URL.init(string: url.absoluteString)!
if url.path.starts(with: CapacitorBridge.httpInterceptorStartIdentifier) {
- handleCapacitorHttpRequest(urlSchemeTask, localUrl, false)
+ // Only serve the proxy when CapacitorHttp is on. A scheme task can't tell a document from
+ // a subresource, so keeping documents out relies on the check in decidePolicyFor.
+ if configuration?.getPluginConfig("CapacitorHttp").getBoolean("enabled", false) == true {
+ handleCapacitorHttpRequest(urlSchemeTask, localUrl, false)
+ } else {
+ urlSchemeTask.didFailWithError(URLError(.unsupportedURL))
+ }
return
}
@@ -152,12 +163,13 @@ open class WebViewAssetHandler: NSObject, WKURLSchemeHandler {
let existingHeaders = response.allHeaderFields
var newHeaders: [AnyHashable: Any] = [:]
+ // Nothing should render this. If anything does, sandbox keeps it inert.
+ newHeaders["Content-Security-Policy"] = "sandbox; frame-ancestors 'none'"
+
// if using live reload, then set CORS headers
if self.isUsingLiveReload(url) {
- newHeaders = [
- "Access-Control-Allow-Origin": self.serverUrl?.absoluteString ?? "",
- "Access-Control-Allow-Methods": "GET, HEAD, OPTIONS, TRACE"
- ]
+ newHeaders["Access-Control-Allow-Origin"] = self.serverUrl?.absoluteString ?? ""
+ newHeaders["Access-Control-Allow-Methods"] = "GET, HEAD, OPTIONS, TRACE"
}
if let mergedHeaders = existingHeaders.merging(newHeaders, uniquingKeysWith: { (_, newHeaders) in newHeaders }) as? [String: String] {
diff --git a/ios/Capacitor/Capacitor/WebViewDelegationHandler.swift b/ios/Capacitor/Capacitor/WebViewDelegationHandler.swift
index 866c7e8c5d..935a4c4117 100644
--- a/ios/Capacitor/Capacitor/WebViewDelegationHandler.swift
+++ b/ios/Capacitor/Capacitor/WebViewDelegationHandler.swift
@@ -74,6 +74,12 @@ open class WebViewDelegationHandler: NSObject, WKNavigationDelegate, WKUIDelegat
return
}
+ // The proxy returns a remote body at the app origin, so block it before plugins can allow it.
+ if navURL.path.starts(with: CapacitorBridge.httpInterceptorStartIdentifier) {
+ decisionHandler(.cancel)
+ return
+ }
+
// first, give plugins the chance to handle the decision
for pluginObject in bridge.plugins {
let plugin = pluginObject.value
diff --git a/ios/Capacitor/Capacitor/assets/native-bridge.js b/ios/Capacitor/Capacitor/assets/native-bridge.js
index 5a26f66e06..2485098ca0 100644
--- a/ios/Capacitor/Capacitor/assets/native-bridge.js
+++ b/ios/Capacitor/Capacitor/assets/native-bridge.js
@@ -417,12 +417,6 @@ var nativeBridge = (function (exports) {
}
};
const platform = getPlatformId(win);
- if (platform == 'android' && typeof win.CapacitorSystemBarsAndroidInterface !== 'undefined') {
- // add DOM ready listener for System Bars
- document.addEventListener('DOMContentLoaded', function () {
- win.CapacitorSystemBarsAndroidInterface.onDOMReady();
- });
- }
if (platform == 'android' || platform == 'ios') {
// patch document.cookie on Android/iOS
win.CapacitorCookiesDescriptor =
diff --git a/ios/Capacitor/CapacitorTests/HttpInterceptorNavigationTests.swift b/ios/Capacitor/CapacitorTests/HttpInterceptorNavigationTests.swift
new file mode 100644
index 0000000000..eeb2f8c4a6
--- /dev/null
+++ b/ios/Capacitor/CapacitorTests/HttpInterceptorNavigationTests.swift
@@ -0,0 +1,63 @@
+import WebKit
+import XCTest
+
+@testable import Capacitor
+
+private class StubFrameInfo: WKFrameInfo {
+ override var isMainFrame: Bool { false }
+}
+
+private class StubNavigationAction: WKNavigationAction {
+ private let stubbedRequest: URLRequest
+ private let stubbedTargetFrame: WKFrameInfo?
+ init(url: String, subframe: Bool = false) {
+ self.stubbedRequest = URLRequest(url: URL(string: url)!)
+ self.stubbedTargetFrame = subframe ? StubFrameInfo() : nil
+ super.init()
+ }
+ override var request: URLRequest { stubbedRequest }
+ override var targetFrame: WKFrameInfo? { stubbedTargetFrame }
+}
+
+// The proxy path shares the app's origin, so the origin guard alone would allow it.
+class HttpInterceptorNavigationTests: XCTestCase {
+ private var bridge: MockBridge!
+ private var handler: WebViewDelegationHandler!
+ private let webView = WKWebView()
+
+ override func setUp() {
+ super.setUp()
+ let descriptor = InstanceDescriptor()
+ handler = WebViewDelegationHandler()
+ bridge = MockBridge(
+ with: InstanceConfiguration(with: descriptor, isDebug: true),
+ delegate: MockBridgeViewController(),
+ cordovaConfiguration: descriptor.cordovaConfiguration,
+ assetHandler: MockAssetHandler(router: CapacitorRouter()),
+ delegationHandler: handler
+ )
+ }
+
+ private func policy(for url: String) -> WKNavigationActionPolicy {
+ var decision: WKNavigationActionPolicy?
+ handler.webView(webView, decidePolicyFor: StubNavigationAction(url: url)) { decision = $0 }
+ return decision!
+ }
+
+ func testBlocksNavigationToInterceptorPath() {
+ let interceptorURL = "capacitor://localhost\(CapacitorBridge.httpInterceptorStartIdentifier)?u=https://example.com/payload.html"
+ XCTAssertEqual(policy(for: interceptorURL), .cancel)
+ }
+
+ // decidePolicyFor also fires for subframes, and a same-origin iframe gets the bridge too.
+ func testBlocksSubframeNavigationToInterceptorPath() {
+ let interceptorURL = "capacitor://localhost\(CapacitorBridge.httpInterceptorStartIdentifier)?u=https://example.com/payload.html"
+ var decision: WKNavigationActionPolicy?
+ handler.webView(webView, decidePolicyFor: StubNavigationAction(url: interceptorURL, subframe: true)) { decision = $0 }
+ XCTAssertEqual(decision, .cancel)
+ }
+
+ func testAllowsInAppNavigation() {
+ XCTAssertEqual(policy(for: "capacitor://localhost/index.html"), .allow)
+ }
+}
diff --git a/ios/package.json b/ios/package.json
index 0925b653eb..02c915e608 100644
--- a/ios/package.json
+++ b/ios/package.json
@@ -1,9 +1,9 @@
{
- "name": "@capacitor-plus/ios",
+ "name": "@capacitor/ios",
"version": "8.5.2",
- "description": "Capacitor+: Enhanced Capacitor with automated upstream sync - Cross-platform apps with JavaScript and the web",
- "homepage": "https://capgo.app/docs/plugins/capacitor-plus/",
- "author": "Capgo Team (https://capgo.app)",
+ "description": "Capacitor: Cross-platform apps with JavaScript and the web",
+ "homepage": "https://capacitorjs.com",
+ "author": "Ionic Team (https://ionic.io)",
"license": "MIT",
"repository": {
"type": "git",
@@ -25,7 +25,6 @@
"xc:build:CapacitorCordova": "cd CapacitorCordova && xcodebuild && cd .."
},
"peerDependencies": {
- "@capacitor-plus/core": "^8.5.0",
"@capacitor/core": "^8.5.0"
},
"publishConfig": {