|
|
@@ -1,7 +1,7 @@
|
|
|
"ui";
|
|
|
/*! tiktok-app v2026.10.13
|
|
|
- * built 2026-10-29T10:43:51.193Z
|
|
|
- * modules: theme, store, records, sources, device, service, fingerprint, engine, session, resume, widgets, dialog, floatball, page-id, page-dm, page-video, page-tasks, page-records, page-login, page-mine, main
|
|
|
+ * built 2026-11-01T06:48:56.695Z
|
|
|
+ * modules: theme, store, records, sources, device, service, fingerprint, protocol, engine, session, resume, widgets, dialog, floatball, page-id, page-dm, page-video, page-tasks, page-records, page-login, page-mine, main
|
|
|
* 由 tools/build.js 自动生成 —— 请不要直接编辑本文件,改 src/ 后重新构建。
|
|
|
*/
|
|
|
(function () {
|
|
|
@@ -2758,17 +2758,224 @@ var Fingerprint = (function () {
|
|
|
}());
|
|
|
|
|
|
|
|
|
+/* ============================== src/protocol.js ============================== */
|
|
|
+/* ===========================================================================
|
|
|
+ * protocol.js —— 界面 ↔ 引擎 的运行期协议(唯一事实来源)
|
|
|
+ *
|
|
|
+ * 这个文件解决的是"两边各写一套枚举、靠魔法数字猜死活"的老问题。
|
|
|
+ * 它只放**纯数据 + 纯判定**:不读文件、不写文件、不起线程、不看时间。
|
|
|
+ * 所以界面侧与引擎侧可以各存一份完整拷贝,行为完全一致。
|
|
|
+ *
|
|
|
+ * ★ 两份拷贝的纪律:`tiktok-app/src/protocol.js` 与
|
|
|
+ * `tiktok-auto/src/protocol.js` **除第 1 行(挂载点)外必须逐字节相同**。
|
|
|
+ * 界面侧是 `var Protocol = …`,引擎侧是 `TikTokAuto.protocol = …`。
|
|
|
+ * `tiktok-app/tools/protocol-check.js` 会剥掉第 1 行后逐字节比对,
|
|
|
+ * 任何一边单独改都会立刻被抓出来。
|
|
|
+ *
|
|
|
+ * ★ 为什么状态判定要放在这里而不是各自实现:
|
|
|
+ * `isTerminalStatus` 曾经只存在于界面(engine.js),引擎侧自己写
|
|
|
+ * `'finished' / 'stopped' / 'failed'` 三元推导(main.js)。两边漏改一个
|
|
|
+ * 就会出现"引擎以为结束了、界面还在等"这种零报错的悬挂。
|
|
|
+ * =========================================================================== */
|
|
|
+
|
|
|
+var Protocol = (function () {
|
|
|
+ 'use strict';
|
|
|
+
|
|
|
+ /** 协议版本。加字段不改语义不用抬;改语义 / 改判定规则必须抬。 */
|
|
|
+ var VERSION = 2;
|
|
|
+
|
|
|
+ /* ------------------------------------------------------------ 状态枚举 */
|
|
|
+
|
|
|
+ var STATUS = {
|
|
|
+ STARTING: 'starting',
|
|
|
+ RUNNING: 'running',
|
|
|
+ STOPPING: 'stopping',
|
|
|
+ FINISHED: 'finished',
|
|
|
+ STOPPED: 'stopped',
|
|
|
+ FAILED: 'failed'
|
|
|
+ };
|
|
|
+
|
|
|
+ /** 终态:到了这里就没有出边(再回到活跃态一律算 regress)。 */
|
|
|
+ var TERMINAL = { finished: true, stopped: true, failed: true };
|
|
|
+
|
|
|
+ /** 活跃态:运行条要显示、判活逻辑要关心的三档。 */
|
|
|
+ var ACTIVE = { starting: true, running: true, stopping: true };
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 合法迁移表:from → 允许去的 to。
|
|
|
+ *
|
|
|
+ * 空串代表"还没有状态"(本轮的第一次上报)。
|
|
|
+ * `starting` 的出边特别宽:配置无效会在启动瞬间直接 failed,
|
|
|
+ * 极短任务可能 starting 之后直接终态,这两种都是真实的。
|
|
|
+ */
|
|
|
+ var EDGES = {
|
|
|
+ '': { starting: true },
|
|
|
+ starting: {
|
|
|
+ starting: true,
|
|
|
+ running: true,
|
|
|
+ stopping: true,
|
|
|
+ finished: true,
|
|
|
+ stopped: true,
|
|
|
+ failed: true
|
|
|
+ },
|
|
|
+ running: {
|
|
|
+ running: true,
|
|
|
+ stopping: true,
|
|
|
+ finished: true,
|
|
|
+ stopped: true,
|
|
|
+ failed: true
|
|
|
+ },
|
|
|
+ stopping: {
|
|
|
+ stopping: true,
|
|
|
+ finished: true,
|
|
|
+ stopped: true,
|
|
|
+ failed: true
|
|
|
+ },
|
|
|
+ finished: {},
|
|
|
+ stopped: {},
|
|
|
+ failed: {}
|
|
|
+ };
|
|
|
+
|
|
|
+ /* ------------------------------------------------------------ 阈值 */
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 所有超时都在这里,界面侧从 `Protocol.SPEC.thresholds` 取,
|
|
|
+ * 引擎侧心跳间隔也从这里取 —— 不允许在业务代码里再写字面量。
|
|
|
+ *
|
|
|
+ * 语义补充(光看名字看不出来的):
|
|
|
+ * · beatFreshMs 只用于"加速判死"(见界面 pollEngine),**不产生文案**
|
|
|
+ * · startAckSoftMs 只是日志与文案降级(「启动中(较慢)」),不改状态
|
|
|
+ * · stopAckMs / stopFinishMs 是两段:先等"看见了停止请求",再等"真的收完尾"
|
|
|
+ * · legacy* 是无心跳(v1)引擎的回退值,就是改造前在用的数字
|
|
|
+ */
|
|
|
+ var THRESHOLDS = {
|
|
|
+ /* 心跳 */
|
|
|
+ beatIntervalMs: 2000,
|
|
|
+ beatFreshMs: 8000,
|
|
|
+ beatDeadMs: 15000,
|
|
|
+ /* 启动握手 */
|
|
|
+ startAckSoftMs: 6000,
|
|
|
+ startAckMaxMs: 15000,
|
|
|
+ /* 停止两段 */
|
|
|
+ stopAckMs: 3000,
|
|
|
+ stopFinishMs: 10000,
|
|
|
+ /* v1 回退组(老引擎 / 心跳线程起不来) */
|
|
|
+ legacyChildGraceMs: 4000,
|
|
|
+ legacyStopTimeoutMs: 8000,
|
|
|
+ legacyNoBeatDeadMs: 30000
|
|
|
+ };
|
|
|
+
|
|
|
+ var SPEC = {
|
|
|
+ version: VERSION,
|
|
|
+ status: STATUS,
|
|
|
+ terminal: TERMINAL,
|
|
|
+ active: ACTIVE,
|
|
|
+ edges: EDGES,
|
|
|
+ thresholds: THRESHOLDS
|
|
|
+ };
|
|
|
+
|
|
|
+ /* ------------------------------------------------------------ 工具 */
|
|
|
+
|
|
|
+ /* 单文件拼接后所有模块共享一个作用域,而 `Object.prototype.hasOwnProperty`
|
|
|
+ * 的 call 形式会命中引擎侧构建黑名单里的那种写法(连注释都会被扫到),
|
|
|
+ * 所以这里统一走 apply。 */
|
|
|
+ function owns(object, key) {
|
|
|
+ return Object.prototype.hasOwnProperty.apply(object, [key]) === true;
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 把任意输入变成字符串状态;null / undefined → 空串(= 还没有状态)。 */
|
|
|
+ function statusOf(value) {
|
|
|
+ if (value === undefined || value === null) {
|
|
|
+ return '';
|
|
|
+ }
|
|
|
+ return String(value);
|
|
|
+ }
|
|
|
+
|
|
|
+ /* ------------------------------------------------------------ 判定 */
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 是否终态。
|
|
|
+ * ★ 未知状态一律返回 false —— 界面绝不能因为读到一个不认识的 status
|
|
|
+ * 就把正在跑的运行结束掉(前向兼容第一条)。
|
|
|
+ */
|
|
|
+ function isTerminal(status) {
|
|
|
+ var key = statusOf(status);
|
|
|
+ return owns(TERMINAL, key) === true && TERMINAL[key] === true;
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 是否活跃态(运行条要显示的那三档)。未知状态同样返回 false。 */
|
|
|
+ function isActive(status) {
|
|
|
+ var key = statusOf(status);
|
|
|
+ return owns(ACTIVE, key) === true && ACTIVE[key] === true;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 三分类,给日志和排障用。
|
|
|
+ * @returns 'active' | 'terminal' | 'unknown'
|
|
|
+ */
|
|
|
+ function levelOf(status) {
|
|
|
+ var key = statusOf(status);
|
|
|
+ if (isTerminal(key)) {
|
|
|
+ return 'terminal';
|
|
|
+ }
|
|
|
+ if (isActive(key)) {
|
|
|
+ return 'active';
|
|
|
+ }
|
|
|
+ return 'unknown';
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 迁移合法性。
|
|
|
+ *
|
|
|
+ * @returns 'ok' 表里有的合法迁移
|
|
|
+ * 'idempotent' 原地重复上报(引擎允许重复写同一个状态)
|
|
|
+ * 'regress' 从终态往外走 —— **必须拒绝**
|
|
|
+ * 'unknown' 表里没有的组合,多半是前向兼容来的新状态,
|
|
|
+ * 调用方应当"放宽"(不据此结束运行),
|
|
|
+ * 只在日志里留一句
|
|
|
+ */
|
|
|
+ function legality(from, to) {
|
|
|
+ var a = statusOf(from);
|
|
|
+ var b = statusOf(to);
|
|
|
+ if (a === b) {
|
|
|
+ return 'idempotent';
|
|
|
+ }
|
|
|
+ if (isTerminal(a)) {
|
|
|
+ return 'regress';
|
|
|
+ }
|
|
|
+ if (owns(EDGES, a) === true && owns(EDGES[a], b) === true) {
|
|
|
+ return 'ok';
|
|
|
+ }
|
|
|
+ return 'unknown';
|
|
|
+ }
|
|
|
+
|
|
|
+ /* ------------------------------------------------------------ 导出 */
|
|
|
+
|
|
|
+ return {
|
|
|
+ VERSION: VERSION,
|
|
|
+ STATUS: STATUS,
|
|
|
+ SPEC: SPEC,
|
|
|
+ statusOf: statusOf,
|
|
|
+ isTerminal: isTerminal,
|
|
|
+ isActive: isActive,
|
|
|
+ levelOf: levelOf,
|
|
|
+ legality: legality
|
|
|
+ };
|
|
|
+})();
|
|
|
+
|
|
|
+
|
|
|
/* ============================== src/engine.js ============================== */
|
|
|
/* ============================== src/engine.js ==============================
|
|
|
* 执行引擎桥接:把界面收集的配置交给 tiktok-auto,再把它的进度读回来。
|
|
|
*
|
|
|
* ## 为什么走文件,不走内存
|
|
|
* 界面与被唤起的引擎是**两个独立的 ScriptEngine**:各有各的全局作用域,
|
|
|
- * 界面里的对象传不过去(实测确认)。所以两边只通过约定好的四个文件说话:
|
|
|
+ * 界面里的对象传不过去(实测确认)。所以两边只通过约定好的几个文件说话:
|
|
|
*
|
|
|
* run-config.json 界面 → 人/追溯:完整配置快照(人可读)
|
|
|
* engine-config.json 界面 → 引擎:映射后的引擎配置
|
|
|
* run-state.json 引擎 → 界面:进度与终态
|
|
|
+ * engine-beat.json 引擎 → 界面:心跳(每 2 秒一次,唯一写者是引擎的心跳线程)
|
|
|
* stop.flag 界面 → 引擎:停止请求(内容是本次运行的 runId)
|
|
|
*
|
|
|
* 文件通道是**异步**的,所以界面这边的规则是:**所有动作以状态文件的回报为准**,
|
|
|
@@ -2795,6 +3002,15 @@ Engine.CONFIG_PATH = Engine.DIR + "/engine-config.json";
|
|
|
Engine.STATE_PATH = Engine.DIR + "/run-state.json";
|
|
|
Engine.STOP_FLAG_PATH = Engine.DIR + "/stop.flag";
|
|
|
|
|
|
+/**
|
|
|
+ * 引擎心跳文件(引擎的心跳线程每 2 秒整份重写一次)。
|
|
|
+ *
|
|
|
+ * 为什么要和 run-state 分开:run-state 由引擎主流程按条目写,
|
|
|
+ * 心跳每 2 秒一次,两个写者写同一个文件必然互相覆盖。
|
|
|
+ * 有了它,界面才能区分「活着但正卡在一个 25 秒的 watch 里」和「已经死了」。
|
|
|
+ */
|
|
|
+Engine.BEAT_PATH = Engine.DIR + "/engine-beat.json";
|
|
|
+
|
|
|
/**
|
|
|
* 引擎的日志文件(jsonl,一行一条)。
|
|
|
*
|
|
|
@@ -2811,6 +3027,14 @@ Engine.LOG_MAX_LINES = 200;
|
|
|
/** 引擎产物在项目里的相对位置(项目模式下从这个位置找) */
|
|
|
Engine.SCRIPT_RELATIVE = "tiktok-auto/dist/tiktok-auto.js";
|
|
|
|
|
|
+/**
|
|
|
+ * 协议阈值。唯一来源是 `src/protocol.js` ——
|
|
|
+ * 判活/握手/停止的所有超时都从这里取,**业务代码里不许再写字面量**。
|
|
|
+ * (改一个数忘了改另一个,会得到"ack 等得比心跳还短"这种永远等不到的怪状态,
|
|
|
+ * `tools/protocol-check.js` 会把这种关系当场拦下。)
|
|
|
+ */
|
|
|
+Engine.T = Protocol.SPEC.thresholds;
|
|
|
+
|
|
|
/** 本次运行的标识。由界面生成并同时告诉引擎与停止标志,三方比对。 */
|
|
|
Engine.RUN_ID = "";
|
|
|
|
|
|
@@ -3450,10 +3674,22 @@ Engine.prepare = function (task, opts) {
|
|
|
};
|
|
|
};
|
|
|
|
|
|
-/** 把引擎配置落盘。返回是否成功。 */
|
|
|
-Engine.writeConfig = function (config) {
|
|
|
+/**
|
|
|
+ * 把引擎配置落盘。返回是否成功。
|
|
|
+ *
|
|
|
+ * `runId` 会在写入前塞进 `runtime.runId`:引擎拿它跟命令行里的 `--run-id` 比对,
|
|
|
+ * 挡住"上一轮的引擎迟到启动、却读到这一轮的配置并真的跑起来"。
|
|
|
+ * ★ 注入放在这里而不是四个 `buildXxxConfig` 里 —— 那四份 runtime 段本来就不一致
|
|
|
+ * (dm / nurture 连 configPath 都没有),逐份加必然漏一份,而漏掉的那份会让
|
|
|
+ * 那个任务**永远启动失败**(配置与运行不匹配 → failed)。
|
|
|
+ */
|
|
|
+Engine.writeConfig = function (config, runId) {
|
|
|
try {
|
|
|
files.ensureDir(Engine.DIR + "/");
|
|
|
+ if (config) {
|
|
|
+ config.runtime = config.runtime || {};
|
|
|
+ config.runtime.runId = runId === undefined || runId === null ? "" : String(runId);
|
|
|
+ }
|
|
|
files.write(Engine.CONFIG_PATH, JSON.stringify(config, null, 2));
|
|
|
return true;
|
|
|
} catch (e) {
|
|
|
@@ -3490,7 +3726,22 @@ Engine.launch = function (runId, scriptPath, opts) {
|
|
|
if (!handle) {
|
|
|
return { ok: false, error: "\u5F15\u64CE\u542F\u52A8\u8FD4\u56DE\u7A7A\uFF08\u53EF\u80FD\u88AB\u7CFB\u7EDF\u62E6\u622A\uFF09" };
|
|
|
}
|
|
|
- return { ok: true };
|
|
|
+
|
|
|
+ /* ★ 记下子引擎的身份,供 childAlive() 精确匹配。
|
|
|
+ * `ScriptExecution.getEngine()` 的类型是 `ScriptEngine | null` ——
|
|
|
+ * 刚 execScriptFile 返回的那一瞬间,子引擎可能还没建好(真机上取到 null),
|
|
|
+ * 所以这里是"能拿到就拿,拿不到就算了":
|
|
|
+ * 取不到只会让判活退回旧的宽口径(任何第二个引擎都算活着),不会误判成已退出。 */
|
|
|
+ Engine.childId = "";
|
|
|
+ try {
|
|
|
+ var child = handle.getEngine ? handle.getEngine() : null;
|
|
|
+ if (child && child.id !== undefined && child.id !== null) {
|
|
|
+ Engine.childId = String(child.id);
|
|
|
+ }
|
|
|
+ } catch (childError) {
|
|
|
+ Engine.childId = "";
|
|
|
+ }
|
|
|
+ return { ok: true, childId: Engine.childId };
|
|
|
} catch (e) {
|
|
|
console.log("[engine] 唤起失败: " + e);
|
|
|
return { ok: false, error: String(e) };
|
|
|
@@ -3544,14 +3795,39 @@ Engine.readState = function () {
|
|
|
}
|
|
|
};
|
|
|
|
|
|
+/** 读引擎心跳。没有 / 读坏了都返回 null(**绝不用文件时间戳判活**,stat 语义不可靠)。 */
|
|
|
+Engine.readBeat = function () {
|
|
|
+ try {
|
|
|
+ if (!files.exists(Engine.BEAT_PATH)) { return null; }
|
|
|
+ var parsed = JSON.parse(String(files.read(Engine.BEAT_PATH)));
|
|
|
+ return parsed && typeof parsed === "object" ? parsed : null;
|
|
|
+ } catch (e) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+};
|
|
|
+
|
|
|
+/**
|
|
|
+ * 挑出属于本轮的心跳。
|
|
|
+ *
|
|
|
+ * 判定:`runId` 必须与本次运行**严格相等**。
|
|
|
+ * ★ 不等就是"还没有本轮心跳",**不等于"引擎不支持心跳"** ——
|
|
|
+ * 后者只由 `run-state.engine.capabilities.heartbeat` 决定。
|
|
|
+ * 把这两件事混在一起,会在引擎刚启动的那一两秒里误判成 v1。
|
|
|
+ */
|
|
|
+Engine.beatOf = function (beat, runId) {
|
|
|
+ if (!beat || !runId) { return null; }
|
|
|
+ if (String(beat.runId || "") !== String(runId)) { return null; }
|
|
|
+ return beat;
|
|
|
+};
|
|
|
+
|
|
|
/** 运行中(含正在收尾)—— 界面据此继续显示状态条 */
|
|
|
Engine.isActiveStatus = function (status) {
|
|
|
- return status === "starting" || status === "running" || status === "stopping";
|
|
|
+ return Protocol.isActive(status);
|
|
|
};
|
|
|
|
|
|
/** 终态 —— 界面据此结束运行态并告知结果 */
|
|
|
Engine.isTerminalStatus = function (status) {
|
|
|
- return status === "finished" || status === "stopped" || status === "failed";
|
|
|
+ return Protocol.isTerminal(status);
|
|
|
};
|
|
|
|
|
|
/** 请求停止:写停止标志(内容 = runId,引擎比对通过才会停) */
|
|
|
@@ -3566,17 +3842,36 @@ Engine.requestStop = function (runId) {
|
|
|
}
|
|
|
};
|
|
|
|
|
|
+/** 上次 launch 到的子引擎 id;空串 = 没取到,按旧的"任意第二个引擎"判定 */
|
|
|
+Engine.childId = "";
|
|
|
+
|
|
|
/**
|
|
|
* 引擎是否还活着。
|
|
|
*
|
|
|
* 用途只有一个:**兜底判定"引擎没写终态就没了"**(被系统杀掉、被手工停掉)。
|
|
|
* 查不出来时返回 true(当作还活着)—— 宁可多显示一会儿"运行中",
|
|
|
* 也不要误判成"已结束"而把运行态清掉、让用户以为跑完了。
|
|
|
+ *
|
|
|
+ * ★ 优先按 `childId` 精确匹配:`engines.all()` 里出现**任意**第二个引擎都算活着
|
|
|
+ * 的说法太宽(用户自己开的别的脚本也算),那会让"子引擎静默消失"这条兜底
|
|
|
+ * 永远不触发。只有取不到 id 时才退回旧口径。
|
|
|
*/
|
|
|
Engine.childAlive = function () {
|
|
|
try {
|
|
|
var mine = engines.myEngine();
|
|
|
var all = engines.all() || [];
|
|
|
+ var wanted = Engine.childId;
|
|
|
+
|
|
|
+ if (wanted !== "") {
|
|
|
+ for (var k = 0; k < all.length; k += 1) {
|
|
|
+ var child = all[k];
|
|
|
+ if (child && String(child.id) === String(wanted)) {
|
|
|
+ return true;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+
|
|
|
for (var i = 0; i < all.length; i += 1) {
|
|
|
var one = all[i];
|
|
|
if (one && String(one.id) !== String(mine.id)) {
|
|
|
@@ -3635,6 +3930,46 @@ Session.stopping = false;
|
|
|
/** 发出停止请求的时刻,用于超时兜底(引擎不确认也不能一直等)。 */
|
|
|
Session.stopRequestedAt = 0;
|
|
|
|
|
|
+/* ---------------------------------------------------------------------------
|
|
|
+ * 握手字段(2026-11 新增,全部是**独立字段**,不改上面任何一个的语义)
|
|
|
+ *
|
|
|
+ * 老逻辑只有"运行中 / 正在收尾 / 结束"三档,中间那段
|
|
|
+ * 「点了开始但引擎还没起来」和「点了停止但引擎还没回话」是**模糊**的:
|
|
|
+ * 状态条说"运行中",可实际上什么都没在跑。这四个字段把它们拆开。
|
|
|
+ * ------------------------------------------------------------------------- */
|
|
|
+
|
|
|
+/**
|
|
|
+ * 还在等引擎的**启动确认**(收到本轮 run-state 或本轮心跳即为确认)。
|
|
|
+ *
|
|
|
+ * ★ 由 `Session.start()` 置 true —— 不能由 `App.startRun()` 置:
|
|
|
+ * startRun 里的顺序是 launch(先) → Session.start(后),后者会把字段复位掉。
|
|
|
+ */
|
|
|
+Session.awaitingAck = false;
|
|
|
+
|
|
|
+/** 收到启动确认的时刻。0 = 还没收到。 */
|
|
|
+Session.ackedAt = 0;
|
|
|
+
|
|
|
+/**
|
|
|
+ * 本轮"开始等引擎"的时刻。握手超时从**它**算,不从 `startedAt` 算。
|
|
|
+ *
|
|
|
+ * 为什么单开一个:`startedAt` 是"用户点开始"的时刻,会被运行计时直接使用;
|
|
|
+ * 自检里为了验证计时格式会把它往前推几小时,若握手超时也用它,
|
|
|
+ * 一次"验证时长显示"的用例就会顺带把运行判成启动超时。
|
|
|
+ */
|
|
|
+Session.ackStartedAt = 0;
|
|
|
+
|
|
|
+/** 启动确认来得太慢(超过 startAckSoftMs)。只影响文案,不影响状态。 */
|
|
|
+Session.ackSlow = false;
|
|
|
+
|
|
|
+/** 最近一次**属于本轮**的心跳时刻。0 = 本轮还没收到过心跳。 */
|
|
|
+Session.beatAt = 0;
|
|
|
+
|
|
|
+/**
|
|
|
+ * 引擎确认收到停止请求的时刻(心跳里的 stopSeenAt,或它自己报了 stopping)。
|
|
|
+ * 0 = 还没确认 —— 状态条此时说「正在通知引擎」,确认后才说「正在收尾」。
|
|
|
+ */
|
|
|
+Session.stopAckedAt = 0;
|
|
|
+
|
|
|
/* 计时器句柄。用 setInterval 而不是 threads.start:
|
|
|
* UI 模式下 setInterval 的回调跑在主线程的 Looper 上,可以安全改 View;
|
|
|
* 而 threads.start + ui.run 那条链路在真机上改 TextView 会崩
|
|
|
@@ -3659,6 +3994,14 @@ Session.start = function (tab, scriptKey, scriptName, runId) {
|
|
|
Session.total = 0;
|
|
|
Session.stopping = false;
|
|
|
Session.stopRequestedAt = 0;
|
|
|
+ /* 新一次运行 = 还没握手成功。放在这里而不是 startRun:
|
|
|
+ * startRun 是 launch → Session.start 的顺序,放前面会被这里复位掉。 */
|
|
|
+ Session.awaitingAck = true;
|
|
|
+ Session.ackedAt = 0;
|
|
|
+ Session.ackStartedAt = Session.startedAt;
|
|
|
+ Session.ackSlow = false;
|
|
|
+ Session.beatAt = 0;
|
|
|
+ Session.stopAckedAt = 0;
|
|
|
};
|
|
|
|
|
|
Session.stop = function () {
|
|
|
@@ -3673,6 +4016,14 @@ Session.stop = function () {
|
|
|
Session.total = 0;
|
|
|
Session.stopping = false;
|
|
|
Session.stopRequestedAt = 0;
|
|
|
+ /* ★ 与 start 成对:漏一个就会把上一轮的脏值带进下一次运行
|
|
|
+ * (audit-values 的场景 E 会真的 start/stop,然后接着扫全页)。 */
|
|
|
+ Session.awaitingAck = false;
|
|
|
+ Session.ackedAt = 0;
|
|
|
+ Session.ackStartedAt = 0;
|
|
|
+ Session.ackSlow = false;
|
|
|
+ Session.beatAt = 0;
|
|
|
+ Session.stopAckedAt = 0;
|
|
|
};
|
|
|
|
|
|
/**
|
|
|
@@ -3694,7 +4045,22 @@ Session.barText = function () {
|
|
|
var dup = short === "" || short === taskName || short.indexOf(taskName) >= 0;
|
|
|
var head = dup ? taskName : (taskName + " \u00B7 " + short);
|
|
|
|
|
|
- if (Session.stopping) { return head + " \u00B7 \u6B63\u5728\u6536\u5C3E"; }
|
|
|
+ /* ★ 顺序就是正确性,别调换:
|
|
|
+ * ① stopping 必须排最前 —— 点了停止之后 regardless 其它字段,
|
|
|
+ * 显示的都应该是停止相关的文案("运行中"在那时说不过去);
|
|
|
+ * ② stopping 内部再分两档:引擎还没回话时说「正在通知引擎」,
|
|
|
+ * 回了话说「正在收尾」—— 否则"已发出但没人应答"会被说成"正在收尾",
|
|
|
+ * 用户会以为它听见了;
|
|
|
+ * ③ awaitingAck 排在 phase 之前:启动瞬间引擎可能已经写了
|
|
|
+ * `phase: "启动应用"`,但那时候"启动中"比"启动应用"更准确。 */
|
|
|
+ if (Session.stopping) {
|
|
|
+ return head + " \u00B7 " +
|
|
|
+ (Session.stopAckedAt ? "\u6B63\u5728\u6536\u5C3E" : "\u6B63\u5728\u901A\u77E5\u5F15\u64CE");
|
|
|
+ }
|
|
|
+ if (Session.awaitingAck) {
|
|
|
+ return head + " \u00B7 " +
|
|
|
+ (Session.ackSlow ? "\u542F\u52A8\u4E2D\uFF08\u8F83\u6162\uFF09" : "\u542F\u52A8\u4E2D");
|
|
|
+ }
|
|
|
if (Session.phase) { return head + " \u00B7 " + Session.phase; }
|
|
|
return head + " \u00B7 \u8FD0\u884C\u4E2D";
|
|
|
};
|
|
|
@@ -9304,16 +9670,19 @@ App.failBar = null;
|
|
|
App.recordId = "";
|
|
|
|
|
|
/**
|
|
|
- * 引擎"没写终态就没了"的判定宽限期。
|
|
|
+ * 引擎"没写终态就没了"的判定宽限期(**只有 v1 / 无心跳引擎用得上**)。
|
|
|
* 引擎刚起来时还没出现在引擎列表里,头几秒不判定,免得刚启动就被判成已退出。
|
|
|
+ *
|
|
|
+ * 值来自 `Protocol.SPEC.thresholds.legacyChildGraceMs`:超时数字只有协议模块
|
|
|
+ * 一个来源,这里只是给它一个好读的名字,不再写字面量。
|
|
|
*/
|
|
|
-App.CHILD_GRACE_MS = 4000;
|
|
|
+App.CHILD_GRACE_MS = Engine.T.legacyChildGraceMs;
|
|
|
|
|
|
/**
|
|
|
- * 发出停止请求后等引擎确认的上限。
|
|
|
+ * 发出停止请求后等引擎确认的上限(同样只给 v1 引擎兜底)。
|
|
|
* 超时就当它已经停了:用户按了停止,界面不能一直卡在"正在收尾"上。
|
|
|
*/
|
|
|
-App.STOP_TIMEOUT_MS = 8000;
|
|
|
+App.STOP_TIMEOUT_MS = Engine.T.legacyStopTimeoutMs;
|
|
|
|
|
|
/**
|
|
|
* 本次运行的 runId,在唤起引擎之前就定下来。
|
|
|
@@ -9323,6 +9692,9 @@ App.STOP_TIMEOUT_MS = 8000;
|
|
|
*/
|
|
|
App.pendingRunId = "";
|
|
|
|
|
|
+/** 本轮"引擎无心跳"这件事记过日志了吗(每轮只记一次,免得每秒刷屏) */
|
|
|
+App.tierLogged = false;
|
|
|
+
|
|
|
/** 本次运行是否已提示过「引擎需要 TikTok 保持前台」。来回切窗口时只提醒一次。 */
|
|
|
App.backgroundWarned = false;
|
|
|
|
|
|
@@ -9780,51 +10152,177 @@ App.tickTimer = function () {
|
|
|
};
|
|
|
|
|
|
/**
|
|
|
- * 读引擎上报的状态,驱动界面的运行态。
|
|
|
+ * 本轮引擎的协议档次。
|
|
|
+ *
|
|
|
+ * @param mine 本轮 run-state(可能为 null)
|
|
|
+ * @param beat 本轮心跳(可能为 null)
|
|
|
+ * @returns 'v1' 老引擎 → 只能靠 engines.all() 判活
|
|
|
+ * 'v2' 懂启动握手与心跳
|
|
|
+ * 'unknown' 本轮还没收到过任何 run-state
|
|
|
+ *
|
|
|
+ * ★ 判据是「run-state 里的 v」**或**「已经收到过本轮心跳」。
|
|
|
+ * 两者都是**正向证据**:有 v≥2 说明是新引擎,有心跳更是铁证。
|
|
|
+ * 反过来**不成立** —— 没有心跳不能当成 v1:新引擎起来后的头一两秒
|
|
|
+ * 同样没有心跳文件,那不是老引擎,是还没开始跳。这个方向搞反了,
|
|
|
+ * 每次启动都会被降级到 4 秒的旧宽限期,反而更容易误判退出。
|
|
|
+ */
|
|
|
+App.engineTier = function (mine, beat) {
|
|
|
+ if (beat) { return "v2"; }
|
|
|
+ if (!mine) { return "unknown"; }
|
|
|
+ return Number(mine.v) >= 2 ? "v2" : "v1";
|
|
|
+};
|
|
|
+
|
|
|
+/** 引擎自报"心跳线程起来了"吗?没报或报 false 都按"没有心跳"处理(阈值放大)。 */
|
|
|
+App.engineCapBeat = function (mine) {
|
|
|
+ return !!(mine && mine.engine && mine.engine.capabilities &&
|
|
|
+ mine.engine.capabilities.heartbeat === true);
|
|
|
+};
|
|
|
+
|
|
|
+/**
|
|
|
+ * 读引擎上报的状态与心跳,驱动界面的运行态。
|
|
|
*
|
|
|
* ★ 必须比对 runId:状态文件是设备上的共享文件,上一轮运行留下的内容还在。
|
|
|
* 不比对就会出现「刚点开始运行,界面立刻显示上一轮的进度」。
|
|
|
+ *
|
|
|
+ * 四段,顺序不能换:
|
|
|
+ * ① 终态——看到就立刻收,不等任何超时;
|
|
|
+ * ② 启动握手——本轮还没收到任何消息时说"启动中",超时才判失败;
|
|
|
+ * ③ 停止两段——先等"看见了停止请求",再等"真的收完尾";
|
|
|
+ * ④ 常态——进度回填 + 判活。
|
|
|
*/
|
|
|
App.pollEngine = function () {
|
|
|
+ var now = Date.now();
|
|
|
var state = Engine.readState();
|
|
|
+ var mine = state && state.runId === Session.runId ? state : null;
|
|
|
+ var beat = Engine.beatOf(Engine.readBeat(), Session.runId);
|
|
|
+ var tier = App.engineTier(mine, beat);
|
|
|
+
|
|
|
+ /* —— ① 终态:最高优先,看到就收 —— */
|
|
|
+ if (mine && Engine.isTerminalStatus(mine.status)) {
|
|
|
+ Engine.sawChild = true;
|
|
|
+ App.finishRun(mine);
|
|
|
+ return;
|
|
|
+ }
|
|
|
|
|
|
- if (state && state.runId === Session.runId) {
|
|
|
- /* 看到过它,才有资格在后来说"它不见了" —— 见下面的兜底判定 */
|
|
|
- if (Engine.childAlive()) { Engine.sawChild = true; }
|
|
|
+ /* —— ② 启动握手 —— */
|
|
|
+ if (Session.awaitingAck) {
|
|
|
+ if (mine || beat) {
|
|
|
+ Session.awaitingAck = false;
|
|
|
+ Session.ackedAt = now;
|
|
|
+ Session.ackSlow = false;
|
|
|
+ /* 每轮只记一次:排障时最想知道的就是"这台设备上跑的是哪一档协议" */
|
|
|
+ if (tier === "v1" && !App.tierLogged) {
|
|
|
+ App.tierLogged = true;
|
|
|
+ console.log("[app] 引擎没有心跳(协议 v1),按旧阈值判活");
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+ var waited = now - (Session.ackStartedAt || Session.startedAt);
|
|
|
+ if (waited > Engine.T.startAckSoftMs && !Session.ackSlow) {
|
|
|
+ /* 只降文案、不改状态:慢一次很常见,不必吓人 */
|
|
|
+ Session.ackSlow = true;
|
|
|
+ console.log("[app] 引擎启动较慢,已等待 " + waited + "ms");
|
|
|
+ }
|
|
|
+ if (waited > Engine.T.startAckMaxMs) {
|
|
|
+ /* 迟到的引擎醒来后可能读到本轮配置就开跑。补写停止标志
|
|
|
+ *(内容 = 本轮 runId),它一 checkStop 就会自己收手。
|
|
|
+ * ★ 不要"顺手清掉"这个标志:下一轮的 runId 不同,自然失效。 */
|
|
|
+ Engine.requestStop(Session.runId);
|
|
|
+ App.finishRun({
|
|
|
+ status: "failed",
|
|
|
+ message: "启动超时(引擎没有响应)"
|
|
|
+ });
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ /* 握手期不做判活:这时候"还没有心跳"是正常的,不代表死了 */
|
|
|
+ if (Engine.childAlive()) { Engine.sawChild = true; }
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ }
|
|
|
|
|
|
- if (Engine.isTerminalStatus(state.status)) {
|
|
|
- App.finishRun(state);
|
|
|
+ /* —— ③ 停止:ack(看见请求)→ 收尾(写出终态)—— */
|
|
|
+ if (Session.stopping) {
|
|
|
+ if (tier === "v1") {
|
|
|
+ /* 老引擎没有心跳,只能用单段超时,就是改造前的那 8 秒 */
|
|
|
+ if (now - Session.stopRequestedAt > App.STOP_TIMEOUT_MS) {
|
|
|
+ App.finishRun({
|
|
|
+ status: "stopped",
|
|
|
+ message: "已发出停止请求(引擎未确认)"
|
|
|
+ });
|
|
|
+ }
|
|
|
return;
|
|
|
}
|
|
|
- if (state.phase) { Session.phase = state.phase; }
|
|
|
- Session.done = state.done || 0;
|
|
|
- Session.total = state.total || 0;
|
|
|
- if (state.status === "stopping") { Session.stopping = true; }
|
|
|
+
|
|
|
+ var seenAt = beat && beat.stopSeenAt ? Number(beat.stopSeenAt) : 0;
|
|
|
+ if (!Session.stopAckedAt && (seenAt > 0 || (mine && mine.status === "stopping"))) {
|
|
|
+ Session.stopAckedAt = now;
|
|
|
+ }
|
|
|
+ if (Session.stopRequestedAt <= 0) {
|
|
|
+ /* 引擎自己先报了 stopping:补一个起点,否则下面会算到 1970 年、
|
|
|
+ * 立刻误判成"已经停了" */
|
|
|
+ Session.stopRequestedAt = now;
|
|
|
+ }
|
|
|
+ var deadline = Session.stopAckedAt > 0
|
|
|
+ ? Session.stopAckedAt + Engine.T.stopFinishMs
|
|
|
+ : Session.stopRequestedAt + Engine.T.stopAckMs + Engine.T.stopFinishMs;
|
|
|
+ if (now > deadline) {
|
|
|
+ App.finishRun({
|
|
|
+ status: "stopped",
|
|
|
+ message: Session.stopAckedAt > 0
|
|
|
+ ? "已停止(引擎未确认收尾)"
|
|
|
+ : "已停止(引擎未确认收到请求)"
|
|
|
+ });
|
|
|
+ }
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ /* —— ④ 常态:进度回填 —— */
|
|
|
+ if (mine) {
|
|
|
+ Engine.sawChild = true;
|
|
|
+ if (mine.phase) { Session.phase = mine.phase; }
|
|
|
+ Session.done = mine.done || 0;
|
|
|
+ Session.total = mine.total || 0;
|
|
|
+ if (mine.status === "stopping") { Session.stopping = true; }
|
|
|
} else if (Engine.childAlive()) {
|
|
|
/* 引擎在跑但还没写出状态:正常的启动间隙,什么都不做 */
|
|
|
Engine.sawChild = true;
|
|
|
}
|
|
|
|
|
|
- /* —— 兜底一:点了停止但引擎迟迟不确认 —— */
|
|
|
- if (Session.stopping && Date.now() - Session.stopRequestedAt > App.STOP_TIMEOUT_MS) {
|
|
|
+ if (beat) { Session.beatAt = Number(beat.beatAt) || now; }
|
|
|
+
|
|
|
+ /* —— 判活 —— */
|
|
|
+ if (tier === "v1") {
|
|
|
+ /* 老口径:只认 engines.all(),且"没看到过就不判",宁可多显示运行中 */
|
|
|
+ if (Engine.sawChild &&
|
|
|
+ now - Session.startedAt > App.CHILD_GRACE_MS &&
|
|
|
+ !Engine.childAlive()) {
|
|
|
+ App.finishRun({
|
|
|
+ status: "failed",
|
|
|
+ message: "引擎已退出(未收到结束信号)"
|
|
|
+ });
|
|
|
+ }
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (Session.stopping) { return; }
|
|
|
+
|
|
|
+ var capBeat = App.engineCapBeat(mine);
|
|
|
+ var limitMs = capBeat ? Engine.T.beatDeadMs : Engine.T.legacyNoBeatDeadMs;
|
|
|
+ var accelMs = capBeat ? Engine.T.beatFreshMs : App.CHILD_GRACE_MS;
|
|
|
+ var anchor = Session.beatAt > 0 ? Session.beatAt : (Session.ackedAt || Session.startedAt);
|
|
|
+
|
|
|
+ if (now - anchor > limitMs) {
|
|
|
App.finishRun({
|
|
|
- status: "stopped",
|
|
|
- message: "\u5DF2\u53D1\u51FA\u505C\u6B62\u8BF7\u6C42\uFF08\u5F15\u64CE\u672A\u786E\u8BA4\uFF09"
|
|
|
+ status: "failed",
|
|
|
+ message: "引擎已退出(未收到结束信号)"
|
|
|
});
|
|
|
return;
|
|
|
}
|
|
|
-
|
|
|
- /* —— 兜底二:引擎没写终态就没了(被系统杀掉 / 被人手工停掉)——
|
|
|
- * 只在"确实看到过它、现在它不见了"时判定。`engines.all()` 在不同运行时里
|
|
|
- * 的可见性不完全一致,没看到过就当作"查不出来",宁可多显示一会儿运行中,
|
|
|
- * 也不要误判成已结束把运行态清掉。 */
|
|
|
- if (!Session.stopping &&
|
|
|
- Engine.sawChild &&
|
|
|
- Date.now() - Session.startedAt > App.CHILD_GRACE_MS &&
|
|
|
- !Engine.childAlive()) {
|
|
|
+ /* 加速判死:引擎列表里也没了,且心跳已经不新鲜 —— 不用等满 15 秒。
|
|
|
+ * ★ 反过来说:心跳新鲜时**绝不**因为 engines.all() 说没有就判死。 */
|
|
|
+ if (!Engine.childAlive() && now - anchor > accelMs) {
|
|
|
App.finishRun({
|
|
|
status: "failed",
|
|
|
- message: "\u5F15\u64CE\u5DF2\u9000\u51FA\uFF08\u672A\u6536\u5230\u7ED3\u675F\u4FE1\u53F7\uFF09"
|
|
|
+ message: "引擎已退出(未收到结束信号)"
|
|
|
});
|
|
|
}
|
|
|
};
|
|
|
@@ -10181,11 +10679,17 @@ App.startRun = function (task, opts) {
|
|
|
toast("\u8FD0\u884C\u914D\u7F6E\u5199\u5165\u5931\u8D25\uFF0C\u8BF7\u68C0\u67E5\u5B58\u50A8\u6743\u9650");
|
|
|
return;
|
|
|
}
|
|
|
- if (!Engine.writeConfig(plan.config)) {
|
|
|
+ if (!Engine.writeConfig(plan.config, App.pendingRunId)) {
|
|
|
toast("\u5F15\u64CE\u914D\u7F6E\u5199\u5165\u5931\u8D25\uFF0C\u8BF7\u68C0\u67E5\u5B58\u50A8\u6743\u9650");
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
+ /* 上一轮的子引擎 id 先清掉:万一这次唤起直接抛错,
|
|
|
+ * 留着旧 id 会让 childAlive() 一直返回 false,
|
|
|
+ * 于是新一轮刚起跑就被判成"引擎已退出"。 */
|
|
|
+ Engine.childId = "";
|
|
|
+ App.tierLogged = false;
|
|
|
+
|
|
|
var launched = Engine.launch(App.pendingRunId, plan.scriptPath, opts);
|
|
|
if (!launched.ok) {
|
|
|
Dialog.engineUnavailable("\u5F15\u64CE\u542F\u52A8\u5931\u8D25\uFF1A" + launched.error);
|
|
|
@@ -10238,6 +10742,10 @@ App.stopRun = function () {
|
|
|
if (Engine.requestStop(Session.runId)) {
|
|
|
Session.stopping = true;
|
|
|
Session.stopRequestedAt = Date.now();
|
|
|
+ /* ★ 新字段,不复用 stopRequestedAt:
|
|
|
+ * "什么时候点的停止"和"引擎什么时候承认收到了"是两个时刻,
|
|
|
+ * 混用一个字段就没法区分"还没听见"和"听见了正在收尾"。 */
|
|
|
+ Session.stopAckedAt = 0;
|
|
|
console.log("[app] 已发出停止请求 runId=" + Session.runId);
|
|
|
return;
|
|
|
}
|
|
|
@@ -10600,6 +11108,7 @@ if (typeof globalThis !== "undefined" && globalThis.__FEIZHOU_TEST__) {
|
|
|
Session: Session,
|
|
|
Resume: Resume,
|
|
|
Fingerprint: Fingerprint,
|
|
|
+ Protocol: Protocol,
|
|
|
W: W,
|
|
|
Dialog: Dialog,
|
|
|
Pages: Pages,
|