Compare commits
13 Commits
main
...
native-sandbox
| Author | SHA1 | Date | |
|---|---|---|---|
| 3253d6a2ec | |||
| e035d8894f | |||
| 6dc7b5fa53 | |||
| 5d2adce90e | |||
| 36c4d6f877 | |||
| 9f7a77d3b9 | |||
| ceed989a57 | |||
| 3a96c58de0 | |||
| 6aecb04e69 | |||
| feb3739222 | |||
| d7c23a0a32 | |||
| 2dc28e98e6 | |||
| 2418d6af2f |
+2
-1
@@ -145,8 +145,9 @@ build-apk:
|
||||
- npx vite build -l error
|
||||
- cp dist/index.html dist/404.html
|
||||
|
||||
# Sync web assets to Capacitor Android project
|
||||
# Sync web assets to Capacitor Android project and register local plugins
|
||||
- npx cap sync android
|
||||
- node scripts/patch-cap-config.mjs
|
||||
|
||||
# Build signed release APK
|
||||
- cd android && chmod +x gradlew && ./gradlew assembleRelease bundleRelease && cd ..
|
||||
|
||||
@@ -17,8 +17,9 @@ public class MainActivity extends BridgeActivity {
|
||||
|
||||
@Override
|
||||
protected void onCreate(Bundle savedInstanceState) {
|
||||
// Register the native notification config plugin before super.onCreate
|
||||
// Register native plugins before super.onCreate.
|
||||
registerPlugin(DittoNotificationPlugin.class);
|
||||
registerPlugin(SandboxPlugin.class);
|
||||
|
||||
super.onCreate(savedInstanceState);
|
||||
|
||||
|
||||
@@ -0,0 +1,467 @@
|
||||
package pub.ditto.app;
|
||||
|
||||
import android.graphics.Color;
|
||||
import android.os.Handler;
|
||||
import android.os.Looper;
|
||||
import android.util.Base64;
|
||||
import android.util.Log;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.webkit.JavascriptInterface;
|
||||
import android.webkit.WebResourceRequest;
|
||||
import android.webkit.WebResourceResponse;
|
||||
import android.webkit.WebSettings;
|
||||
import android.webkit.WebView;
|
||||
import android.webkit.WebViewClient;
|
||||
import android.widget.FrameLayout;
|
||||
|
||||
import com.getcapacitor.JSObject;
|
||||
import com.getcapacitor.Plugin;
|
||||
import com.getcapacitor.PluginCall;
|
||||
import com.getcapacitor.PluginMethod;
|
||||
import com.getcapacitor.annotation.CapacitorPlugin;
|
||||
|
||||
import org.json.JSONException;
|
||||
import org.json.JSONObject;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.InputStream;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
* Capacitor plugin that creates isolated Android WebViews for sandboxed content.
|
||||
*
|
||||
* Each sandbox uses shouldInterceptRequest to intercept all requests and forward
|
||||
* them to the JS layer as fetch events — the same protocol iframe.diy uses.
|
||||
* The React code can serve files identically regardless of platform.
|
||||
*/
|
||||
@CapacitorPlugin(name = "SandboxPlugin")
|
||||
public class SandboxPlugin extends Plugin {
|
||||
|
||||
private static final String TAG = "SandboxPlugin";
|
||||
private final Map<String, SandboxInstance> sandboxes = new HashMap<>();
|
||||
private final Handler mainHandler = new Handler(Looper.getMainLooper());
|
||||
|
||||
@PluginMethod
|
||||
public void create(PluginCall call) {
|
||||
String sandboxId = call.getString("id");
|
||||
if (sandboxId == null) {
|
||||
call.reject("Missing required parameter: id");
|
||||
return;
|
||||
}
|
||||
|
||||
JSObject frame = call.getObject("frame");
|
||||
if (frame == null) {
|
||||
call.reject("Missing required parameter: frame");
|
||||
return;
|
||||
}
|
||||
|
||||
int x = frame.optInt("x", 0);
|
||||
int y = frame.optInt("y", 0);
|
||||
int width = frame.optInt("width", 0);
|
||||
int height = frame.optInt("height", 0);
|
||||
|
||||
if (sandboxes.containsKey(sandboxId)) {
|
||||
call.reject("Sandbox already exists: " + sandboxId);
|
||||
return;
|
||||
}
|
||||
|
||||
float density = getActivity().getResources().getDisplayMetrics().density;
|
||||
int pxX = Math.round(x * density);
|
||||
int pxY = Math.round(y * density);
|
||||
int pxWidth = Math.round(width * density);
|
||||
int pxHeight = Math.round(height * density);
|
||||
|
||||
mainHandler.post(() -> {
|
||||
SandboxInstance sandbox = new SandboxInstance(sandboxId, this);
|
||||
sandboxes.put(sandboxId, sandbox);
|
||||
|
||||
// Add the WebView on top of the Capacitor WebView.
|
||||
View capWebView = getBridge().getWebView();
|
||||
ViewGroup parent = (ViewGroup) capWebView.getParent();
|
||||
FrameLayout.LayoutParams params = new FrameLayout.LayoutParams(pxWidth, pxHeight);
|
||||
params.leftMargin = pxX;
|
||||
params.topMargin = pxY;
|
||||
parent.addView(sandbox.webView, params);
|
||||
|
||||
// Load the initial page.
|
||||
sandbox.webView.loadUrl("https://" + sandboxId + ".sandbox.native/index.html");
|
||||
|
||||
call.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
public void updateFrame(PluginCall call) {
|
||||
String sandboxId = call.getString("id");
|
||||
if (sandboxId == null) {
|
||||
call.reject("Missing required parameter: id");
|
||||
return;
|
||||
}
|
||||
|
||||
JSObject frame = call.getObject("frame");
|
||||
if (frame == null) {
|
||||
call.reject("Missing required parameter: frame");
|
||||
return;
|
||||
}
|
||||
|
||||
int x = frame.optInt("x", 0);
|
||||
int y = frame.optInt("y", 0);
|
||||
int width = frame.optInt("width", 0);
|
||||
int height = frame.optInt("height", 0);
|
||||
|
||||
float density = getActivity().getResources().getDisplayMetrics().density;
|
||||
int pxX = Math.round(x * density);
|
||||
int pxY = Math.round(y * density);
|
||||
int pxWidth = Math.round(width * density);
|
||||
int pxHeight = Math.round(height * density);
|
||||
|
||||
mainHandler.post(() -> {
|
||||
SandboxInstance sandbox = sandboxes.get(sandboxId);
|
||||
if (sandbox == null) {
|
||||
call.reject("Sandbox not found: " + sandboxId);
|
||||
return;
|
||||
}
|
||||
|
||||
FrameLayout.LayoutParams params = new FrameLayout.LayoutParams(pxWidth, pxHeight);
|
||||
params.leftMargin = pxX;
|
||||
params.topMargin = pxY;
|
||||
sandbox.webView.setLayoutParams(params);
|
||||
|
||||
call.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
public void respondToFetch(PluginCall call) {
|
||||
String sandboxId = call.getString("id");
|
||||
if (sandboxId == null) {
|
||||
call.reject("Missing required parameter: id");
|
||||
return;
|
||||
}
|
||||
String requestId = call.getString("requestId");
|
||||
if (requestId == null) {
|
||||
call.reject("Missing required parameter: requestId");
|
||||
return;
|
||||
}
|
||||
JSObject response = call.getObject("response");
|
||||
if (response == null) {
|
||||
call.reject("Missing required parameter: response");
|
||||
return;
|
||||
}
|
||||
|
||||
SandboxInstance sandbox = sandboxes.get(sandboxId);
|
||||
if (sandbox == null) {
|
||||
call.reject("Sandbox not found: " + sandboxId);
|
||||
return;
|
||||
}
|
||||
|
||||
int status = response.optInt("status", 200);
|
||||
String statusText = response.optString("statusText", "OK");
|
||||
String bodyBase64 = response.optString("body", null);
|
||||
|
||||
Map<String, String> headers = new HashMap<>();
|
||||
JSONObject headersObj = response.optJSONObject("headers");
|
||||
if (headersObj != null) {
|
||||
for (java.util.Iterator<String> it = headersObj.keys(); it.hasNext(); ) {
|
||||
String key = it.next();
|
||||
headers.put(key, headersObj.optString(key));
|
||||
}
|
||||
}
|
||||
|
||||
sandbox.resolveRequest(requestId, status, statusText, headers, bodyBase64);
|
||||
|
||||
call.resolve();
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
public void postMessage(PluginCall call) {
|
||||
String sandboxId = call.getString("id");
|
||||
if (sandboxId == null) {
|
||||
call.reject("Missing required parameter: id");
|
||||
return;
|
||||
}
|
||||
JSObject message = call.getObject("message");
|
||||
if (message == null) {
|
||||
call.reject("Missing required parameter: message");
|
||||
return;
|
||||
}
|
||||
|
||||
SandboxInstance sandbox = sandboxes.get(sandboxId);
|
||||
if (sandbox == null) {
|
||||
call.reject("Sandbox not found: " + sandboxId);
|
||||
return;
|
||||
}
|
||||
|
||||
mainHandler.post(() -> sandbox.postMessageToWebView(message.toString()));
|
||||
|
||||
call.resolve();
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
public void destroy(PluginCall call) {
|
||||
String sandboxId = call.getString("id");
|
||||
if (sandboxId == null) {
|
||||
call.reject("Missing required parameter: id");
|
||||
return;
|
||||
}
|
||||
|
||||
mainHandler.post(() -> {
|
||||
SandboxInstance sandbox = sandboxes.remove(sandboxId);
|
||||
if (sandbox != null) {
|
||||
ViewGroup parent = (ViewGroup) sandbox.webView.getParent();
|
||||
if (parent != null) {
|
||||
parent.removeView(sandbox.webView);
|
||||
}
|
||||
sandbox.webView.destroy();
|
||||
}
|
||||
call.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
void emitFetchRequest(String sandboxId, String requestId, JSObject request) {
|
||||
JSObject data = new JSObject();
|
||||
data.put("id", sandboxId);
|
||||
data.put("requestId", requestId);
|
||||
data.put("request", request);
|
||||
notifyListeners("fetch", data);
|
||||
}
|
||||
|
||||
void emitScriptMessage(String sandboxId, JSObject message) {
|
||||
JSObject data = new JSObject();
|
||||
data.put("id", sandboxId);
|
||||
data.put("message", message);
|
||||
notifyListeners("scriptMessage", data);
|
||||
}
|
||||
|
||||
/**
|
||||
* A single sandboxed WebView instance.
|
||||
*/
|
||||
private static class SandboxInstance {
|
||||
final String id;
|
||||
final WebView webView;
|
||||
final SandboxPlugin plugin;
|
||||
private final ConcurrentHashMap<String, PendingRequest> pendingRequests = new ConcurrentHashMap<>();
|
||||
|
||||
SandboxInstance(String id, SandboxPlugin plugin) {
|
||||
this.id = id;
|
||||
this.plugin = plugin;
|
||||
this.webView = new WebView(plugin.getActivity());
|
||||
|
||||
WebSettings settings = webView.getSettings();
|
||||
settings.setJavaScriptEnabled(true);
|
||||
settings.setDomStorageEnabled(true);
|
||||
settings.setAllowFileAccess(false);
|
||||
settings.setAllowContentAccess(false);
|
||||
settings.setDatabaseEnabled(true);
|
||||
|
||||
webView.setBackgroundColor(Color.WHITE);
|
||||
|
||||
// Add JavaScript interface for script->native communication.
|
||||
webView.addJavascriptInterface(new SandboxBridge(this), "__sandboxNative");
|
||||
|
||||
// Inject the bridge script and intercept requests.
|
||||
webView.setWebViewClient(new SandboxWebViewClient(this));
|
||||
}
|
||||
|
||||
void postMessageToWebView(String jsonString) {
|
||||
String js = "(function() { " +
|
||||
"if (window.__sandboxBridge && window.__sandboxBridge.onMessage) { " +
|
||||
"window.__sandboxBridge.onMessage(" + jsonString + "); " +
|
||||
"} " +
|
||||
"})();";
|
||||
webView.evaluateJavascript(js, null);
|
||||
}
|
||||
|
||||
void resolveRequest(String requestId, int status, String statusText,
|
||||
Map<String, String> headers, String bodyBase64) {
|
||||
PendingRequest pending = pendingRequests.remove(requestId);
|
||||
if (pending == null) return;
|
||||
|
||||
byte[] bodyBytes = null;
|
||||
if (bodyBase64 != null && !bodyBase64.equals("null")) {
|
||||
try {
|
||||
bodyBytes = Base64.decode(bodyBase64, Base64.DEFAULT);
|
||||
} catch (Exception e) {
|
||||
Log.w(TAG, "Base64 decode failed for request " + requestId, e);
|
||||
}
|
||||
}
|
||||
|
||||
String contentType = headers.getOrDefault("Content-Type", "application/octet-stream");
|
||||
String encoding = contentType.contains("text/") ? "UTF-8" : null;
|
||||
|
||||
InputStream body = bodyBytes != null
|
||||
? new ByteArrayInputStream(bodyBytes)
|
||||
: new ByteArrayInputStream(new byte[0]);
|
||||
|
||||
WebResourceResponse response = new WebResourceResponse(
|
||||
contentType, encoding, status, statusText, headers, body
|
||||
);
|
||||
|
||||
pending.resolve(response);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* WebViewClient that intercepts all requests and forwards them to JS.
|
||||
*/
|
||||
private static class SandboxWebViewClient extends WebViewClient {
|
||||
private final SandboxInstance sandbox;
|
||||
private boolean bridgeInjected = false;
|
||||
|
||||
SandboxWebViewClient(SandboxInstance sandbox) {
|
||||
this.sandbox = sandbox;
|
||||
}
|
||||
|
||||
@Override
|
||||
public WebResourceResponse shouldInterceptRequest(WebView view, WebResourceRequest request) {
|
||||
String url = request.getUrl().toString();
|
||||
|
||||
// Only intercept requests to the sandbox domain.
|
||||
if (!url.contains(".sandbox.native")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
String requestId = UUID.randomUUID().toString();
|
||||
|
||||
// Create a pending request with a blocking latch.
|
||||
PendingRequest pending = new PendingRequest();
|
||||
sandbox.pendingRequests.put(requestId, pending);
|
||||
|
||||
// Rewrite URL to include the sandbox ID for the JS handler.
|
||||
String path = request.getUrl().getPath();
|
||||
if (path == null || path.isEmpty()) path = "/";
|
||||
String rewrittenURL = "https://" + sandbox.id + ".sandbox.native" + path;
|
||||
|
||||
// Serialise the request.
|
||||
JSObject serialisedRequest = new JSObject();
|
||||
serialisedRequest.put("url", rewrittenURL);
|
||||
serialisedRequest.put("method", request.getMethod());
|
||||
|
||||
JSObject headers = new JSObject();
|
||||
for (Map.Entry<String, String> entry : request.getRequestHeaders().entrySet()) {
|
||||
headers.put(entry.getKey(), entry.getValue());
|
||||
}
|
||||
serialisedRequest.put("headers", headers);
|
||||
serialisedRequest.put("body", JSONObject.NULL);
|
||||
|
||||
// Emit to JS.
|
||||
sandbox.plugin.emitFetchRequest(sandbox.id, requestId, serialisedRequest);
|
||||
|
||||
// Block this thread until JS responds (with a timeout).
|
||||
WebResourceResponse response = pending.awaitResponse(10000);
|
||||
|
||||
if (response != null) {
|
||||
return response;
|
||||
}
|
||||
|
||||
// Timeout — return error response.
|
||||
sandbox.pendingRequests.remove(requestId);
|
||||
return new WebResourceResponse(
|
||||
"text/plain", "UTF-8", 504,
|
||||
"Gateway Timeout", new HashMap<>(),
|
||||
new ByteArrayInputStream("Request timed out".getBytes())
|
||||
);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onPageFinished(WebView view, String url) {
|
||||
super.onPageFinished(view, url);
|
||||
|
||||
if (!bridgeInjected) {
|
||||
bridgeInjected = true;
|
||||
view.evaluateJavascript(getBridgeScript(), null);
|
||||
}
|
||||
}
|
||||
|
||||
private String getBridgeScript() {
|
||||
return "(function() {" +
|
||||
"'use strict';" +
|
||||
"var messageListeners = [];" +
|
||||
"window.__sandboxBridge = {" +
|
||||
" onMessage: function(data) {" +
|
||||
" var event = {" +
|
||||
" data: data," +
|
||||
" origin: 'https://" + id + ".sandbox.native'," +
|
||||
" source: window.parent," +
|
||||
" type: 'message'" +
|
||||
" };" +
|
||||
" for (var i = 0; i < messageListeners.length; i++) {" +
|
||||
" try { messageListeners[i](event); } catch(e) {}" +
|
||||
" }" +
|
||||
" }" +
|
||||
"};" +
|
||||
"var origAdd = window.addEventListener;" +
|
||||
"window.addEventListener = function(type, fn, opts) {" +
|
||||
" if (type === 'message' && typeof fn === 'function') messageListeners.push(fn);" +
|
||||
" return origAdd.call(window, type, fn, opts);" +
|
||||
"};" +
|
||||
"var origRemove = window.removeEventListener;" +
|
||||
"window.removeEventListener = function(type, fn, opts) {" +
|
||||
" if (type === 'message') {" +
|
||||
" var idx = messageListeners.indexOf(fn);" +
|
||||
" if (idx !== -1) messageListeners.splice(idx, 1);" +
|
||||
" }" +
|
||||
" return origRemove.call(window, type, fn, opts);" +
|
||||
"};" +
|
||||
"if (!window.parent || window.parent === window) window.parent = {};" +
|
||||
"window.parent.postMessage = function(data) {" +
|
||||
" if (data && typeof data === 'object' && data.jsonrpc === '2.0') {" +
|
||||
" try { window.__sandboxNative.postMessage(JSON.stringify(data)); } catch(e) {}" +
|
||||
" }" +
|
||||
"};" +
|
||||
"})();";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* JavaScript interface exposed to the sandbox WebView.
|
||||
*/
|
||||
private static class SandboxBridge {
|
||||
private final SandboxInstance sandbox;
|
||||
|
||||
SandboxBridge(SandboxInstance sandbox) {
|
||||
this.sandbox = sandbox;
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
public void postMessage(String json) {
|
||||
try {
|
||||
JSONObject obj = new JSONObject(json);
|
||||
JSObject jsObj = new JSObject();
|
||||
for (java.util.Iterator<String> it = obj.keys(); it.hasNext(); ) {
|
||||
String key = it.next();
|
||||
jsObj.put(key, obj.get(key));
|
||||
}
|
||||
sandbox.plugin.emitScriptMessage(sandbox.id, jsObj);
|
||||
} catch (JSONException e) {
|
||||
Log.w(TAG, "Failed to parse script message", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A pending request that blocks the WebViewClient thread until resolved.
|
||||
*/
|
||||
private static class PendingRequest {
|
||||
private WebResourceResponse response;
|
||||
private final java.util.concurrent.CountDownLatch latch = new java.util.concurrent.CountDownLatch(1);
|
||||
|
||||
void resolve(WebResourceResponse response) {
|
||||
this.response = response;
|
||||
latch.countDown();
|
||||
}
|
||||
|
||||
WebResourceResponse awaitResponse(long timeoutMs) {
|
||||
try {
|
||||
latch.await(timeoutMs, java.util.concurrent.TimeUnit.MILLISECONDS);
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
return response;
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -17,7 +17,7 @@ const config: CapacitorConfig = {
|
||||
},
|
||||
ios: {
|
||||
backgroundColor: '#14161f',
|
||||
contentInset: 'automatic',
|
||||
contentInset: 'never',
|
||||
scheme: 'Ditto'
|
||||
}
|
||||
};
|
||||
|
||||
@@ -15,6 +15,8 @@
|
||||
504EC30F1FED79650016851F /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 504EC30E1FED79650016851F /* Assets.xcassets */; };
|
||||
504EC3121FED79650016851F /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 504EC3101FED79650016851F /* LaunchScreen.storyboard */; };
|
||||
50B271D11FEDC1A000F3C39B /* public in Resources */ = {isa = PBXBuildFile; fileRef = 50B271D01FEDC1A000F3C39B /* public */; };
|
||||
B1A2C3D40001000100000001 /* SandboxPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1A2C3D40001000100000002 /* SandboxPlugin.swift */; };
|
||||
B1A2C3D40002000100000001 /* DittoBridgeViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1A2C3D40002000100000002 /* DittoBridgeViewController.swift */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
@@ -28,6 +30,8 @@
|
||||
504EC3131FED79650016851F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||
50B271D01FEDC1A000F3C39B /* public */ = {isa = PBXFileReference; lastKnownFileType = folder; path = public; sourceTree = "<group>"; };
|
||||
958DCC722DB07C7200EA8C5F /* debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = debug.xcconfig; path = ../debug.xcconfig; sourceTree = SOURCE_ROOT; };
|
||||
B1A2C3D40001000100000002 /* SandboxPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SandboxPlugin.swift; sourceTree = "<group>"; };
|
||||
B1A2C3D40002000100000002 /* DittoBridgeViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DittoBridgeViewController.swift; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
@@ -64,6 +68,8 @@
|
||||
children = (
|
||||
50379B222058CBB4000EE86E /* capacitor.config.json */,
|
||||
504EC3071FED79650016851F /* AppDelegate.swift */,
|
||||
B1A2C3D40001000100000002 /* SandboxPlugin.swift */,
|
||||
B1A2C3D40002000100000002 /* DittoBridgeViewController.swift */,
|
||||
504EC30B1FED79650016851F /* Main.storyboard */,
|
||||
504EC30E1FED79650016851F /* Assets.xcassets */,
|
||||
504EC3101FED79650016851F /* LaunchScreen.storyboard */,
|
||||
@@ -156,6 +162,8 @@
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
504EC3081FED79650016851F /* AppDelegate.swift in Sources */,
|
||||
B1A2C3D40001000100000001 /* SandboxPlugin.swift in Sources */,
|
||||
B1A2C3D40002000100000001 /* DittoBridgeViewController.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
<!--Bridge View Controller-->
|
||||
<scene sceneID="tne-QT-ifu">
|
||||
<objects>
|
||||
<viewController id="BYZ-38-t0r" customClass="CAPBridgeViewController" customModule="Capacitor" sceneMemberID="viewController"/>
|
||||
<viewController id="BYZ-38-t0r" customClass="DittoBridgeViewController" customModule="App" sceneMemberID="viewController"/>
|
||||
<placeholder placeholderIdentifier="IBFirstResponder" id="dkx-z0-nzr" sceneMemberID="firstResponder"/>
|
||||
</objects>
|
||||
</scene>
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import UIKit
|
||||
import Capacitor
|
||||
|
||||
class DittoBridgeViewController: CAPBridgeViewController {
|
||||
override func capacitorDidLoad() {
|
||||
super.capacitorDidLoad()
|
||||
webView?.allowsBackForwardNavigationGestures = true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,475 @@
|
||||
import Foundation
|
||||
import Capacitor
|
||||
import WebKit
|
||||
|
||||
// MARK: - Plugin
|
||||
|
||||
/// Capacitor plugin that creates isolated WKWebViews for sandboxed content.
|
||||
///
|
||||
/// Each sandbox gets a unique custom URL scheme (`sbx-<id>://`) so that
|
||||
/// every embedded app has its own origin (separate localStorage, cookies, etc.).
|
||||
/// All requests on the custom scheme are intercepted via `WKURLSchemeHandler`
|
||||
/// and forwarded to the JS layer as fetch events — the same protocol
|
||||
/// iframe.diy uses. This lets the existing React code serve files identically.
|
||||
@objc(SandboxPlugin)
|
||||
public class SandboxPlugin: CAPPlugin, CAPBridgedPlugin {
|
||||
public let identifier = "SandboxPlugin"
|
||||
public let jsName = "SandboxPlugin"
|
||||
public let pluginMethods: [CAPPluginMethod] = [
|
||||
CAPPluginMethod(name: "create", returnType: CAPPluginReturnPromise),
|
||||
CAPPluginMethod(name: "updateFrame", returnType: CAPPluginReturnPromise),
|
||||
CAPPluginMethod(name: "respondToFetch", returnType: CAPPluginReturnPromise),
|
||||
CAPPluginMethod(name: "postMessage", returnType: CAPPluginReturnPromise),
|
||||
CAPPluginMethod(name: "destroy", returnType: CAPPluginReturnPromise),
|
||||
]
|
||||
|
||||
/// Active sandbox instances, keyed by sandbox ID.
|
||||
private var sandboxes: [String: SandboxInstance] = [:]
|
||||
|
||||
// MARK: - Plugin Methods
|
||||
|
||||
@objc func create(_ call: CAPPluginCall) {
|
||||
guard let sandboxId = call.getString("id") else {
|
||||
call.reject("Missing required parameter: id")
|
||||
return
|
||||
}
|
||||
guard let frame = call.getObject("frame"),
|
||||
let x = frame["x"] as? Double,
|
||||
let y = frame["y"] as? Double,
|
||||
let width = frame["width"] as? Double,
|
||||
let height = frame["height"] as? Double else {
|
||||
call.reject("Missing or invalid parameter: frame")
|
||||
return
|
||||
}
|
||||
|
||||
if sandboxes[sandboxId] != nil {
|
||||
call.reject("Sandbox already exists: \(sandboxId)")
|
||||
return
|
||||
}
|
||||
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self = self else { return }
|
||||
|
||||
let webViewFrame = CGRect(x: x, y: y, width: width, height: height)
|
||||
let sandbox = SandboxInstance(
|
||||
id: sandboxId,
|
||||
frame: webViewFrame,
|
||||
plugin: self
|
||||
)
|
||||
self.sandboxes[sandboxId] = sandbox
|
||||
|
||||
// Add the WebView on top of the Capacitor WebView.
|
||||
if let bridge = self.bridge,
|
||||
let webView = bridge.webView {
|
||||
webView.superview?.addSubview(sandbox.webView)
|
||||
}
|
||||
|
||||
call.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
@objc func updateFrame(_ call: CAPPluginCall) {
|
||||
guard let sandboxId = call.getString("id") else {
|
||||
call.reject("Missing required parameter: id")
|
||||
return
|
||||
}
|
||||
guard let frame = call.getObject("frame"),
|
||||
let x = frame["x"] as? Double,
|
||||
let y = frame["y"] as? Double,
|
||||
let width = frame["width"] as? Double,
|
||||
let height = frame["height"] as? Double else {
|
||||
call.reject("Missing or invalid parameter: frame")
|
||||
return
|
||||
}
|
||||
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let sandbox = self?.sandboxes[sandboxId] else {
|
||||
call.reject("Sandbox not found: \(sandboxId)")
|
||||
return
|
||||
}
|
||||
sandbox.webView.frame = CGRect(x: x, y: y, width: width, height: height)
|
||||
call.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
@objc func respondToFetch(_ call: CAPPluginCall) {
|
||||
guard let sandboxId = call.getString("id") else {
|
||||
call.reject("Missing required parameter: id")
|
||||
return
|
||||
}
|
||||
guard let requestId = call.getString("requestId") else {
|
||||
call.reject("Missing required parameter: requestId")
|
||||
return
|
||||
}
|
||||
guard let response = call.getObject("response") else {
|
||||
call.reject("Missing required parameter: response")
|
||||
return
|
||||
}
|
||||
|
||||
guard let sandbox = sandboxes[sandboxId] else {
|
||||
call.reject("Sandbox not found: \(sandboxId)")
|
||||
return
|
||||
}
|
||||
|
||||
sandbox.schemeHandler.resolveRequest(
|
||||
requestId: requestId,
|
||||
status: response["status"] as? Int ?? 200,
|
||||
statusText: response["statusText"] as? String ?? "OK",
|
||||
headers: response["headers"] as? [String: String] ?? [:],
|
||||
bodyBase64: response["body"] as? String
|
||||
)
|
||||
|
||||
call.resolve()
|
||||
}
|
||||
|
||||
@objc func postMessage(_ call: CAPPluginCall) {
|
||||
guard let sandboxId = call.getString("id") else {
|
||||
call.reject("Missing required parameter: id")
|
||||
return
|
||||
}
|
||||
guard let message = call.getObject("message") else {
|
||||
call.reject("Missing required parameter: message")
|
||||
return
|
||||
}
|
||||
|
||||
guard let sandbox = sandboxes[sandboxId] else {
|
||||
call.reject("Sandbox not found: \(sandboxId)")
|
||||
return
|
||||
}
|
||||
|
||||
DispatchQueue.main.async {
|
||||
sandbox.postMessageToWebView(message)
|
||||
}
|
||||
|
||||
call.resolve()
|
||||
}
|
||||
|
||||
@objc func destroy(_ call: CAPPluginCall) {
|
||||
guard let sandboxId = call.getString("id") else {
|
||||
call.reject("Missing required parameter: id")
|
||||
return
|
||||
}
|
||||
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self = self else { return }
|
||||
if let sandbox = self.sandboxes.removeValue(forKey: sandboxId) {
|
||||
sandbox.webView.removeFromSuperview()
|
||||
sandbox.schemeHandler.cancelAll()
|
||||
}
|
||||
call.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Event Forwarding
|
||||
|
||||
/// Forward a fetch request from the native WebView to JS.
|
||||
func emitFetchRequest(sandboxId: String, requestId: String, request: [String: Any]) {
|
||||
notifyListeners("fetch", data: [
|
||||
"id": sandboxId,
|
||||
"requestId": requestId,
|
||||
"request": request,
|
||||
])
|
||||
}
|
||||
|
||||
/// Forward a script message from the sandbox to JS.
|
||||
func emitScriptMessage(sandboxId: String, message: [String: Any]) {
|
||||
notifyListeners("scriptMessage", data: [
|
||||
"id": sandboxId,
|
||||
"message": message,
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - SandboxInstance
|
||||
|
||||
/// Manages a single sandboxed WKWebView instance.
|
||||
private class SandboxInstance: NSObject, WKScriptMessageHandler {
|
||||
let id: String
|
||||
let webView: WKWebView
|
||||
let schemeHandler: SandboxSchemeHandler
|
||||
private weak var plugin: SandboxPlugin?
|
||||
private let customScheme: String
|
||||
|
||||
init(id: String, frame: CGRect, plugin: SandboxPlugin) {
|
||||
self.id = id
|
||||
self.plugin = plugin
|
||||
|
||||
// Each sandbox gets a unique custom URL scheme so that WKWebView
|
||||
// assigns a distinct origin, isolating localStorage/IndexedDB/cookies.
|
||||
self.customScheme = "sbx-\(id)"
|
||||
|
||||
self.schemeHandler = SandboxSchemeHandler(
|
||||
sandboxId: id,
|
||||
scheme: self.customScheme,
|
||||
plugin: plugin
|
||||
)
|
||||
|
||||
let config = WKWebViewConfiguration()
|
||||
config.setURLSchemeHandler(self.schemeHandler, forURLScheme: self.customScheme)
|
||||
|
||||
// Add a script message handler for communication from injected scripts.
|
||||
let userContentController = WKUserContentController()
|
||||
|
||||
// Inject a bridge script that:
|
||||
// 1. Provides window.parent.postMessage()-like functionality
|
||||
// 2. Routes messages through the native bridge
|
||||
let bridgeScript = WKUserScript(
|
||||
source: SandboxInstance.bridgeScript(scheme: self.customScheme),
|
||||
injectionTime: .atDocumentStart,
|
||||
forMainFrameOnly: false
|
||||
)
|
||||
userContentController.addUserScript(bridgeScript)
|
||||
|
||||
config.userContentController = userContentController
|
||||
config.preferences.javaScriptCanOpenWindowsAutomatically = false
|
||||
config.defaultWebpagePreferences.allowsContentJavaScript = true
|
||||
|
||||
self.webView = WKWebView(frame: frame, configuration: config)
|
||||
self.webView.isOpaque = false
|
||||
self.webView.backgroundColor = .white
|
||||
self.webView.scrollView.bounces = false
|
||||
|
||||
super.init()
|
||||
|
||||
// Register the message handler after super.init().
|
||||
userContentController.add(self, name: "sandboxBridge")
|
||||
|
||||
// Load the initial page via the custom scheme.
|
||||
let initialURL = URL(string: "\(self.customScheme)://app/index.html")!
|
||||
self.webView.load(URLRequest(url: initialURL))
|
||||
}
|
||||
|
||||
/// Post a JSON-RPC message to injected scripts inside the WebView.
|
||||
func postMessageToWebView(_ message: [String: Any]) {
|
||||
guard let jsonData = try? JSONSerialization.data(withJSONObject: message),
|
||||
let jsonString = String(data: jsonData, encoding: .utf8) else {
|
||||
return
|
||||
}
|
||||
|
||||
let js = """
|
||||
(function() {
|
||||
if (window.__sandboxBridge && window.__sandboxBridge.onMessage) {
|
||||
window.__sandboxBridge.onMessage(\(jsonString));
|
||||
}
|
||||
})();
|
||||
"""
|
||||
webView.evaluateJavaScript(js, completionHandler: nil)
|
||||
}
|
||||
|
||||
// MARK: - WKScriptMessageHandler
|
||||
|
||||
/// Receive messages from injected scripts via webkit.messageHandlers.sandboxBridge.
|
||||
func userContentController(
|
||||
_ userContentController: WKUserContentController,
|
||||
didReceive message: WKScriptMessage
|
||||
) {
|
||||
guard message.name == "sandboxBridge",
|
||||
let body = message.body as? [String: Any] else {
|
||||
return
|
||||
}
|
||||
plugin?.emitScriptMessage(sandboxId: id, message: body)
|
||||
}
|
||||
|
||||
// MARK: - Bridge Script
|
||||
|
||||
/// JavaScript injected at document start that provides:
|
||||
/// - `window.parent.postMessage()` emulation via WKScriptMessageHandler
|
||||
/// - `window.__sandboxBridge.onMessage()` for receiving messages from parent
|
||||
/// - `window.addEventListener("message", ...)` support for injected scripts
|
||||
private static func bridgeScript(scheme: String) -> String {
|
||||
return """
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
// Message listeners registered by injected scripts.
|
||||
var messageListeners = [];
|
||||
|
||||
// Bridge object for native communication.
|
||||
window.__sandboxBridge = {
|
||||
onMessage: function(data) {
|
||||
// Dispatch to all registered message listeners.
|
||||
var event = {
|
||||
data: data,
|
||||
origin: '\(scheme)://app',
|
||||
source: window.parent,
|
||||
type: 'message'
|
||||
};
|
||||
for (var i = 0; i < messageListeners.length; i++) {
|
||||
try {
|
||||
messageListeners[i](event);
|
||||
} catch (e) {
|
||||
console.error('[SandboxBridge] Listener error:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Override addEventListener to capture "message" listeners.
|
||||
var originalAddEventListener = window.addEventListener;
|
||||
window.addEventListener = function(type, listener, options) {
|
||||
if (type === 'message' && typeof listener === 'function') {
|
||||
messageListeners.push(listener);
|
||||
}
|
||||
return originalAddEventListener.call(window, type, listener, options);
|
||||
};
|
||||
|
||||
var originalRemoveEventListener = window.removeEventListener;
|
||||
window.removeEventListener = function(type, listener, options) {
|
||||
if (type === 'message') {
|
||||
var idx = messageListeners.indexOf(listener);
|
||||
if (idx !== -1) messageListeners.splice(idx, 1);
|
||||
}
|
||||
return originalRemoveEventListener.call(window, type, listener, options);
|
||||
};
|
||||
|
||||
// Emulate window.parent.postMessage for scripts that use it
|
||||
// (e.g. the webxdc bridge script, preview injected script).
|
||||
if (!window.parent || window.parent === window) {
|
||||
window.parent = {};
|
||||
}
|
||||
window.parent.postMessage = function(data, targetOrigin, transfer) {
|
||||
if (data && typeof data === 'object' && data.jsonrpc === '2.0') {
|
||||
try {
|
||||
window.webkit.messageHandlers.sandboxBridge.postMessage(data);
|
||||
} catch (e) {
|
||||
console.error('[SandboxBridge] postMessage failed:', e);
|
||||
}
|
||||
}
|
||||
};
|
||||
})();
|
||||
""";
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - SandboxSchemeHandler
|
||||
|
||||
/// WKURLSchemeHandler that intercepts all requests on the sandbox's custom
|
||||
/// URL scheme and forwards them to the JS layer as fetch events.
|
||||
private class SandboxSchemeHandler: NSObject, WKURLSchemeHandler {
|
||||
private let sandboxId: String
|
||||
private let scheme: String
|
||||
private weak var plugin: SandboxPlugin?
|
||||
|
||||
/// Pending scheme tasks waiting for a response from JS.
|
||||
/// Key: requestId (UUID string), Value: the WKURLSchemeTask to respond to.
|
||||
private var pendingTasks: [String: WKURLSchemeTask] = [:]
|
||||
private let lock = NSLock()
|
||||
|
||||
init(sandboxId: String, scheme: String, plugin: SandboxPlugin) {
|
||||
self.sandboxId = sandboxId
|
||||
self.scheme = scheme
|
||||
self.plugin = plugin
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView, start urlSchemeTask: WKURLSchemeTask) {
|
||||
let request = urlSchemeTask.request
|
||||
guard let url = request.url else {
|
||||
urlSchemeTask.didFailWithError(NSError(
|
||||
domain: "SandboxPlugin", code: -1,
|
||||
userInfo: [NSLocalizedDescriptionKey: "No URL in request"]
|
||||
))
|
||||
return
|
||||
}
|
||||
|
||||
let requestId = UUID().uuidString
|
||||
|
||||
lock.lock()
|
||||
pendingTasks[requestId] = urlSchemeTask
|
||||
lock.unlock()
|
||||
|
||||
// Serialise the request for the fetch event.
|
||||
// Rewrite the URL so it looks like a normal HTTP URL to the parent
|
||||
// (e.g. "sbx-abc123://app/index.html" -> "https://<sandboxId>.sandbox.native/index.html")
|
||||
// The JS side only cares about the pathname.
|
||||
var headers: [String: String] = [:]
|
||||
if let allHeaders = request.allHTTPHeaderFields {
|
||||
headers = allHeaders
|
||||
}
|
||||
|
||||
var bodyBase64: String? = nil
|
||||
if let bodyData = request.httpBody {
|
||||
bodyBase64 = bodyData.base64EncodedString()
|
||||
}
|
||||
|
||||
let path = url.path.isEmpty ? "/" : url.path
|
||||
let rewrittenURL = "https://\(sandboxId).sandbox.native\(path)"
|
||||
|
||||
let serialisedRequest: [String: Any] = [
|
||||
"url": rewrittenURL,
|
||||
"method": request.httpMethod ?? "GET",
|
||||
"headers": headers,
|
||||
"body": bodyBase64 as Any,
|
||||
]
|
||||
|
||||
plugin?.emitFetchRequest(
|
||||
sandboxId: sandboxId,
|
||||
requestId: requestId,
|
||||
request: serialisedRequest
|
||||
)
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView, stop urlSchemeTask: WKURLSchemeTask) {
|
||||
// Remove the task from pending — JS response will be ignored if it arrives later.
|
||||
lock.lock()
|
||||
let removed = pendingTasks.first(where: { $0.value === urlSchemeTask })
|
||||
if let key = removed?.key {
|
||||
pendingTasks.removeValue(forKey: key)
|
||||
}
|
||||
lock.unlock()
|
||||
}
|
||||
|
||||
/// Called by the plugin when JS responds to a fetch request.
|
||||
func resolveRequest(
|
||||
requestId: String,
|
||||
status: Int,
|
||||
statusText: String,
|
||||
headers: [String: String],
|
||||
bodyBase64: String?
|
||||
) {
|
||||
lock.lock()
|
||||
guard let task = pendingTasks.removeValue(forKey: requestId) else {
|
||||
lock.unlock()
|
||||
return
|
||||
}
|
||||
lock.unlock()
|
||||
|
||||
// Decode the base64 body.
|
||||
var bodyData: Data? = nil
|
||||
if let b64 = bodyBase64 {
|
||||
bodyData = Data(base64Encoded: b64)
|
||||
}
|
||||
|
||||
// Build the response.
|
||||
// Use the task's original URL for the response.
|
||||
let responseURL = task.request.url ?? URL(string: "\(scheme)://app/")!
|
||||
let response = HTTPURLResponse(
|
||||
url: responseURL,
|
||||
statusCode: status,
|
||||
httpVersion: "HTTP/1.1",
|
||||
headerFields: headers
|
||||
)!
|
||||
|
||||
DispatchQueue.main.async {
|
||||
task.didReceive(response)
|
||||
if let data = bodyData {
|
||||
task.didReceive(data)
|
||||
}
|
||||
task.didFinish()
|
||||
}
|
||||
}
|
||||
|
||||
/// Cancel all pending tasks (called on destroy).
|
||||
func cancelAll() {
|
||||
lock.lock()
|
||||
let tasks = pendingTasks
|
||||
pendingTasks.removeAll()
|
||||
lock.unlock()
|
||||
|
||||
for (_, task) in tasks {
|
||||
task.didFailWithError(NSError(
|
||||
domain: "SandboxPlugin", code: -999,
|
||||
userInfo: [NSLocalizedDescriptionKey: "Sandbox destroyed"]
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@
|
||||
"dev": "npm i --silent && vite",
|
||||
"build": "npm i --silent && vite build -l error && cp dist/index.html dist/404.html && echo 'Project built successfully!'",
|
||||
"test": "npm i --silent && tsc --noEmit && eslint && vitest run --reporter=dot --silent && vite build -l error && cp dist/index.html dist/404.html && echo 'All tests passed!'",
|
||||
"cap:sync": "npx cap sync && node scripts/patch-cap-config.mjs",
|
||||
"keygen": "keytool -genkey -v -keystore android/app/my-upload-key.keystore -keyalg RSA -keysize 2048 -validity 10000 -alias upload",
|
||||
"icons": "bash scripts/generate-icons.sh"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Patch capacitor.config.json to include local (non-SPM) plugin classes.
|
||||
*
|
||||
* `npx cap sync` regenerates the `packageClassList` array from SPM packages
|
||||
* only, so local plugins compiled directly into the app binary (like
|
||||
* SandboxPlugin) are not included. This script appends them after sync so
|
||||
* the Capacitor bridge eagerly registers them at startup.
|
||||
*
|
||||
* Usage: node scripts/patch-cap-config.mjs
|
||||
* Typically run after `npx cap sync`.
|
||||
*/
|
||||
|
||||
import { readFileSync, writeFileSync } from 'fs';
|
||||
import { resolve } from 'path';
|
||||
|
||||
/** Local plugin class names to ensure are registered. */
|
||||
const LOCAL_PLUGINS = ['SandboxPlugin'];
|
||||
|
||||
const platforms = ['ios/App/App', 'android/app/src/main/assets'];
|
||||
|
||||
for (const platform of platforms) {
|
||||
const configPath = resolve(platform, 'capacitor.config.json');
|
||||
|
||||
let config;
|
||||
try {
|
||||
config = JSON.parse(readFileSync(configPath, 'utf-8'));
|
||||
} catch {
|
||||
// Platform may not exist or config not yet generated — skip.
|
||||
continue;
|
||||
}
|
||||
|
||||
const classList = new Set(config.packageClassList ?? []);
|
||||
let changed = false;
|
||||
|
||||
for (const plugin of LOCAL_PLUGINS) {
|
||||
if (!classList.has(plugin)) {
|
||||
classList.add(plugin);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (changed) {
|
||||
config.packageClassList = [...classList];
|
||||
writeFileSync(configPath, JSON.stringify(config, null, '\t') + '\n');
|
||||
console.log(`Patched ${configPath}: added ${LOCAL_PLUGINS.join(', ')}`);
|
||||
}
|
||||
}
|
||||
@@ -137,8 +137,8 @@ export function MobileBottomNav() {
|
||||
|
||||
</div>
|
||||
</div>
|
||||
{/* Safe area spacer — fully opaque so any subpixel gap is invisible */}
|
||||
<div className="safe-area-bottom bg-background" />
|
||||
{/* Safe area fill — matches the arc's semi-transparent background */}
|
||||
<div className="safe-area-bottom bg-background/85" />
|
||||
</nav>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -186,7 +186,7 @@ export function NsitePreviewDialog({ event, appName, appPicture, open, onOpenCha
|
||||
}}
|
||||
>
|
||||
{/* Nav bar */}
|
||||
<div className="h-11 flex items-center gap-2 px-3 border-b bg-muted/30 shrink-0">
|
||||
<div className="min-h-11 flex items-center gap-2 px-3 border-b bg-muted/30 shrink-0 safe-area-top">
|
||||
{/* App icon + name */}
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
{appPicture ? (
|
||||
|
||||
+417
-98
@@ -8,6 +8,8 @@ import {
|
||||
type IframeHTMLAttributes,
|
||||
} from 'react';
|
||||
|
||||
import { Capacitor } from '@capacitor/core';
|
||||
|
||||
import { useAppContext } from '@/hooks/useAppContext';
|
||||
import {
|
||||
bytesToBase64,
|
||||
@@ -20,6 +22,11 @@ import type {
|
||||
JsonRpcResponse,
|
||||
SerialisedRequest,
|
||||
} from '@/lib/sandbox';
|
||||
import {
|
||||
SandboxPlugin,
|
||||
type SandboxFetchEvent,
|
||||
type SandboxScriptMessageEvent,
|
||||
} from '@/lib/sandboxPlugin';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public types
|
||||
@@ -71,22 +78,98 @@ export interface SandboxFrameHandle {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component
|
||||
// Shared fetch/RPC handler logic
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Renders an iframe sandbox on a unique subdomain (`<id>.<sandboxDomain>`)
|
||||
* and implements the sandbox handshake + fetch proxy protocol.
|
||||
*
|
||||
* All file serving is delegated to the `resolveFile` callback.
|
||||
* Custom RPC methods are delegated to the optional `onRpc` callback.
|
||||
*
|
||||
* The sandbox domain is read from `AppConfig.sandboxDomain` (default:
|
||||
* `iframe.diy`). This is the single component that would be swapped out
|
||||
* for a native implementation on Capacitor builds.
|
||||
* Build a serialised HTTP response and call `respond` with it.
|
||||
* Shared between the web (postMessage) and native (respondToFetch) paths.
|
||||
*/
|
||||
export const SandboxFrame = forwardRef<SandboxFrameHandle, SandboxFrameProps>(
|
||||
function SandboxFrame(
|
||||
async function handleFetchRequest(
|
||||
pathname: string,
|
||||
resolveFile: (pathname: string) => Promise<FileResponse | null>,
|
||||
scripts: InjectedScript[],
|
||||
activeCsp: string | undefined,
|
||||
respond: (result: Record<string, unknown>) => void,
|
||||
respondError: (code: number, message: string) => void,
|
||||
): Promise<void> {
|
||||
// Check if the request is for a virtual injected script.
|
||||
const virtualScript = scripts.find(
|
||||
(s) => pathname === `/${s.path}` || pathname === s.path,
|
||||
);
|
||||
if (virtualScript) {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/javascript',
|
||||
'Cache-Control': 'no-cache',
|
||||
};
|
||||
if (activeCsp) headers['Content-Security-Policy'] = activeCsp;
|
||||
|
||||
respond({
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
headers,
|
||||
body: utf8ToBase64(virtualScript.content),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Delegate to the consumer's file resolver.
|
||||
try {
|
||||
const file = await resolveFile(pathname);
|
||||
|
||||
if (!file) {
|
||||
const headers: Record<string, string> = { 'Content-Type': 'text/plain' };
|
||||
if (activeCsp) headers['Content-Security-Policy'] = activeCsp;
|
||||
|
||||
respond({
|
||||
status: 404,
|
||||
statusText: 'Not Found',
|
||||
headers,
|
||||
body: utf8ToBase64('Not Found'),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// For HTML responses, inject script tags.
|
||||
let bodyBase64: string;
|
||||
if (file.contentType === 'text/html' && scripts.length > 0) {
|
||||
const html = new TextDecoder().decode(file.body);
|
||||
const injected = injectScriptTags(
|
||||
html,
|
||||
scripts.map((s) => `/${s.path}`),
|
||||
);
|
||||
bodyBase64 = utf8ToBase64(injected);
|
||||
} else {
|
||||
bodyBase64 = bytesToBase64(file.body);
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': file.contentType,
|
||||
'Cache-Control': 'no-cache',
|
||||
};
|
||||
if (activeCsp) headers['Content-Security-Policy'] = activeCsp;
|
||||
// Include Content-Length for non-HTML (binary) responses.
|
||||
if (file.contentType !== 'text/html') {
|
||||
headers['Content-Length'] = String(file.body.byteLength);
|
||||
}
|
||||
|
||||
respond({
|
||||
status: file.status,
|
||||
statusText: 'OK',
|
||||
headers,
|
||||
body: bodyBase64,
|
||||
});
|
||||
} catch (err) {
|
||||
respondError(-32002, String(err));
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Web (iframe.diy) implementation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const SandboxFrameWeb = forwardRef<SandboxFrameHandle, SandboxFrameProps>(
|
||||
function SandboxFrameWeb(
|
||||
{ id, resolveFile, onRpc, injectedScripts, csp, onReady, ...iframeProps },
|
||||
ref,
|
||||
) {
|
||||
@@ -145,7 +228,7 @@ export const SandboxFrame = forwardRef<SandboxFrameHandle, SandboxFrameProps>(
|
||||
const msg = event.data;
|
||||
if (!msg || typeof msg !== 'object' || msg.jsonrpc !== '2.0') return;
|
||||
|
||||
// Notification: ready → await onReady, then respond with init
|
||||
// Notification: ready -> await onReady, then respond with init
|
||||
if (msg.method === 'ready' && msg.id === undefined) {
|
||||
handleReady();
|
||||
return;
|
||||
@@ -178,7 +261,10 @@ export const SandboxFrame = forwardRef<SandboxFrameHandle, SandboxFrameProps>(
|
||||
// Fetch handler
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
async function handleFetch(id: string | number, params: { request?: SerialisedRequest }) {
|
||||
async function handleFetch(
|
||||
id: string | number,
|
||||
params: { request?: SerialisedRequest },
|
||||
) {
|
||||
const reqUrl = params?.request?.url;
|
||||
if (!reqUrl) {
|
||||
post({ jsonrpc: '2.0', id, error: { code: -32001, message: 'Invalid request' } });
|
||||
@@ -199,96 +285,25 @@ export const SandboxFrame = forwardRef<SandboxFrameHandle, SandboxFrameProps>(
|
||||
return;
|
||||
}
|
||||
|
||||
const scripts = injectedScriptsRef.current ?? [];
|
||||
const activeCsp = cspRef.current;
|
||||
|
||||
// Check if the request is for a virtual injected script.
|
||||
const virtualScript = scripts.find((s) => pathname === `/${s.path}` || pathname === s.path);
|
||||
if (virtualScript) {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/javascript',
|
||||
'Cache-Control': 'no-cache',
|
||||
};
|
||||
if (activeCsp) headers['Content-Security-Policy'] = activeCsp;
|
||||
|
||||
post({
|
||||
jsonrpc: '2.0',
|
||||
id,
|
||||
result: {
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
headers,
|
||||
body: utf8ToBase64(virtualScript.content),
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Delegate to the consumer's file resolver.
|
||||
try {
|
||||
const file = await resolveFileRef.current(pathname);
|
||||
|
||||
if (!file) {
|
||||
const headers: Record<string, string> = { 'Content-Type': 'text/plain' };
|
||||
if (activeCsp) headers['Content-Security-Policy'] = activeCsp;
|
||||
|
||||
post({
|
||||
jsonrpc: '2.0',
|
||||
id,
|
||||
result: {
|
||||
status: 404,
|
||||
statusText: 'Not Found',
|
||||
headers,
|
||||
body: utf8ToBase64('Not Found'),
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// For HTML responses, inject script tags.
|
||||
let bodyBase64: string;
|
||||
if (file.contentType === 'text/html' && scripts.length > 0) {
|
||||
const html = new TextDecoder().decode(file.body);
|
||||
const injected = injectScriptTags(html, scripts.map((s) => `/${s.path}`));
|
||||
bodyBase64 = utf8ToBase64(injected);
|
||||
} else {
|
||||
bodyBase64 = bytesToBase64(file.body);
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': file.contentType,
|
||||
'Cache-Control': 'no-cache',
|
||||
};
|
||||
if (activeCsp) headers['Content-Security-Policy'] = activeCsp;
|
||||
// Include Content-Length for non-HTML (binary) responses.
|
||||
if (file.contentType !== 'text/html') {
|
||||
headers['Content-Length'] = String(file.body.byteLength);
|
||||
}
|
||||
|
||||
post({
|
||||
jsonrpc: '2.0',
|
||||
id,
|
||||
result: {
|
||||
status: file.status,
|
||||
statusText: 'OK',
|
||||
headers,
|
||||
body: bodyBase64,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
post({
|
||||
jsonrpc: '2.0',
|
||||
id,
|
||||
error: { code: -32002, message: String(err) },
|
||||
});
|
||||
}
|
||||
await handleFetchRequest(
|
||||
pathname,
|
||||
resolveFileRef.current,
|
||||
injectedScriptsRef.current ?? [],
|
||||
cspRef.current,
|
||||
(result) => post({ jsonrpc: '2.0', id, result }),
|
||||
(code, message) => post({ jsonrpc: '2.0', id, error: { code, message } }),
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Custom RPC handler
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
async function handleRpc(id: string | number, method: string, params: unknown) {
|
||||
async function handleRpc(
|
||||
id: string | number,
|
||||
method: string,
|
||||
params: unknown,
|
||||
) {
|
||||
try {
|
||||
const result = await onRpcRef.current!(method, params, post);
|
||||
post({ jsonrpc: '2.0', id, result: result ?? null } satisfies JsonRpcResponse);
|
||||
@@ -315,4 +330,308 @@ export const SandboxFrame = forwardRef<SandboxFrameHandle, SandboxFrameProps>(
|
||||
},
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Native (Capacitor) implementation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const SandboxFrameNative = forwardRef<SandboxFrameHandle, SandboxFrameProps>(
|
||||
function SandboxFrameNative(
|
||||
{ id, resolveFile, onRpc, injectedScripts, csp, onReady, className, style, title },
|
||||
ref,
|
||||
) {
|
||||
const placeholderRef = useRef<HTMLDivElement>(null);
|
||||
const createdRef = useRef(false);
|
||||
const destroyedRef = useRef(false);
|
||||
|
||||
// Keep latest callbacks in refs.
|
||||
const resolveFileRef = useRef(resolveFile);
|
||||
const onRpcRef = useRef(onRpc);
|
||||
const injectedScriptsRef = useRef(injectedScripts);
|
||||
const cspRef = useRef(csp);
|
||||
const onReadyRef = useRef(onReady);
|
||||
|
||||
useEffect(() => { resolveFileRef.current = resolveFile; }, [resolveFile]);
|
||||
useEffect(() => { onRpcRef.current = onRpc; }, [onRpc]);
|
||||
useEffect(() => { injectedScriptsRef.current = injectedScripts; }, [injectedScripts]);
|
||||
useEffect(() => { cspRef.current = csp; }, [csp]);
|
||||
useEffect(() => { onReadyRef.current = onReady; }, [onReady]);
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// Post a message into the native sandbox
|
||||
// -----------------------------------------------------------------
|
||||
|
||||
const postToSandbox = useCallback(
|
||||
(msg: Record<string, unknown>) => {
|
||||
if (!createdRef.current || destroyedRef.current) return;
|
||||
SandboxPlugin.postMessage({ id, message: msg }).catch((err) => {
|
||||
console.error('[SandboxFrame] postMessage failed:', err);
|
||||
});
|
||||
},
|
||||
[id],
|
||||
);
|
||||
|
||||
// Expose imperative handle.
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
postMessage: (msg: Record<string, unknown>) => {
|
||||
postToSandbox(msg);
|
||||
},
|
||||
focus: () => {
|
||||
// No-op on native — the WebView is overlaid, not an iframe.
|
||||
},
|
||||
}),
|
||||
[postToSandbox],
|
||||
);
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// Lifecycle: onReady -> create WebView -> listen for events -> destroy
|
||||
// -----------------------------------------------------------------
|
||||
|
||||
useEffect(() => {
|
||||
if (createdRef.current) return;
|
||||
|
||||
const listeners: Array<{ remove: () => void }> = [];
|
||||
let cancelled = false;
|
||||
|
||||
async function setup() {
|
||||
// Run onReady first so the consumer can prepare (e.g. download and
|
||||
// unzip a .xdc archive) before the native WebView starts loading
|
||||
// resources. This mirrors the web behaviour where onReady runs
|
||||
// before `init` is sent.
|
||||
try {
|
||||
await onReadyRef.current?.();
|
||||
} catch (err) {
|
||||
console.error('[SandboxFrame] onReady failed:', err);
|
||||
}
|
||||
|
||||
if (cancelled || destroyedRef.current) return;
|
||||
|
||||
// Measure the placeholder position.
|
||||
const el = placeholderRef.current;
|
||||
if (!el) return;
|
||||
|
||||
const rect = el.getBoundingClientRect();
|
||||
|
||||
// Create the native WebView.
|
||||
await SandboxPlugin.create({
|
||||
id,
|
||||
frame: {
|
||||
x: Math.round(rect.left),
|
||||
y: Math.round(rect.top),
|
||||
width: Math.round(rect.width),
|
||||
height: Math.round(rect.height),
|
||||
},
|
||||
});
|
||||
|
||||
if (cancelled || destroyedRef.current) {
|
||||
// Component unmounted while we were awaiting — clean up immediately.
|
||||
SandboxPlugin.destroy({ id }).catch(() => {});
|
||||
return;
|
||||
}
|
||||
|
||||
createdRef.current = true;
|
||||
|
||||
// Listen for fetch requests from the native URL scheme handler.
|
||||
const fetchListener = await SandboxPlugin.addListener(
|
||||
'fetch',
|
||||
(event: SandboxFetchEvent) => {
|
||||
if (event.id !== id) return;
|
||||
handleNativeFetch(event);
|
||||
},
|
||||
);
|
||||
listeners.push(fetchListener);
|
||||
|
||||
// Listen for script messages (custom JSON-RPC from injected scripts).
|
||||
const scriptListener = await SandboxPlugin.addListener(
|
||||
'scriptMessage',
|
||||
(event: SandboxScriptMessageEvent) => {
|
||||
if (event.id !== id) return;
|
||||
handleNativeScriptMessage(event);
|
||||
},
|
||||
);
|
||||
listeners.push(scriptListener);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Handle a fetch request from the native WebView
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
async function handleNativeFetch(event: SandboxFetchEvent) {
|
||||
const reqUrl = event.request.url;
|
||||
|
||||
let pathname: string;
|
||||
try {
|
||||
pathname = new URL(reqUrl).pathname;
|
||||
} catch {
|
||||
// The native handler rewrites custom-scheme URLs to
|
||||
// https://<id>.sandbox.native/<path> so we can parse them.
|
||||
// If that fails, try extracting the path directly.
|
||||
const pathMatch = reqUrl.match(/\/\/[^/]+(\/.*)/);
|
||||
pathname = pathMatch?.[1] ?? '/';
|
||||
}
|
||||
|
||||
await handleFetchRequest(
|
||||
pathname,
|
||||
resolveFileRef.current,
|
||||
injectedScriptsRef.current ?? [],
|
||||
cspRef.current,
|
||||
(result) => {
|
||||
SandboxPlugin.respondToFetch({
|
||||
id,
|
||||
requestId: event.requestId,
|
||||
response: result as {
|
||||
status: number;
|
||||
statusText: string;
|
||||
headers: Record<string, string>;
|
||||
body: string | null;
|
||||
},
|
||||
}).catch((err) => {
|
||||
console.error('[SandboxFrame] respondToFetch failed:', err);
|
||||
});
|
||||
},
|
||||
(_code, message) => {
|
||||
SandboxPlugin.respondToFetch({
|
||||
id,
|
||||
requestId: event.requestId,
|
||||
response: {
|
||||
status: 500,
|
||||
statusText: 'Internal Error',
|
||||
headers: { 'Content-Type': 'text/plain' },
|
||||
body: btoa(message),
|
||||
},
|
||||
}).catch((err) => {
|
||||
console.error('[SandboxFrame] respondToFetch error failed:', err);
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Handle a script message from the native WebView
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
async function handleNativeScriptMessage(event: SandboxScriptMessageEvent) {
|
||||
const msg = event.message;
|
||||
if (!msg || typeof msg !== 'object') return;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const rpc = msg as any;
|
||||
if (rpc.jsonrpc !== '2.0') return;
|
||||
|
||||
// Handle RPC requests (have both `id` and `method`).
|
||||
if (rpc.id !== undefined && rpc.method && onRpcRef.current) {
|
||||
try {
|
||||
const result = await onRpcRef.current(
|
||||
rpc.method,
|
||||
rpc.params ?? {},
|
||||
postToSandbox,
|
||||
);
|
||||
postToSandbox({
|
||||
jsonrpc: '2.0',
|
||||
id: rpc.id,
|
||||
result: result ?? null,
|
||||
});
|
||||
} catch (err) {
|
||||
postToSandbox({
|
||||
jsonrpc: '2.0',
|
||||
id: rpc.id,
|
||||
error: { code: -1, message: String(err) },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setup().catch((err) => {
|
||||
console.error('[SandboxFrame] native setup failed:', err);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
destroyedRef.current = true;
|
||||
for (const listener of listeners) {
|
||||
listener.remove();
|
||||
}
|
||||
if (createdRef.current) {
|
||||
SandboxPlugin.destroy({ id }).catch((err) => {
|
||||
console.error('[SandboxFrame] destroy failed:', err);
|
||||
});
|
||||
createdRef.current = false;
|
||||
}
|
||||
};
|
||||
}, [id, postToSandbox]);
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// Keep frame in sync with placeholder size/position
|
||||
// -----------------------------------------------------------------
|
||||
|
||||
useEffect(() => {
|
||||
const el = placeholderRef.current;
|
||||
if (!el) return;
|
||||
|
||||
function updateFrame() {
|
||||
if (!createdRef.current || destroyedRef.current) return;
|
||||
const rect = el!.getBoundingClientRect();
|
||||
SandboxPlugin.updateFrame({
|
||||
id,
|
||||
frame: {
|
||||
x: Math.round(rect.left),
|
||||
y: Math.round(rect.top),
|
||||
width: Math.round(rect.width),
|
||||
height: Math.round(rect.height),
|
||||
},
|
||||
}).catch(() => {
|
||||
// Ignore — WebView may not be created yet.
|
||||
});
|
||||
}
|
||||
|
||||
const ro = new ResizeObserver(updateFrame);
|
||||
ro.observe(el);
|
||||
window.addEventListener('scroll', updateFrame, { passive: true });
|
||||
|
||||
return () => {
|
||||
ro.disconnect();
|
||||
window.removeEventListener('scroll', updateFrame);
|
||||
};
|
||||
}, [id]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={placeholderRef}
|
||||
className={className}
|
||||
style={style}
|
||||
title={title}
|
||||
data-sandbox-id={id}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public component — delegates to web or native implementation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Renders a sandboxed content frame.
|
||||
*
|
||||
* On web, this creates an iframe on a unique subdomain (`<id>.<sandboxDomain>`)
|
||||
* and implements the iframe.diy handshake + fetch proxy protocol.
|
||||
*
|
||||
* On native platforms (iOS/Android via Capacitor), this creates a native
|
||||
* WKWebView/WebView overlay with a custom URL scheme handler that intercepts
|
||||
* all requests and routes them through the same `resolveFile` callback.
|
||||
*
|
||||
* All file serving is delegated to the `resolveFile` callback.
|
||||
* Custom RPC methods are delegated to the optional `onRpc` callback.
|
||||
* Consumers (Webxdc, NsitePreviewDialog) are platform-agnostic.
|
||||
*/
|
||||
export const SandboxFrame = forwardRef<SandboxFrameHandle, SandboxFrameProps>(
|
||||
function SandboxFrame(props, ref) {
|
||||
if (Capacitor.isNativePlatform()) {
|
||||
return <SandboxFrameNative ref={ref} {...props} />;
|
||||
}
|
||||
return <SandboxFrameWeb ref={ref} {...props} />;
|
||||
},
|
||||
);
|
||||
|
||||
export default SandboxFrame;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState, useRef, useCallback, forwardRef } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { Blocks, Play, Maximize2, Minimize2, RotateCcw, X, Gamepad2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger, TooltipProvider } from '@/components/ui/tooltip';
|
||||
@@ -94,7 +95,7 @@ export function WebxdcEmbed({ url, uuid, name, icon, className }: WebxdcEmbedPro
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
const content = (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={cn(
|
||||
@@ -108,7 +109,7 @@ export function WebxdcEmbed({ url, uuid, name, icon, className }: WebxdcEmbedPro
|
||||
{/* Controls bar */}
|
||||
<div className={cn(
|
||||
'flex items-center justify-between px-3 py-1.5 bg-muted/60 border-b border-border',
|
||||
isFullscreen ? '' : 'rounded-t-2xl',
|
||||
isFullscreen ? 'safe-area-top' : 'rounded-t-2xl',
|
||||
)}>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
{icon ? (
|
||||
@@ -225,6 +226,10 @@ export function WebxdcEmbed({ url, uuid, name, icon, className }: WebxdcEmbedPro
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
// Portal fullscreen out of any parent stacking context (e.g. z-0 center column)
|
||||
// so it reliably sits above the mobile top bar and other fixed UI.
|
||||
return isFullscreen ? createPortal(content, document.body) : content;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* SandboxPlugin — Capacitor plugin for native sandboxed WebViews.
|
||||
*
|
||||
* On iOS, each sandbox gets a WKWebView with a custom URL scheme handler
|
||||
* (`sbx-<id>://`) that intercepts all resource requests and forwards them
|
||||
* to the JS layer. On Android, the same is achieved via
|
||||
* `shouldInterceptRequest`. This replaces iframe.diy on native platforms.
|
||||
*
|
||||
* The plugin is registered as "SandboxPlugin" and is only usable on native
|
||||
* platforms. On web, SandboxFrame uses iframe.diy directly.
|
||||
*/
|
||||
|
||||
import { registerPlugin } from '@capacitor/core';
|
||||
import type { PluginListenerHandle } from '@capacitor/core';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Plugin method options
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Options for creating a new sandbox WebView. */
|
||||
export interface SandboxCreateOptions {
|
||||
/** Unique identifier for this sandbox (the HMAC-derived subdomain ID). */
|
||||
id: string;
|
||||
/** Absolute position and size of the WebView within the app window. */
|
||||
frame: { x: number; y: number; width: number; height: number };
|
||||
}
|
||||
|
||||
/** Options for updating the WebView frame (position/size). */
|
||||
export interface SandboxUpdateFrameOptions {
|
||||
id: string;
|
||||
frame: { x: number; y: number; width: number; height: number };
|
||||
}
|
||||
|
||||
/** A serialised fetch response sent back to the native WebView. */
|
||||
export interface SandboxRespondToFetchOptions {
|
||||
id: string;
|
||||
requestId: string;
|
||||
response: {
|
||||
status: number;
|
||||
statusText: string;
|
||||
headers: Record<string, string>;
|
||||
body: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
/** Options for posting a message into the sandbox (to injected scripts). */
|
||||
export interface SandboxPostMessageOptions {
|
||||
id: string;
|
||||
message: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** Options for destroying a sandbox. */
|
||||
export interface SandboxDestroyOptions {
|
||||
id: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Plugin event payloads
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** A fetch request forwarded from the native WebView's URL scheme handler. */
|
||||
export interface SandboxFetchEvent {
|
||||
/** The sandbox ID this request belongs to. */
|
||||
id: string;
|
||||
/** Unique request ID — pass back to `respondToFetch`. */
|
||||
requestId: string;
|
||||
/** The serialised HTTP request. */
|
||||
request: {
|
||||
url: string;
|
||||
method: string;
|
||||
headers: Record<string, string>;
|
||||
body: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
/** A JSON-RPC message from an injected script inside the sandbox. */
|
||||
export interface SandboxScriptMessageEvent {
|
||||
/** The sandbox ID this message came from. */
|
||||
id: string;
|
||||
/** The JSON-RPC message body. */
|
||||
message: Record<string, unknown>;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Plugin interface
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface SandboxPluginInterface {
|
||||
/** Create a new sandbox WebView with a unique custom URL scheme. */
|
||||
create(options: SandboxCreateOptions): Promise<void>;
|
||||
|
||||
/** Update the position/size of an existing sandbox WebView. */
|
||||
updateFrame(options: SandboxUpdateFrameOptions): Promise<void>;
|
||||
|
||||
/** Send a fetch response back to the native WebView for a pending request. */
|
||||
respondToFetch(options: SandboxRespondToFetchOptions): Promise<void>;
|
||||
|
||||
/** Post a JSON-RPC message to injected scripts inside the sandbox. */
|
||||
postMessage(options: SandboxPostMessageOptions): Promise<void>;
|
||||
|
||||
/** Destroy a sandbox WebView and clean up all resources. */
|
||||
destroy(options: SandboxDestroyOptions): Promise<void>;
|
||||
|
||||
/** Listen for fetch requests from the native WebView. */
|
||||
addListener(
|
||||
eventName: 'fetch',
|
||||
handler: (event: SandboxFetchEvent) => void,
|
||||
): Promise<PluginListenerHandle>;
|
||||
|
||||
/** Listen for JSON-RPC messages from injected scripts inside the sandbox. */
|
||||
addListener(
|
||||
eventName: 'scriptMessage',
|
||||
handler: (event: SandboxScriptMessageEvent) => void,
|
||||
): Promise<PluginListenerHandle>;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Plugin registration
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* The SandboxPlugin Capacitor plugin.
|
||||
* Only usable on native platforms (iOS/Android). On web, SandboxFrame
|
||||
* falls back to the iframe.diy service worker sandbox.
|
||||
*/
|
||||
export const SandboxPlugin = registerPlugin<SandboxPluginInterface>('SandboxPlugin');
|
||||
Reference in New Issue
Block a user