@forsion/tangu-computer-use 0.5.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +223 -0
- package/LICENSE +26 -0
- package/LICENSE.upstream +21 -0
- package/README.md +169 -0
- package/UPSTREAM.md +277 -0
- package/check.mjs +369 -0
- package/icon.png +0 -0
- package/install.sh +112 -0
- package/main.js +541 -0
- package/manifest.json +47 -0
- package/native/linux/bridge-rs/Cargo.lock +1204 -0
- package/native/linux/bridge-rs/Cargo.toml +18 -0
- package/native/linux/bridge-rs/src/atspi.rs +640 -0
- package/native/linux/bridge-rs/src/error.rs +65 -0
- package/native/linux/bridge-rs/src/lib.rs +11 -0
- package/native/linux/bridge-rs/src/main.rs +1074 -0
- package/native/linux/bridge-rs/src/protocol.rs +49 -0
- package/native/linux/bridge-rs/src/state.rs +293 -0
- package/native/linux/bridge-rs/src/wayland.rs +89 -0
- package/native/linux/bridge-rs/src/x11.rs +909 -0
- package/native/linux/bridge-rs/tests/protocol_tests.rs +73 -0
- package/native/macos/agent_cursor.swift +184 -0
- package/native/macos/agent_cursor_motion.swift +252 -0
- package/native/macos/agent_cursor_tests.swift +128 -0
- package/native/macos/agent_highlight.swift +229 -0
- package/native/macos/agent_highlight_tests.swift +99 -0
- package/native/macos/bridge.swift +3852 -0
- package/native/macos/foreground_activity.swift +57 -0
- package/native/macos/foreground_activity_tests.swift +31 -0
- package/native/macos/live_stream.swift +289 -0
- package/native/windows/bridge-rs/Cargo.lock +396 -0
- package/native/windows/bridge-rs/Cargo.toml +30 -0
- package/native/windows/bridge-rs/src/capture.rs +518 -0
- package/native/windows/bridge-rs/src/error.rs +81 -0
- package/native/windows/bridge-rs/src/input.rs +501 -0
- package/native/windows/bridge-rs/src/lib.rs +16 -0
- package/native/windows/bridge-rs/src/main.rs +1683 -0
- package/native/windows/bridge-rs/src/protocol.rs +53 -0
- package/native/windows/bridge-rs/src/refs.rs +237 -0
- package/native/windows/bridge-rs/src/state.rs +55 -0
- package/native/windows/bridge-rs/src/uia.rs +1252 -0
- package/native/windows/bridge-rs/src/window.rs +718 -0
- package/native/windows/bridge-rs/tests/protocol_tests.rs +252 -0
- package/native/windows/bridge-rs/tests/refs_tests.rs +86 -0
- package/native/windows/bridge-rs/tests/state_tests.rs +33 -0
- package/package.json +74 -0
- package/prebuilt/linux/arm64/linux-bridge +0 -0
- package/prebuilt/linux/x64/linux-bridge +0 -0
- package/prebuilt/macos/arm64/bridge +0 -0
- package/prebuilt/macos/arm64/tangu-computer-use.app.json +10 -0
- package/prebuilt/macos/arm64/tangu-computer-use.app.zip +0 -0
- package/prebuilt/macos/x64/bridge +0 -0
- package/prebuilt/macos/x64/tangu-computer-use.app.json +10 -0
- package/prebuilt/macos/x64/tangu-computer-use.app.zip +0 -0
- package/prebuilt/windows/windows-bridge.exe +0 -0
- package/scripts/blind-click.check.mjs +128 -0
- package/scripts/build-native.mjs +302 -0
- package/scripts/build.mjs +84 -0
- package/scripts/calc-fixture.mjs +78 -0
- package/scripts/cli-exit.check.mjs +49 -0
- package/scripts/helper-path.check.mjs +82 -0
- package/scripts/helper-refresh.check.mjs +62 -0
- package/scripts/helper-signal.check.mjs +59 -0
- package/scripts/highlight.check.mjs +32 -0
- package/scripts/keychain-free-install.check.mjs +140 -0
- package/scripts/live-view.check.mjs +124 -0
- package/scripts/macos-bundle.d.mts +1 -0
- package/scripts/macos-bundle.mjs +119 -0
- package/scripts/make-signing-cert.sh +57 -0
- package/scripts/mini-foreground.check.mjs +11 -0
- package/scripts/no-foreground.check.mjs +81 -0
- package/scripts/overlay-visible.check.mjs +221 -0
- package/scripts/package-macos-app.mjs +39 -0
- package/scripts/permissions.check.mjs +199 -0
- package/scripts/platform-contract.check.mjs +30 -0
- package/scripts/setup-helper.mjs +154 -0
- package/scripts/tangu-computer-use.entitlements +10 -0
- package/scripts/verify-macos-bundles.mjs +25 -0
- package/scripts/verify-package.mjs +74 -0
- package/skills/computer-use/SKILL.md +110 -0
- package/src/foregroundNote.ts +43 -0
- package/src/helperState.ts +31 -0
- package/src/index.ts +63 -0
- package/src/onboarding.ts +198 -0
- package/src/pi-compat.ts +49 -0
- package/src/settings.ts +11 -0
- package/src/setup.ts +97 -0
- package/src/tools.ts +289 -0
- package/src/vendor/actions.ts +130 -0
- package/src/vendor/bridge.ts +2405 -0
- package/src/vendor/cdp.ts +658 -0
- package/src/vendor/config.ts +113 -0
- package/src/vendor/contract.ts +104 -0
- package/src/vendor/note.ts +195 -0
- package/src/vendor/outline.ts +651 -0
- package/src/vendor/output.ts +134 -0
- package/src/vendor/permissions.ts +111 -0
- package/src/vendor/platform/architecture.ts +23 -0
- package/src/vendor/platform/coerce.ts +16 -0
- package/src/vendor/platform/index.ts +59 -0
- package/src/vendor/platform/linux/backend.ts +186 -0
- package/src/vendor/platform/linux/helper.ts +238 -0
- package/src/vendor/platform/macos/backend.ts +131 -0
- package/src/vendor/platform/macos/browser.ts +110 -0
- package/src/vendor/platform/macos/helper-path.d.mts +9 -0
- package/src/vendor/platform/macos/helper-path.mjs +34 -0
- package/src/vendor/platform/macos/helper.ts +291 -0
- package/src/vendor/platform/macos/permissions.ts +146 -0
- package/src/vendor/platform/types.ts +222 -0
- package/src/vendor/platform/windows/backend.ts +142 -0
- package/src/vendor/platform/windows/helper.ts +140 -0
- package/src/vendor/root-selection.ts +25 -0
- package/src/vendor/runtime.ts +129 -0
- package/src/vendor/state.ts +146 -0
- package/src/vendor/view.ts +147 -0
- package/tangu-plugins/computer-use/dist/foregroundNote.js +19 -0
- package/tangu-plugins/computer-use/dist/index.js +5672 -0
- package/tangu-plugins/computer-use/tangu-plugin.json +9 -0
- package/tsconfig.json +22 -0
- package/types/tangu-agent.d.ts +142 -0
package/install.sh
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# 装/更新「电脑操作(Computer Use)」捆绑包到 Forsion 家目录。
|
|
3
|
+
# 用法:sh install.sh [dev|prod] 缺省 dev(~/.forsion-dev);prod=~/.forsion
|
|
4
|
+
#
|
|
5
|
+
# 本仓即 bundle 本体,整目录拷到 <home>/plugins/tangu-computer-use/ 一处即完成:
|
|
6
|
+
# 桌面识别 manifest.json + main.js(「被操控的窗口」实时画面视图);
|
|
7
|
+
# 引擎(tangu-agent bundles.ts)原地读 tangu-plugins/computer-use/(12 个工具)与 skills/(配套技能)。
|
|
8
|
+
# native helper 不在这里装,但**也不用人管**:随包的 prebuilt 二进制会在第一次用工具时自动装/更新
|
|
9
|
+
# (src/onboarding.ts)。只有系统授权(辅助功能/屏幕录制)必须用户自己拨 —— agent 会替他打开面板。
|
|
10
|
+
set -euo pipefail
|
|
11
|
+
MODE="${1:-dev}"
|
|
12
|
+
case "$MODE" in
|
|
13
|
+
dev) HOME_DIR="$HOME/.forsion-dev" ;;
|
|
14
|
+
prod) HOME_DIR="$HOME/.forsion" ;;
|
|
15
|
+
*) echo "用法:sh install.sh [dev|prod]" >&2; exit 2 ;;
|
|
16
|
+
esac
|
|
17
|
+
HERE="$(cd "$(dirname "$0")" && pwd)"
|
|
18
|
+
DEST="$HOME_DIR/plugins/tangu-computer-use"
|
|
19
|
+
|
|
20
|
+
# 不许从已安装目录内自更新:下面的 rm -rf 会先删掉复制源(自己),把插件卸成空壳
|
|
21
|
+
if [ "$HERE" = "$(cd "$DEST" 2>/dev/null && pwd || true)" ]; then
|
|
22
|
+
echo "❌ 正在从已安装目录运行,请从源码仓的 tangu-computer-use/ 目录执行 install.sh" >&2
|
|
23
|
+
exit 2
|
|
24
|
+
fi
|
|
25
|
+
|
|
26
|
+
# 引擎入口必须先构建出来,否则装过去是个不会激活的空壳
|
|
27
|
+
if [ ! -f "$HERE/tangu-plugins/computer-use/dist/index.js" ]; then
|
|
28
|
+
echo "❌ 缺少 tangu-plugins/computer-use/dist/index.js,先跑 npm install && npm run build" >&2
|
|
29
|
+
exit 2
|
|
30
|
+
fi
|
|
31
|
+
|
|
32
|
+
# 原子落位:先拷进同盘 staging,拷成功了再换掉旧的。
|
|
33
|
+
# 直接 `rm -rf $DEST && cp` 的话,中途失败(磁盘满、拷贝被打断)就把用户装好的插件删成了空壳,
|
|
34
|
+
# 而空壳是会被宿主扫到的——比"没装"更糟。同 bundles.ts 的播种纪律。
|
|
35
|
+
mkdir -p "$HOME_DIR/plugins"
|
|
36
|
+
# 互斥锁:mkdir 是原子的。两个 install 同时跑会互相把对方的 staging 塞进对方刚装好的目录里。
|
|
37
|
+
LOCK="$DEST.lock"
|
|
38
|
+
if ! mkdir "$LOCK" 2>/dev/null; then
|
|
39
|
+
echo "❌ 另一个安装正在进行($LOCK 已存在)。确认没有别的 install.sh 在跑后,删掉它再试。" >&2
|
|
40
|
+
exit 2
|
|
41
|
+
fi
|
|
42
|
+
STAGING="$DEST.staging.$$"
|
|
43
|
+
OLD="$DEST.old.$$"
|
|
44
|
+
cleanup() { rm -rf "$STAGING"; rmdir "$LOCK" 2>/dev/null || true; }
|
|
45
|
+
trap cleanup EXIT
|
|
46
|
+
rm -rf "$STAGING"
|
|
47
|
+
mkdir -p "$STAGING"
|
|
48
|
+
# node_modules / .git 不进 bundle(运行时零依赖,dist 是单文件)
|
|
49
|
+
tar -C "$HERE" --exclude='./node_modules' --exclude='./.git' --exclude='./.idea' --exclude='.DS_Store' -cf - . | tar -C "$STAGING" -xf -
|
|
50
|
+
# 校验:关键三件缺一件就当拷贝失败,旧的原样留着
|
|
51
|
+
for REQUIRED in manifest.json main.js tangu-plugins/computer-use/dist/index.js; do
|
|
52
|
+
if [ ! -f "$STAGING/$REQUIRED" ]; then
|
|
53
|
+
echo "❌ 拷贝不完整(缺 $REQUIRED),已保留原安装不动" >&2
|
|
54
|
+
exit 1
|
|
55
|
+
fi
|
|
56
|
+
done
|
|
57
|
+
if [ -e "$DEST" ]; then mv "$DEST" "$OLD"; fi
|
|
58
|
+
if ! mv "$STAGING" "$DEST"; then
|
|
59
|
+
[ -e "$OLD" ] && mv "$OLD" "$DEST" # 换不过去就把旧的放回来
|
|
60
|
+
echo "❌ 落位失败,已回滚到原安装" >&2
|
|
61
|
+
exit 1
|
|
62
|
+
fi
|
|
63
|
+
rm -rf "$OLD"
|
|
64
|
+
|
|
65
|
+
echo "✅ 已安装 bundle → $DEST"
|
|
66
|
+
|
|
67
|
+
# 旧形态残留处理:0.1.x 是走 `tangu install` 的**独立引擎插件**,装在 <home>/tangu/plugins/ 或 ~/.tangu/plugins/。
|
|
68
|
+
# 它和 bundle 内嵌的那份是**同一个插件 id**(computer-use),而用户插件目录的优先级**高于** bundle 内嵌根
|
|
69
|
+
# —— 所以旧的那份会**赢**,新功能全部装不上,还会因协议版本对不上把整个 CU 卡死(2026-07-27 真事)。
|
|
70
|
+
# 因此这里主动挪走,不是只报告一句让人自己看见。
|
|
71
|
+
#
|
|
72
|
+
# ⚠️ 两条纪律:
|
|
73
|
+
# 1. **必须挪出扫描根**,光改名没用 —— 加载器认的是 manifest 里的 id,不是目录名。
|
|
74
|
+
# 2. **挪不是删**:落到 <home>/tangu/_disabled-plugins/,随时能搬回来。软链只删链接本身。
|
|
75
|
+
# ⚠️ 这一段发生在 bundle 已经装好之后,任何一步失败都**不许**中断脚本 —— 装好的东西不能因为
|
|
76
|
+
# 清理失败而回滚成"没装"。所以整段包在 `|| true` 语义里,失败只报告。
|
|
77
|
+
LEGACY=""
|
|
78
|
+
DISABLED="$HOME_DIR/tangu/_disabled-plugins"
|
|
79
|
+
# 顶层 id 必须**解析 JSON** 来取:grep 会误中嵌套字段里的 "id":"computer-use",
|
|
80
|
+
# 把一个完全无关的插件当成旧副本挪走。node 一定在(前面已要求先 npm run build)。
|
|
81
|
+
topLevelId() { node -e 'try{process.stdout.write(String(JSON.parse(require("fs").readFileSync(process.argv[1],"utf8")).id||""))}catch(e){}' "$1" 2>/dev/null; }
|
|
82
|
+
for ROOT in "$HOME_DIR/tangu/plugins" "$HOME/.tangu/plugins"; do
|
|
83
|
+
[ -d "$ROOT" ] || continue
|
|
84
|
+
for ENTRY in "$ROOT"/*; do
|
|
85
|
+
[ -e "$ENTRY" ] || continue
|
|
86
|
+
[ -f "$ENTRY/tangu-plugin.json" ] || continue
|
|
87
|
+
[ "$(topLevelId "$ENTRY/tangu-plugin.json")" = "computer-use" ] || continue
|
|
88
|
+
if [ -L "$ENTRY" ]; then
|
|
89
|
+
rm "$ENTRY" && LEGACY="$LEGACY\n 已删软链 $ENTRY"
|
|
90
|
+
else
|
|
91
|
+
# 目标名带时间戳:**绝不 rm -rf 一个已存在的同名目录** —— 那可能是上次挪走的、
|
|
92
|
+
# 或干脆是用户自己放在这儿的别的东西。
|
|
93
|
+
TARGET="$DISABLED/$(basename "$ENTRY").$(date +%Y%m%d%H%M%S).$$"
|
|
94
|
+
if mkdir -p "$DISABLED" && mv "$ENTRY" "$TARGET"; then
|
|
95
|
+
LEGACY="$LEGACY\n 已挪走 $ENTRY → $TARGET"
|
|
96
|
+
else
|
|
97
|
+
LEGACY="$LEGACY\n ⚠️ 挪不动 $ENTRY(权限?)—— 它会遮蔽 bundle,请手动移出 $ROOT"
|
|
98
|
+
fi
|
|
99
|
+
fi
|
|
100
|
+
done
|
|
101
|
+
done
|
|
102
|
+
echo " 引擎插件:$DEST/tangu-plugins/computer-use(12 个工具)"
|
|
103
|
+
echo " 配套技能:$DEST/skills/computer-use"
|
|
104
|
+
echo "重开 Forsion(dev:重启 desktop)后:设置 → 插件 → 启用「电脑操作」;"
|
|
105
|
+
echo "native helper 会在首次用工具时自动装好;届时按提示在系统设置里授予辅助功能与屏幕录制即可。"
|
|
106
|
+
|
|
107
|
+
if [ -n "$LEGACY" ]; then
|
|
108
|
+
echo ""
|
|
109
|
+
echo "⚠️ 清理了旧形态的独立引擎插件(与 bundle 内嵌的同 id,且优先级更高会把新版遮蔽掉):"
|
|
110
|
+
printf "%b\n" "$LEGACY"
|
|
111
|
+
echo " 要还原的话把目录搬回 $HOME_DIR/tangu/plugins/ 即可。"
|
|
112
|
+
fi
|
package/main.js
ADDED
|
@@ -0,0 +1,541 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Computer Use —— Forsion 桌面插件(裸 setup(ctx) 体,宿主 new Function('ctx', code) 装载)。
|
|
3
|
+
*
|
|
4
|
+
* 只做一件事:注册 `plugin:tangu-computer-use:live` 视图 —— 显示 agent 正在操控的那个窗口的画面。
|
|
5
|
+
* agent 在 Agent Desk 上摆它(desk_present {type:'view'}),就成了"演出面板里的实况转播";
|
|
6
|
+
* 用户也能在命令面板里单独打开看。
|
|
7
|
+
*
|
|
8
|
+
* 画面从哪来:主进程 `window.tangu.computerUseLiveView()` → CU 原生 helper 的 liveView 命令
|
|
9
|
+
* (ScreenCaptureKit 后台截目标窗口,不抢焦点)。三条纪律照抄主进程那层:
|
|
10
|
+
* 1. **只读**:helper 没跑就如实说"没在操控",绝不因为打开了视图去把辅助 App 拉起来。
|
|
11
|
+
* 2. **不伪造**:拿不到画面就摆错误说明(最常见是没给录屏权限),不留一张旧图假装实时。
|
|
12
|
+
* 3. **看不见就不拉**:页面隐藏 / 视图滚出可视区就停轮询 —— 每帧都是一次真实截屏,不能白烧。
|
|
13
|
+
*
|
|
14
|
+
* 配色一律吃宿主 token(--bg-card/--text/--border/--accent/--on-accent…),绝不写死颜色:
|
|
15
|
+
* LCL 的 --accent 在 cream 暗色是纸白,写死白字必然压成 1.02:1 全瞎(青鸟踩过)。
|
|
16
|
+
*/
|
|
17
|
+
const PLUGIN_ID = 'tangu-computer-use'
|
|
18
|
+
const VIEW_ID = 'live'
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* 轮询间隔(毫秒)≈ 8fps。
|
|
22
|
+
* helper 那边已经是**常驻 ScreenCaptureKit 取景流**(live_stream.swift),取一帧只是从缓存里拿最新
|
|
23
|
+
* 那张 + JPEG 编码,不再是"每帧重新协商一次采集会话"。所以这里可以按屏幕共享的节奏拉。
|
|
24
|
+
* ⚠️别再调回 1000:1fps 是幻灯片,用户实报"不是 share screen 的实时画面,是掉帧的"。
|
|
25
|
+
*/
|
|
26
|
+
const TICK_MS = 120
|
|
27
|
+
/** 没在操控时放慢:纯问一句状态(不带图),便宜。 */
|
|
28
|
+
const IDLE_TICK_MS = 2500
|
|
29
|
+
/**
|
|
30
|
+
* 看不见时的间隔。**是"降频"不是"停"**。
|
|
31
|
+
* ⚠️曾经写成"不可见就一帧都不请求",结果:隐藏页面里浏览器不给做交集计算,
|
|
32
|
+
* IntersectionObserver 连首次回调都不来 → 可见性永远停在初值「不可见」→ 视图**永久静默**。
|
|
33
|
+
* 而 computer use 恰恰会把被操控的 App 拉到前台、让 Forsion 失去前台 ——
|
|
34
|
+
* 越是需要看画面的时候越不刷新。宁可慢,不可死。
|
|
35
|
+
*/
|
|
36
|
+
const HIDDEN_TICK_MS = 6000
|
|
37
|
+
|
|
38
|
+
const ERROR_TEXT = {
|
|
39
|
+
unsupported_platform: '实时画面目前只有 macOS 支持(Windows 的 helper 是子进程,桌面端够不着)。',
|
|
40
|
+
helper_not_running: '还没有窗口正在被操控。让 agent 用 computer use 的工具动一下,画面就会出现。',
|
|
41
|
+
unsupported_helper: '本机的 Computer Use helper 还是旧版,不认识实时画面(边缘光效同样不会出现——两者在同一个二进制里)。让 agent 用一次 computer use 的工具,它会自动把 helper 更新到随包的版本;更新后 macOS 可能要求重新授权「辅助功能」与「屏幕录制」。',
|
|
42
|
+
capture_timeout: '截取窗口画面超时了,下一帧会重试。',
|
|
43
|
+
client_timeout: '等本机 helper 回画面超时了,下一帧会重试。',
|
|
44
|
+
capture_failed: '截不到这个窗口的画面 —— 多半是没给「屏幕录制」权限(系统设置 → 隐私与安全性)。',
|
|
45
|
+
screen_recording_denied: '没有「屏幕录制」权限,拿不到窗口画面(系统设置 → 隐私与安全性 → 屏幕录制)。',
|
|
46
|
+
encode_failed: '窗口画面编码失败,下一帧会重试。',
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** 错误码 → 人话。未知码原样带出去,别吞掉线索。 */
|
|
50
|
+
function explain(code) {
|
|
51
|
+
if (!code) return ''
|
|
52
|
+
return ERROR_TEXT[code] || `实时画面暂时不可用(${code})。`
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* 全插件共享的单飞:同一时刻最多一次取景请求,所有实例复用同一个 Promise。
|
|
57
|
+
* 为什么必须是模块级而不是每个实例一份:每帧都是一次**真实截屏**(helper 那边最长能跑到 8 秒),
|
|
58
|
+
* 而视图卸载后没法取消已经发出的 IPC —— 关掉再立刻打开,就会有两次捕获在 helper 里叠着跑。
|
|
59
|
+
* 反正所有实例要的是同一帧,合并即可。
|
|
60
|
+
*/
|
|
61
|
+
let sharedFetch = null
|
|
62
|
+
let sharedWantsImage = null
|
|
63
|
+
let sharedKey = null
|
|
64
|
+
function fetchFrame(api, opts) {
|
|
65
|
+
const wantsImage = opts.image !== false
|
|
66
|
+
// 复用的前提是**要到的东西一样**。除了"要不要图",还得算上 sinceFrame:
|
|
67
|
+
// 两个实例见过的帧号可能不同,共用一个回包会让其中一个收到与自己无关的 unchanged。
|
|
68
|
+
const key = `${wantsImage}|${opts.sinceFrame || ''}`
|
|
69
|
+
// ⚠️只在"要不要图"一致时才复用:否则慢速实例先发的 {image:false} 会被要图的实例捡走,
|
|
70
|
+
// 那边看到没有 jpegBase64 就误判成"拿不到画面"——我们没要 ≠ 拿不到,这个坑换条路又冒出来过一次。
|
|
71
|
+
if (!sharedFetch || sharedKey !== key) {
|
|
72
|
+
sharedWantsImage = wantsImage
|
|
73
|
+
sharedKey = key
|
|
74
|
+
// ⚠️`finally` 里只许清**自己**那一份:另一种"要不要图"的请求可能已经把这个槽换成它的了,
|
|
75
|
+
// 无条件清会让那个还在飞的请求失去单飞保护 —— 下一个实例立刻又发一次真实取景。
|
|
76
|
+
const mine = Promise.resolve(api(opts)).finally(() => {
|
|
77
|
+
if (sharedFetch === mine) { sharedFetch = null; sharedWantsImage = null; sharedKey = null }
|
|
78
|
+
})
|
|
79
|
+
sharedFetch = mine
|
|
80
|
+
}
|
|
81
|
+
return sharedFetch
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** 距上次动作多久 → "刚刚 / 3 秒前 / 2 分钟前"。 */
|
|
85
|
+
function ago(ms) {
|
|
86
|
+
const s = Math.max(0, Math.round(Number(ms) || 0) / 1000)
|
|
87
|
+
if (s < 1.5) return '刚刚'
|
|
88
|
+
if (s < 60) return `${Math.round(s)} 秒前`
|
|
89
|
+
return `${Math.round(s / 60)} 分钟前`
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** 标题栏文案:App 名 + 窗口标题,都没有就退回窗口号。 */
|
|
93
|
+
function describe(frame) {
|
|
94
|
+
const app = frame.app || ''
|
|
95
|
+
const title = frame.title || ''
|
|
96
|
+
if (app && title) return `${app} — ${title}`
|
|
97
|
+
return app || title || (frame.windowId ? `窗口 #${frame.windowId}` : '未知窗口')
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ── 缩放的纯几何(抽出来是为了可单测:算错只会"看着不对劲",不会报错)────────────────
|
|
101
|
+
|
|
102
|
+
/** 整幅塞进舞台(等价 object-fit:contain)。量不到尺寸时按 1 处理。 */
|
|
103
|
+
function fitScaleOf(natW, natH, stageW, stageH) {
|
|
104
|
+
if (!(natW > 0 && natH > 0 && stageW > 0 && stageH > 0)) return 1
|
|
105
|
+
return Math.min(stageW / natW, stageH / natH)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* 画布(.cu-live-viewport)在舞台里的实际尺寸 —— **画布的宽高比 = 窗口的宽高比**。
|
|
110
|
+
* 于是画面严丝合缝地铺满画布,内部零留白;剩下的空间落在画布**之外**,是卡片底色而不是
|
|
111
|
+
* 画中的黑边。这就是"根据窗口大小自适应调节"。
|
|
112
|
+
*
|
|
113
|
+
* 曾经的做法是画布占满整格、再用 fit/cover 启发式在"留黑边"与"裁掉一半界面"之间二选一 ——
|
|
114
|
+
* 两头都不讨好(用户实报留白)。让画布自己变形就没有这个取舍了。
|
|
115
|
+
*/
|
|
116
|
+
function viewportBoxOf(natW, natH, stageW, stageH) {
|
|
117
|
+
if (!(natW > 0 && natH > 0 && stageW > 0 && stageH > 0)) return { w: stageW || 0, h: stageH || 0 }
|
|
118
|
+
const scale = Math.min(stageW / natW, stageH / natH)
|
|
119
|
+
return { w: natW * scale, h: natH * scale }
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* 平移量夹紧:图比舞台大时不许把边拖进舞台内部(否则会拖出一片空白再也找不回图);
|
|
124
|
+
* 图比舞台小时一律居中。返回的是图片左上角相对舞台左上角的偏移。
|
|
125
|
+
*/
|
|
126
|
+
function clampOffset(off, scaled, stage) {
|
|
127
|
+
if (scaled <= stage) return (stage - scaled) / 2
|
|
128
|
+
return Math.min(0, Math.max(stage - scaled, off))
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** 以 anchor(舞台坐标)为锚点缩放后的新偏移 —— 光标下的那个像素保持不动。 */
|
|
132
|
+
function anchoredOffset(off, anchor, oldScale, newScale) {
|
|
133
|
+
return anchor - (anchor - off) * (newScale / oldScale)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* 缩放上限 8 倍原始像素;下限"整幅可见",再往下缩只是徒增空白。
|
|
138
|
+
* ⚠️例外:小窗口塞进大面板时 fit 会 >1,此时"整幅可见"已经是放大插值,
|
|
139
|
+
* 若拿它当下限,双击去 1:1 看真实像素就被夹回去、永远到不了。所以下限取 min(fit, 1)。
|
|
140
|
+
*/
|
|
141
|
+
const MAX_SCALE = 8
|
|
142
|
+
function clampScale(scale, fit) {
|
|
143
|
+
return Math.min(MAX_SCALE, Math.max(Math.min(fit, 1), scale))
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const CSS = `
|
|
147
|
+
.cu-live{display:flex;flex-direction:column;height:100%;min-height:0;background:var(--bg-card,var(--bg,transparent));color:var(--text,inherit);font-size:13px}
|
|
148
|
+
.cu-live-head{display:flex;align-items:center;gap:8px;padding:8px 12px;border-bottom:1px solid var(--border,rgba(128,128,128,.25));flex:0 0 auto}
|
|
149
|
+
.cu-live-dot{width:8px;height:8px;border-radius:999px;background:var(--accent,#ff7818);flex:0 0 auto}
|
|
150
|
+
.cu-live-dot.idle{background:var(--text-faint,rgba(128,128,128,.6))}
|
|
151
|
+
.cu-live-title{flex:1 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-weight:500}
|
|
152
|
+
.cu-live-age{flex:0 0 auto;color:var(--text-muted,var(--text-light,rgba(128,128,128,.9)));font-variant-numeric:tabular-nums}
|
|
153
|
+
.cu-live-zoom{display:none;align-items:center;gap:2px;flex:0 0 auto}
|
|
154
|
+
.cu-live-zoom.on{display:flex}
|
|
155
|
+
.cu-live-zoom button{font:inherit;line-height:1;padding:3px 7px;border-radius:7px;border:1px solid transparent;background:transparent;color:var(--text-muted,var(--text-light,inherit));cursor:pointer}
|
|
156
|
+
.cu-live-zoom button:hover:not(:disabled){background:var(--accent-light,var(--overlay-light,rgba(128,128,128,.14)));color:var(--accent-hover,var(--accent,inherit))}
|
|
157
|
+
.cu-live-zoom button:disabled{opacity:.38;cursor:default}
|
|
158
|
+
.cu-live-pct{min-width:4.2em;text-align:center;font-variant-numeric:tabular-nums}
|
|
159
|
+
/* ⚠️舞台**不留 padding**:画布尺寸是拿 stage.clientWidth/Height 算的,而 client* 是**含 padding** 的,
|
|
160
|
+
留了 padding 就等于把它当成可用空间,画布必然溢出(实测 382 高的舞台画布也是 382,顶穿内边距)。
|
|
161
|
+
实时画面本来就该像屏幕共享那样满铺,不需要这圈缝。 */
|
|
162
|
+
.cu-live-stage{position:relative;flex:1 1 auto;min-height:0;display:flex;align-items:center;justify-content:center;overflow:hidden;touch-action:none}
|
|
163
|
+
/* 画布的宽高由 JS 按窗口比例算好写死(layoutViewport)。⚠️别改回 CSS aspect-ratio:
|
|
164
|
+
它只推导「没被指定的那一边」,两种朝向不能同时成立 —— width:100% 时高被 max-height 夹掉、
|
|
165
|
+
宽不会跟着缩(实测宽屏窗口画布停在 620x362,留白原样回来);换成 height:100% 则极宽窗口的
|
|
166
|
+
宽被 max-width 夹掉,同一个病换个方向。真浏览器量出来的,别再试。 */
|
|
167
|
+
/* ⚠️flex:0 0 auto 不能少:画布是 flex item,默认 flex-shrink:1 会在它比舞台宽时把宽压回去,
|
|
168
|
+
高却不动 —— 比例当场就错了(实测 640x150 被压成 620x150)。尺寸只许 layoutViewport 说了算。 */
|
|
169
|
+
.cu-live-viewport{position:relative;flex:0 0 auto;overflow:hidden;border-radius:3px;background:var(--overlay-subtle,rgba(128,128,128,.06))}
|
|
170
|
+
.cu-live-viewport.zoomed{cursor:grab}
|
|
171
|
+
.cu-live-viewport.panning{cursor:grabbing}
|
|
172
|
+
.cu-live-viewport img{position:absolute;top:0;left:0;transform-origin:0 0;max-width:none;max-height:none;-webkit-user-select:none;user-select:none;-webkit-user-drag:none}
|
|
173
|
+
.cu-live-msg{max-width:34em;text-align:center;line-height:1.6;color:var(--text-muted,var(--text-light,rgba(128,128,128,.9)))}
|
|
174
|
+
.cu-live-msg b{display:block;margin-bottom:6px;color:var(--text,inherit);font-weight:500}
|
|
175
|
+
`
|
|
176
|
+
|
|
177
|
+
ctx.registerView({
|
|
178
|
+
id: VIEW_ID,
|
|
179
|
+
title: '被操控的窗口',
|
|
180
|
+
mount(el) {
|
|
181
|
+
const doc = el.ownerDocument || document
|
|
182
|
+
if (!doc.getElementById('cu-live-style')) {
|
|
183
|
+
const style = doc.createElement('style')
|
|
184
|
+
style.id = 'cu-live-style'
|
|
185
|
+
style.textContent = CSS
|
|
186
|
+
doc.head.appendChild(style)
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const root = doc.createElement('div')
|
|
190
|
+
root.className = 'cu-live'
|
|
191
|
+
const head = doc.createElement('div')
|
|
192
|
+
head.className = 'cu-live-head'
|
|
193
|
+
const dot = doc.createElement('span')
|
|
194
|
+
dot.className = 'cu-live-dot idle'
|
|
195
|
+
const title = doc.createElement('span')
|
|
196
|
+
title.className = 'cu-live-title'
|
|
197
|
+
title.textContent = '被操控的窗口'
|
|
198
|
+
const age = doc.createElement('span')
|
|
199
|
+
age.className = 'cu-live-age'
|
|
200
|
+
const zoomBar = doc.createElement('span')
|
|
201
|
+
zoomBar.className = 'cu-live-zoom'
|
|
202
|
+
const mkBtn = (label, tip) => {
|
|
203
|
+
const b = doc.createElement('button')
|
|
204
|
+
b.textContent = label
|
|
205
|
+
b.title = tip
|
|
206
|
+
return b
|
|
207
|
+
}
|
|
208
|
+
const btnOut = mkBtn('−', '缩小')
|
|
209
|
+
const btnPct = mkBtn('100%', '恢复默认比例(整幅可见)')
|
|
210
|
+
btnPct.className = 'cu-live-pct'
|
|
211
|
+
const btnIn = mkBtn('+', '放大')
|
|
212
|
+
zoomBar.append(btnOut, btnPct, btnIn)
|
|
213
|
+
head.append(dot, title, age, zoomBar)
|
|
214
|
+
const stage = doc.createElement('div')
|
|
215
|
+
stage.className = 'cu-live-stage'
|
|
216
|
+
root.append(head, stage)
|
|
217
|
+
el.appendChild(root)
|
|
218
|
+
|
|
219
|
+
// 画布:宽高比跟着被操控窗口走(见 CSS 里的 --cu-ar)。缩放/平移的取景框就是它,不是整个舞台。
|
|
220
|
+
const viewport = doc.createElement('div')
|
|
221
|
+
viewport.className = 'cu-live-viewport'
|
|
222
|
+
const img = doc.createElement('img')
|
|
223
|
+
img.alt = '正在被操控的窗口画面'
|
|
224
|
+
viewport.appendChild(img)
|
|
225
|
+
const msg = doc.createElement('div')
|
|
226
|
+
msg.className = 'cu-live-msg'
|
|
227
|
+
|
|
228
|
+
// ── 缩放/平移状态 ────────────────────────────────────────────────
|
|
229
|
+
// natW/natH = 这一帧画面的像素尺寸;scale = 相对原始像素的倍率(100% = 1:1);
|
|
230
|
+
// atFit = 还停在默认比例(未被用户缩放过)——**新帧到来时只有 atFit 才重新贴合**,
|
|
231
|
+
// 否则用户刚放大到某个按钮上,下一秒的帧就把他弹回全景了。
|
|
232
|
+
let natW = 0
|
|
233
|
+
let natH = 0
|
|
234
|
+
let scale = 1
|
|
235
|
+
let atFit = true
|
|
236
|
+
let offX = 0
|
|
237
|
+
let offY = 0
|
|
238
|
+
let shownWindowId = null
|
|
239
|
+
// 上一帧的序号。带回给 helper,画面没变它就只回一句 unchanged —— 静止的窗口占绝大多数,
|
|
240
|
+
// 不带这个的话同一张图会被每秒重编 8 次 JPEG + base64 + 过 IPC + 换一次 <img src>,纯白烧。
|
|
241
|
+
let shownFrameSeq = null
|
|
242
|
+
|
|
243
|
+
/** 把画布变形成这一帧窗口的形状。几何全从它量,所以任何改变 natW/natH 或舞台尺寸的事之后都得先调它。 */
|
|
244
|
+
const layoutViewport = () => {
|
|
245
|
+
const box = viewportBoxOf(natW, natH, stage.clientWidth || 0, stage.clientHeight || 0)
|
|
246
|
+
viewport.style.width = `${box.w}px`
|
|
247
|
+
viewport.style.height = `${box.h}px`
|
|
248
|
+
return box
|
|
249
|
+
}
|
|
250
|
+
/** 取景框 = 画布(已按窗口比例变形),不是舞台 —— 舞台里画布之外那圈是卡片留白,不参与取景。 */
|
|
251
|
+
const stageBox = () => ({
|
|
252
|
+
w: viewport.clientWidth || 0,
|
|
253
|
+
h: viewport.clientHeight || 0,
|
|
254
|
+
})
|
|
255
|
+
/**
|
|
256
|
+
* 缩放下限 = 整幅可见。画布与画面同比例,所以这同时就是"恰好铺满画布"——
|
|
257
|
+
* fit 与 cover 在这里是同一个值,不再需要在留黑边和裁画面之间做取舍。
|
|
258
|
+
*/
|
|
259
|
+
const fitScale = () => {
|
|
260
|
+
const { w, h } = stageBox()
|
|
261
|
+
return fitScaleOf(natW, natH, w, h)
|
|
262
|
+
}
|
|
263
|
+
const defaultScale = fitScale
|
|
264
|
+
|
|
265
|
+
const paintZoom = () => {
|
|
266
|
+
const fit = fitScale()
|
|
267
|
+
const zoomed = scale > fit * 1.001
|
|
268
|
+
img.style.width = `${natW}px`
|
|
269
|
+
img.style.height = `${natH}px`
|
|
270
|
+
img.style.transform = `translate(${offX}px, ${offY}px) scale(${scale})`
|
|
271
|
+
// 放大到超过原始像素时关掉平滑,让人看清真实像素(这正是"查看细节"要的)
|
|
272
|
+
img.style.imageRendering = scale > 1.5 ? 'pixelated' : 'auto'
|
|
273
|
+
btnPct.textContent = `${Math.round(scale * 100)}%`
|
|
274
|
+
btnOut.disabled = !zoomed
|
|
275
|
+
btnIn.disabled = scale >= MAX_SCALE - 1e-6
|
|
276
|
+
viewport.className = `cu-live-viewport${zoomed ? ' zoomed' : ''}`
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** 回到默认比例(恰好铺满画布),居中。 */
|
|
280
|
+
const resetZoom = () => {
|
|
281
|
+
scale = defaultScale()
|
|
282
|
+
atFit = true
|
|
283
|
+
const { w, h } = stageBox()
|
|
284
|
+
// ⚠️传居中值而不是 0:亚像素误差会让画面比画布大上零点几个像素,0 在合法区间内 →
|
|
285
|
+
// 贴左上角对齐,于是那零点几像素全被切在右/下边。居中才是"取中间那块"。
|
|
286
|
+
offX = clampOffset((w - natW * scale) / 2, natW * scale, w)
|
|
287
|
+
offY = clampOffset((h - natH * scale) / 2, natH * scale, h)
|
|
288
|
+
paintZoom()
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** 以舞台坐标 (ax, ay) 为锚点缩放到 next。 */
|
|
292
|
+
const zoomTo = (next, ax, ay) => {
|
|
293
|
+
const fit = fitScale()
|
|
294
|
+
const target = clampScale(next, fit)
|
|
295
|
+
if (Math.abs(target - scale) < 1e-6) return
|
|
296
|
+
const { w, h } = stageBox()
|
|
297
|
+
offX = anchoredOffset(offX, ax, scale, target)
|
|
298
|
+
offY = anchoredOffset(offY, ay, scale, target)
|
|
299
|
+
scale = target
|
|
300
|
+
// 只有回到默认比例才算"没自己缩放过";落在默认与 fit 之间也算用户在取景。
|
|
301
|
+
atFit = Math.abs(scale - defaultScale()) < 1e-3
|
|
302
|
+
offX = clampOffset(offX, natW * scale, w)
|
|
303
|
+
offY = clampOffset(offY, natH * scale, h)
|
|
304
|
+
paintZoom()
|
|
305
|
+
}
|
|
306
|
+
const zoomByStep = (factor) => {
|
|
307
|
+
const { w, h } = stageBox()
|
|
308
|
+
zoomTo(scale * factor, w / 2, h / 2)
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/** 画布形状可能变了(换窗口 / 窗口改尺寸 / 面板被拖动)→ 重新变形,再收拾取景。
|
|
312
|
+
* 还停在默认比例就整个复位;用户正放大着就守住倍率,但**必须把平移夹回新边界** ——
|
|
313
|
+
* paintZoom 只画不夹,旧偏移相对新画布可能已经越界,画面会整块滑出去。 */
|
|
314
|
+
const reflow = (prevNatW) => {
|
|
315
|
+
if (!natW) return
|
|
316
|
+
layoutViewport()
|
|
317
|
+
if (atFit) return resetZoom()
|
|
318
|
+
// ⚠️同一个窗口换了**编码分辨率**(取景放大时我们主动要更高清的原图,maxDimension 800→1600):
|
|
319
|
+
// scale 是"相对原始像素"的倍率,像素翻倍而 scale 不动,画面就凭空放大一倍、锚点也跟着跳。
|
|
320
|
+
// 按像素比反向补偿,保住用户看到的大小。窗口真的变大时这样做同样对 —— 视觉尺寸不该因此突变。
|
|
321
|
+
if (prevNatW > 0 && prevNatW !== natW) scale *= prevNatW / natW
|
|
322
|
+
const { w, h } = stageBox()
|
|
323
|
+
scale = clampScale(scale, fitScale())
|
|
324
|
+
offX = clampOffset(offX, natW * scale, w)
|
|
325
|
+
offY = clampOffset(offY, natH * scale, h)
|
|
326
|
+
paintZoom()
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** 画布正挂在舞台上(而不是被错误说明顶掉了)—— 所有交互的前提。 */
|
|
330
|
+
const showingImage = () => viewport.parentNode === stage
|
|
331
|
+
|
|
332
|
+
/** 只换文本,不重建节点 —— 每秒重建几次 DOM 没必要,也会让选中/滚动位置抖。 */
|
|
333
|
+
const showMessage = (headline, detail) => {
|
|
334
|
+
if (msg.parentNode !== stage) stage.replaceChildren(msg)
|
|
335
|
+
zoomBar.className = 'cu-live-zoom'
|
|
336
|
+
msg.replaceChildren()
|
|
337
|
+
const b = doc.createElement('b')
|
|
338
|
+
b.textContent = headline
|
|
339
|
+
msg.append(b, doc.createTextNode(detail || ''))
|
|
340
|
+
}
|
|
341
|
+
const showImage = (dataUrl, w, h, forceReset) => {
|
|
342
|
+
const resized = w !== natW || h !== natH
|
|
343
|
+
const prevNatW = natW
|
|
344
|
+
natW = w || natW
|
|
345
|
+
natH = h || natH
|
|
346
|
+
img.src = dataUrl
|
|
347
|
+
if (!showingImage()) stage.replaceChildren(viewport)
|
|
348
|
+
zoomBar.className = 'cu-live-zoom on'
|
|
349
|
+
// 换了窗口一律复位:尺寸恰好相同时 resized=false,不强制的话新窗口会套用上一个窗口的取景框。
|
|
350
|
+
if (forceReset) { layoutViewport(); return resetZoom() }
|
|
351
|
+
if (resized) return reflow(prevNatW)
|
|
352
|
+
paintZoom()
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// ── 交互:滚轮/触控板缩放、拖拽平移、按钮、键盘 ──────────────────────
|
|
356
|
+
const offs = []
|
|
357
|
+
const on = (target, type, fn, opts) => {
|
|
358
|
+
if (!target.addEventListener) return
|
|
359
|
+
target.addEventListener(type, fn, opts)
|
|
360
|
+
offs.push(() => target.removeEventListener(type, fn, opts))
|
|
361
|
+
}
|
|
362
|
+
/** 事件坐标 → **画布**坐标(offX/offY 就是相对画布左上角量的,拿舞台去减会整体错位)。 */
|
|
363
|
+
const localPoint = (e) => {
|
|
364
|
+
const r = viewport.getBoundingClientRect ? viewport.getBoundingClientRect() : { left: 0, top: 0 }
|
|
365
|
+
return { x: e.clientX - r.left, y: e.clientY - r.top }
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
on(btnIn, 'click', () => zoomByStep(1.4))
|
|
369
|
+
on(btnOut, 'click', () => zoomByStep(1 / 1.4))
|
|
370
|
+
on(btnPct, 'click', () => resetZoom())
|
|
371
|
+
|
|
372
|
+
// 触控板捏合在 macOS 上就是 ctrlKey 的 wheel;普通滚轮这里也当缩放(它是个看图器)。
|
|
373
|
+
// passive:false + preventDefault:否则滚轮会冒泡去滚外层面板。
|
|
374
|
+
on(viewport, 'wheel', (e) => {
|
|
375
|
+
if (!showingImage()) return
|
|
376
|
+
e.preventDefault()
|
|
377
|
+
const p = localPoint(e)
|
|
378
|
+
const factor = Math.exp(-(e.deltaY || 0) * (e.ctrlKey ? 0.01 : 0.0022))
|
|
379
|
+
zoomTo(scale * factor, p.x, p.y)
|
|
380
|
+
}, { passive: false })
|
|
381
|
+
|
|
382
|
+
// 双击:默认比例 ↔ 1:1 原始像素,在光标处切换
|
|
383
|
+
on(viewport, 'dblclick', (e) => {
|
|
384
|
+
if (!showingImage()) return
|
|
385
|
+
const p = localPoint(e)
|
|
386
|
+
if (atFit) zoomTo(1, p.x, p.y)
|
|
387
|
+
else resetZoom()
|
|
388
|
+
})
|
|
389
|
+
|
|
390
|
+
let drag = null
|
|
391
|
+
on(viewport, 'pointerdown', (e) => {
|
|
392
|
+
if (!showingImage() || scale <= fitScale() * 1.001) return
|
|
393
|
+
drag = { id: e.pointerId, x: e.clientX, y: e.clientY }
|
|
394
|
+
if (viewport.setPointerCapture) viewport.setPointerCapture(e.pointerId)
|
|
395
|
+
viewport.className = 'cu-live-viewport zoomed panning'
|
|
396
|
+
})
|
|
397
|
+
on(viewport, 'pointermove', (e) => {
|
|
398
|
+
if (!drag || e.pointerId !== drag.id) return
|
|
399
|
+
const { w, h } = stageBox()
|
|
400
|
+
offX = clampOffset(offX + (e.clientX - drag.x), natW * scale, w)
|
|
401
|
+
offY = clampOffset(offY + (e.clientY - drag.y), natH * scale, h)
|
|
402
|
+
drag.x = e.clientX
|
|
403
|
+
drag.y = e.clientY
|
|
404
|
+
img.style.transform = `translate(${offX}px, ${offY}px) scale(${scale})`
|
|
405
|
+
})
|
|
406
|
+
const endDrag = (e) => {
|
|
407
|
+
if (!drag || (e && e.pointerId !== drag.id)) return
|
|
408
|
+
drag = null
|
|
409
|
+
paintZoom()
|
|
410
|
+
}
|
|
411
|
+
on(viewport, 'pointerup', endDrag)
|
|
412
|
+
on(viewport, 'pointercancel', endDrag)
|
|
413
|
+
on(viewport, 'lostpointercapture', endDrag)
|
|
414
|
+
// ⚠️兜底:setPointerCapture 失败/不支持时,指针移出舞台再松手,pointerup 落在别处 →
|
|
415
|
+
// drag 永远清不掉,之后鼠标一动画面就跟着跑。切到别的 App 松手同理(渲染进程收不到 up)。
|
|
416
|
+
// 无参调用 endDrag 会无条件收场(它只在传了不匹配的 pointerId 时才拒绝)。
|
|
417
|
+
const forceEndDrag = () => endDrag()
|
|
418
|
+
on(doc, 'pointerup', forceEndDrag)
|
|
419
|
+
on(doc, 'pointercancel', forceEndDrag)
|
|
420
|
+
if (globalThis.window) on(window, 'blur', forceEndDrag)
|
|
421
|
+
|
|
422
|
+
// 键盘:+ / - / 0(0 = 恢复默认比例)
|
|
423
|
+
stage.setAttribute('tabindex', '0')
|
|
424
|
+
on(stage, 'keydown', (e) => {
|
|
425
|
+
if (!showingImage()) return
|
|
426
|
+
if (e.key === '+' || e.key === '=') { e.preventDefault(); zoomByStep(1.4) }
|
|
427
|
+
else if (e.key === '-' || e.key === '_') { e.preventDefault(); zoomByStep(1 / 1.4) }
|
|
428
|
+
else if (e.key === '0') { e.preventDefault(); resetZoom() }
|
|
429
|
+
})
|
|
430
|
+
|
|
431
|
+
// 面板被拖宽/收窄 → 画布要重新变形。⚠️观察的是**舞台**不是画布:画布的尺寸是我们自己写进去的,
|
|
432
|
+
// 观察它就会被自己的写入再次唤醒 → 死循环。
|
|
433
|
+
const resizeObserver = typeof ResizeObserver === 'function' ? new ResizeObserver(() => reflow()) : null
|
|
434
|
+
if (resizeObserver) resizeObserver.observe(stage)
|
|
435
|
+
|
|
436
|
+
let timer = null
|
|
437
|
+
let stopped = false
|
|
438
|
+
let inFlight = false
|
|
439
|
+
|
|
440
|
+
const schedule = (ms) => {
|
|
441
|
+
if (stopped) return
|
|
442
|
+
if (timer) clearTimeout(timer)
|
|
443
|
+
timer = setTimeout(tick, ms)
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// 可见性是**节奏**,不是开关。三态:null=还没人告诉我(当可见处理),true/false=IO 的判断。
|
|
447
|
+
// ⚠️绝不能因为"不可见"就完全停:隐藏页面里浏览器不做交集计算,IO 连首次回调都不来,
|
|
448
|
+
// 初值若是 false 就永远翻不了身(实测 ioHits=0,视图一帧都不请求)。
|
|
449
|
+
const hasObserver = typeof IntersectionObserver === 'function'
|
|
450
|
+
let onScreen = null
|
|
451
|
+
const observer = hasObserver
|
|
452
|
+
? new IntersectionObserver((entries) => {
|
|
453
|
+
const now = entries.some((e) => e.isIntersecting)
|
|
454
|
+
const appeared = now && onScreen === false
|
|
455
|
+
onScreen = now
|
|
456
|
+
// 刚露出来就立刻补一帧,别让用户对着慢速间隔干等。
|
|
457
|
+
if (appeared && !stopped && !inFlight) schedule(0)
|
|
458
|
+
}, { threshold: 0.01 })
|
|
459
|
+
: null
|
|
460
|
+
if (observer) observer.observe(root)
|
|
461
|
+
/** 明确不可见(IO 说的,或页面整个隐藏)→ 降到慢速;其余按正常节奏。 */
|
|
462
|
+
const unseen = () => onScreen === false || doc.hidden === true
|
|
463
|
+
|
|
464
|
+
async function tick() {
|
|
465
|
+
timer = null
|
|
466
|
+
if (stopped) return
|
|
467
|
+
if (inFlight) return schedule(TICK_MS)
|
|
468
|
+
const api = globalThis.window && window.tangu && window.tangu.computerUseLiveView
|
|
469
|
+
if (!api) {
|
|
470
|
+
showMessage('需要 Forsion 桌面端', '实时画面要通过桌面主进程访问 Computer Use 的本机 helper。')
|
|
471
|
+
dot.className = 'cu-live-dot idle'
|
|
472
|
+
age.textContent = ''
|
|
473
|
+
return // 不再重排:这个环境永远不会有
|
|
474
|
+
}
|
|
475
|
+
inFlight = true
|
|
476
|
+
// 慢速期主动不要图(省掉真正贵的那一步:一次窗口截屏)。记下来 —— 下面判断
|
|
477
|
+
// "没图" 是我们没要,还是真的拿不到,两者的处理完全不同。
|
|
478
|
+
const wantedImage = !unseen()
|
|
479
|
+
let frame
|
|
480
|
+
try {
|
|
481
|
+
// 放大了就要更高分辨率的原图,否则"查看细节"看到的只是被放大的马赛克。
|
|
482
|
+
// 2560 是主进程那边的上限,再高会被夹回去。
|
|
483
|
+
frame = await fetchFrame(api, wantedImage
|
|
484
|
+
? { maxDimension: atFit ? 800 : 1600, quality: atFit ? 0.55 : 0.8, sinceFrame: shownFrameSeq || '' }
|
|
485
|
+
: { image: false })
|
|
486
|
+
} catch (e) {
|
|
487
|
+
frame = { active: false, error: String((e && e.message) || e) }
|
|
488
|
+
} finally {
|
|
489
|
+
inFlight = false
|
|
490
|
+
}
|
|
491
|
+
if (stopped) return
|
|
492
|
+
|
|
493
|
+
if (!frame.active) {
|
|
494
|
+
dot.className = 'cu-live-dot idle'
|
|
495
|
+
title.textContent = '被操控的窗口'
|
|
496
|
+
age.textContent = ''
|
|
497
|
+
showMessage('暂无正在被操控的窗口', explain(frame.error) || '让 agent 用 computer use 的工具动一下,画面就会出现。')
|
|
498
|
+
return schedule(unseen() ? HIDDEN_TICK_MS : IDLE_TICK_MS)
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
dot.className = 'cu-live-dot'
|
|
502
|
+
const label = describe(frame)
|
|
503
|
+
title.textContent = label
|
|
504
|
+
title.title = label
|
|
505
|
+
age.textContent = ago(frame.ageMs)
|
|
506
|
+
// 换了个窗口就回到默认比例:上一个窗口的取景框套在新窗口上毫无意义(还可能停在一片空白上)
|
|
507
|
+
const switchedWindow = Boolean(frame.windowId) && frame.windowId !== shownWindowId
|
|
508
|
+
if (switchedWindow) { shownWindowId = frame.windowId; shownFrameSeq = null }
|
|
509
|
+
if (frame.jpegBase64) {
|
|
510
|
+
shownFrameSeq = frame.frameSeq || null
|
|
511
|
+
showImage(`data:image/jpeg;base64,${frame.jpegBase64}`, frame.width, frame.height, switchedWindow)
|
|
512
|
+
}
|
|
513
|
+
// helper 说"和你上次看到的那一帧一样":静止的窗口本来就该长这样,保留现有画面,只刷标题与时间。
|
|
514
|
+
else if (frame.unchanged) { /* 画面没变 */ }
|
|
515
|
+
// 慢速期是**我们自己没要图**(image:false),不是拿不到 —— 保留正在显示的那一帧,
|
|
516
|
+
// 别把画面换成"拿不到这一帧画面"。真拿不到(该给图却没给)才报。
|
|
517
|
+
else if (!wantedImage) { /* 保留现有画面,只刷新标题与时间 */ }
|
|
518
|
+
else showMessage('拿不到这一帧画面', explain(frame.error))
|
|
519
|
+
schedule(unseen() ? HIDDEN_TICK_MS : TICK_MS)
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
void tick()
|
|
523
|
+
|
|
524
|
+
return () => {
|
|
525
|
+
stopped = true
|
|
526
|
+
if (timer) clearTimeout(timer)
|
|
527
|
+
if (observer) observer.disconnect()
|
|
528
|
+
if (resizeObserver) resizeObserver.disconnect()
|
|
529
|
+
offs.forEach((f) => f())
|
|
530
|
+
offs.length = 0
|
|
531
|
+
img.src = ''
|
|
532
|
+
el.replaceChildren()
|
|
533
|
+
}
|
|
534
|
+
},
|
|
535
|
+
})
|
|
536
|
+
|
|
537
|
+
// 纯函数暴露给 check.mjs(宿主装载时 ctx 存在,check 里只取这个挂点)。
|
|
538
|
+
globalThis.__CU_LIVE_TEST__ = {
|
|
539
|
+
explain, ago, describe, PLUGIN_ID, VIEW_ID, TICK_MS, IDLE_TICK_MS,
|
|
540
|
+
fitScaleOf, viewportBoxOf, fetchFrame, clampOffset, anchoredOffset, clampScale, MAX_SCALE, HIDDEN_TICK_MS,
|
|
541
|
+
}
|