This commit is contained in:
fengge 2026-06-02 18:12:24 +08:00
parent 62cb78167d
commit bcf01631ab
5 changed files with 321 additions and 14 deletions

View File

@ -0,0 +1,94 @@
/*
* StandAPP Bridge - background script
*
* Responsibilities:
* 1. Connect to native via browser.runtime.connectNative("standapp").
* 2. Forward executeJs messages from native to every connected content port.
* 3. Accept content-script connections, and signal "ready" when both the
* native link and the content port are up. Content script uses this
* ready flag to decide whether to enable the WebExtension push channel
* or fall back to the legacy prompt() polling bridge.
*/
"use strict";
const NATIVE_APP = "standapp";
const CONTENT_PORT_NAME = "bridge";
const RECONNECT_DELAYS_MS = [1000, 2000, 4000, 8000, 16000, 30000];
let nativePort = null;
let reconnectAttempt = 0;
let contentPorts = new Set();
function nextReconnectDelay() {
const idx = Math.min(reconnectAttempt, RECONNECT_DELAYS_MS.length - 1);
const delay = RECONNECT_DELAYS_MS[idx];
reconnectAttempt++;
return delay;
}
function broadcastReadyToContent() {
const ready = nativePort !== null;
const msg = { type: "bridgeStatus", ready: ready };
contentPorts.forEach(function (p) {
try { p.postMessage(msg); } catch (e) {}
});
}
function handleNativeMessage(msg) {
if (!msg || typeof msg !== "object") return;
if (msg.type !== "executeJs") return;
if (typeof msg.script !== "string" || msg.script.length === 0) return;
contentPorts.forEach(function (p) {
try { p.postMessage({ type: "executeJs", script: msg.script }); }
catch (e) {}
});
}
function connectNative() {
try {
nativePort = browser.runtime.connectNative(NATIVE_APP);
} catch (e) {
console.log("[bridge-bg] connectNative threw", String(e));
nativePort = null;
scheduleReconnect();
broadcastReadyToContent();
return;
}
reconnectAttempt = 0;
console.log("[bridge-bg] native port connected");
nativePort.onMessage.addListener(handleNativeMessage);
nativePort.onDisconnect.addListener(function () {
console.log("[bridge-bg] native port disconnected");
nativePort = null;
broadcastReadyToContent();
scheduleReconnect();
});
broadcastReadyToContent();
}
function scheduleReconnect() {
const delay = nextReconnectDelay();
setTimeout(function () {
if (nativePort === null) connectNative();
}, delay);
}
browser.runtime.onConnect.addListener(function (port) {
if (port.name !== CONTENT_PORT_NAME) return;
contentPorts.add(port);
console.log("[bridge-bg] content port added, total =", contentPorts.size);
port.onDisconnect.addListener(function () {
contentPorts.delete(port);
console.log("[bridge-bg] content port removed, total =", contentPorts.size);
});
port.postMessage({ type: "bridgeStatus", ready: nativePort !== null });
});
connectNative();

View File

