This commit is contained in:
parent
62cb78167d
commit
bcf01631ab
|
|
@ -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();
|
||||||
|
|
@ -1,5 +1,28 @@
|
||||||
|
/*
|
||||||
|
* 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 () {
|
(function () {
|
||||||
var code = [
|
"use strict";
|
||||||
|
|
||||||
|
var PAGE_SCRIPT = [
|
||||||
'(function() {',
|
'(function() {',
|
||||||
' var _realAndroid = window.android;',
|
' var _realAndroid = window.android;',
|
||||||
' window.android = new Proxy({}, {',
|
' window.android = new Proxy({}, {',
|
||||||
|
|
@ -17,18 +40,110 @@
|
||||||
' try { window.android[k] = _realAndroid[k]; } catch(e) {}',
|
' 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');
|
function injectPageScript(code) {
|
||||||
|
try {
|
||||||
|
var script = document.createElement("script");
|
||||||
script.textContent = code;
|
script.textContent = code;
|
||||||
var parent = document.head || document.documentElement;
|
var parent = document.head || document.documentElement;
|
||||||
if (parent) {
|
if (parent) {
|
||||||
parent.appendChild(script);
|
parent.appendChild(script);
|
||||||
script.remove();
|
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();
|
||||||
})();
|
})();
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,15 @@
|
||||||
"version": "1.0",
|
"version": "1.0",
|
||||||
"type": "extension",
|
"type": "extension",
|
||||||
"description": "JS-Native bridge for StandAPP",
|
"description": "JS-Native bridge for StandAPP",
|
||||||
|
"permissions": [
|
||||||
|
"nativeMessaging",
|
||||||
|
"tabs",
|
||||||
|
"<all_urls>"
|
||||||
|
],
|
||||||
|
"background": {
|
||||||
|
"scripts": ["background.js"],
|
||||||
|
"persistent": true
|
||||||
|
},
|
||||||
"content_scripts": [{
|
"content_scripts": [{
|
||||||
"matches": ["<all_urls>"],
|
"matches": ["<all_urls>"],
|
||||||
"js": ["bridge.js"],
|
"js": ["bridge.js"],
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -36,8 +36,11 @@ class MainActivity : AppCompatActivity() {
|
||||||
@JvmStatic
|
@JvmStatic
|
||||||
fun executeJs(script: String) {
|
fun executeJs(script: String) {
|
||||||
Timber.tag("ExecuteJs").d(script)
|
Timber.tag("ExecuteJs").d(script)
|
||||||
|
val pushed = instance?.bridgePortDelegate?.postScript(script) == true
|
||||||
|
if (pushed) {
|
||||||
|
return
|
||||||
|
}
|
||||||
synchronized(jsLock) {
|
synchronized(jsLock) {
|
||||||
// 合并多次调用(用换行分隔)
|
|
||||||
pendingJsCode = if (pendingJsCode != null) "$pendingJsCode;$script" else script
|
pendingJsCode = if (pendingJsCode != null) "$pendingJsCode;$script" else script
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -132,6 +135,7 @@ class MainActivity : AppCompatActivity() {
|
||||||
private var geckoSession: GeckoSession? = null
|
private var geckoSession: GeckoSession? = null
|
||||||
private var geckoRuntime: GeckoRuntime? = null
|
private var geckoRuntime: GeckoRuntime? = null
|
||||||
private var pttInterface: AndroidInterface? = null
|
private var pttInterface: AndroidInterface? = null
|
||||||
|
private val bridgePortDelegate = BridgePortDelegate()
|
||||||
private var canGoBack = false
|
private var canGoBack = false
|
||||||
private lateinit var geckoView: GeckoView
|
private lateinit var geckoView: GeckoView
|
||||||
|
|
||||||
|
|
@ -185,6 +189,10 @@ class MainActivity : AppCompatActivity() {
|
||||||
)?.accept { extension ->
|
)?.accept { extension ->
|
||||||
Timber.tag("MainActivity").d("WebExtension installed: ${extension?.metaData?.name}")
|
Timber.tag("MainActivity").d("WebExtension installed: ${extension?.metaData?.name}")
|
||||||
runOnUiThread {
|
runOnUiThread {
|
||||||
|
if (extension != null) {
|
||||||
|
extension.setMessageDelegate(bridgePortDelegate, "standapp")
|
||||||
|
Timber.tag("MainActivity").d("Bridge MessageDelegate registered")
|
||||||
|
}
|
||||||
val serverUrl = AppConfig.getServerUrl(this)
|
val serverUrl = AppConfig.getServerUrl(this)
|
||||||
val finalUrl = if (officialMode) {
|
val finalUrl = if (officialMode) {
|
||||||
"$serverUrl/sysdispatch/home?t=${System.currentTimeMillis()}"
|
"$serverUrl/sysdispatch/home?t=${System.currentTimeMillis()}"
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue