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 @@
|
|||
(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();
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
|
|
|
|||
|
|
@ -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
|
||||
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()}"
|
||||
|
|
|
|||
Loading…
Reference in New Issue