@ -1,5 +1,28 @@
(function() {
var code = [
/*
* StandAPP Bridge - content script
*
* Runs in the content-script isolated world, with manifest content_scripts
* settings { matches: "<all_urls>", run_at: "document_start" }.
*
* Two responsibilities:
* 1. Inject a page-context script that builds the window.android Proxy.
* The Proxy still routes every method call through prompt("bridge:...")
* so the page's synchronous calls (e.g. JSON.parse(window.android.xxx()))
* keep working without any front-end change.
* 2. Subscribe to a Port from background.js for native->page pushes.
* The push channel is the WebExtension Port; received scripts are
* injected into the page DOM (same <script> trick) so the script runs
* in the page context and can call page globals like updatePttTalkStatus.
*
* Fallback: if no bridgeStatus{ready:true} arrives within 1.5s, the legacy
* prompt("bridge:_poll") polling is enabled. As soon as the Port reports
* ready, the polling is cleared.
*/
(function () {
"use strict";
var PAGE_SCRIPT = [
'(function() {',
' var _realAndroid = window.android;',
' window.android = new Proxy({}, {',
@ -17,18 +40,110 @@
' try { window.android[k] = _realAndroid[k]; } catch(e) {}',
' });',
' }',
' setInterval(function() {',
' var code = prompt("bridge:_poll", "");',
' if (code) { try { eval(code); } catch(e) {} }',
' }, 2000);',
'})();'
].join('\n');
].join("\n");
var script = document.createElement('script');
script.textContent = code;
var parent = document.head || document.documentElement;
if (parent) {
parent.appendChild(script);
script.remove();
function injectPageScript(code) {
try {
var script = document.createElement("script");
script.textContent = code;
var parent = document.head || document.documentElement;
if (parent) {
parent.appendChild(script);
script.parentNode && script.parentNode.removeChild(script);
}
} catch (e) {
console.log("[bridge-cs] inject failed", String(e));
}
}
injectPageScript(PAGE_SCRIPT);
var nativeChannelReady = false;
var pollTimer = null;
var bgPort = null;
var readyTimeout = null;
var lastPollScript = "";
function startFallbackPoll() {
if (pollTimer !== null) return;
console.log("[bridge-cs] fallback poll enabled");
pollTimer = setInterval(function () {
try {
var code = prompt("bridge:_poll", "");
if (code && code.length > 0 && code !== lastPollScript) {
lastPollScript = code;
injectPageScript(code);
}
} catch (e) {}
}, 2000);
}
function stopFallbackPoll() {
if (pollTimer === null) return;
clearInterval(pollTimer);
pollTimer = null;
console.log("[bridge-cs] fallback poll stopped");
}
function clearReadyTimeout() {
if (readyTimeout !== null) {
clearTimeout(readyTimeout);
readyTimeout = null;
}
}
function connectBackground() {
try {
if (!browser || !browser.runtime || !browser.runtime.connect) {
throw new Error("runtime.connect unavailable");
}
bgPort = browser.runtime.connect({ name: "bridge" });
} catch (e) {
console.log("[bridge-cs] connect failed", String(e));
bgPort = null;
startFallbackPoll();
return;
}
bgPort.onMessage.addListener(function (msg) {
if (!msg || typeof msg !== "object") return;
if (msg.type === "bridgeStatus") {
if (msg.ready) {
nativeChannelReady = true;
clearReadyTimeout();
stopFallbackPoll();
console.log("[bridge-cs] native channel ready");
} else {
nativeChannelReady = false;
startFallbackPoll();
console.log("[bridge-cs] native channel down");
}
} else if (msg.type === "executeJs" && typeof msg.script === "string") {
injectPageScript(msg.script);
}
});
bgPort.onDisconnect.addListener(function () {
console.log("[bridge-cs] bg port disconnected");
bgPort = null;
nativeChannelReady = false;
clearReadyTimeout();
startFallbackPoll();
setTimeout(connectBackground, 1000);
});
}
function armReadyTimeout() {
clearReadyTimeout();
readyTimeout = setTimeout(function () {
if (!nativeChannelReady) {
console.log("[bridge-cs] no ready in 1500ms, starting fallback");
startFallbackPoll();
}
}, 1500);
}
armReadyTimeout();
connectBackground();
})();

View File

@ -9,6 +9,15 @@
"version": "1.0",
"type": "extension",
"description": "JS-Native bridge for StandAPP",
"permissions": [
"nativeMessaging",
"tabs",
"<all_urls>"
],
"background": {
"scripts": ["background.js"],
"persistent": true
},
"content_scripts": [{
"matches": ["<all_urls>"],
"js": ["bridge.js"],

View File

@ -0,0 +1,81 @@
package com.stand.standapp
import org.json.JSONObject
import org.mozilla.geckoview.WebExtension
import timber.log.Timber
/**
* GeckoView WebExtension MessageDelegate for the StandAPP bridge.
*
* Acts as the native side of the browser.runtime.connectNative("standapp")
* port opened by the extension's background script. The extension forwards
* {type:"executeJs", script:"..."} messages emitted from background to every
* connected content script.
*
* Usage from MainActivity:
* runtime.webExtensionController // not used directly; setMessageDelegate is
* invoked on the installed WebExtension instance.
*
* extension.setMessageDelegate(BridgePortDelegate(), "standapp")
*
* BridgePortDelegate.postScript("updatePttTalkStatus(true)")
*
* On the very first connect (background also connects to native for the
* first time), a short startup window exists during which the port is not
* yet attached. Callers can check isReady() or rely on the Boolean return
* value of postScript() to fall back to the legacy prompt() buffer.
*/
class BridgePortDelegate : WebExtension.MessageDelegate {
@Volatile
private var port: WebExtension.Port? = null
@Volatile
private var ready: Boolean = false
override fun onConnect(port: WebExtension.Port) {
Timber.tag(TAG).i("Native port connected: name=%s", port.name)
this.port = port
this.ready = true
port.setDelegate(object : WebExtension.PortDelegate {
override fun onPortMessage(message: Any, p: WebExtension.Port) {
Timber.tag(TAG).d("Received from extension: %s", message)
}
override fun onDisconnect(p: WebExtension.Port) {
Timber.tag(TAG).w("Native port disconnected: name=%s", p.name)
if (this@BridgePortDelegate.port === p) {
this@BridgePortDelegate.ready = false
this@BridgePortDelegate.port = null
}
}
})
}
fun isReady(): Boolean = ready && port != null
/**
* Push a script to every connected content script via the WebExtension
* Port. Returns true on success, false when the channel is not yet
* attached (callers should then fall back to the prompt() buffer).
*/
fun postScript(script: String): Boolean {
val p = port
if (p == null || !ready) return false
return try {
val payload = JSONObject().apply {
put("type", "executeJs")
put("script", script)
}
p.postMessage(payload)
true
} catch (e: Throwable) {
Timber.tag(TAG).e(e, "postScript failed")
false
}
}
companion object {
private const val TAG = "BridgePort"
}
}

View File

@ -36,8 +36,11 @@ class MainActivity : AppCompatActivity() {
@JvmStatic
fun executeJs(script: String) {
Timber.tag("ExecuteJs").d(script)
val pushed = instance?.bridgePortDelegate?.postScript(script) == true
if (pushed) {
return
}
synchronized(jsLock) {
// 合并多次调用(用换行分隔)
pendingJsCode = if (pendingJsCode != null) "$pendingJsCode;$script" else script
}
}
@ -132,6 +135,7 @@ class MainActivity : AppCompatActivity() {
private var geckoSession: GeckoSession? = null
private var geckoRuntime: GeckoRuntime? = null
private var pttInterface: AndroidInterface? = null
private val bridgePortDelegate = BridgePortDelegate()
private var canGoBack = false
private lateinit var geckoView: GeckoView
@ -185,6 +189,10 @@ class MainActivity : AppCompatActivity() {
)?.accept { extension ->
Timber.tag("MainActivity").d("WebExtension installed: ${extension?.metaData?.name}")
runOnUiThread {
if (extension != null) {
extension.setMessageDelegate(bridgePortDelegate, "standapp")
Timber.tag("MainActivity").d("Bridge MessageDelegate registered")
}
val serverUrl = AppConfig.getServerUrl(this)
val finalUrl = if (officialMode) {
"$serverUrl/sysdispatch/home?t=${System.currentTimeMillis()}"