Skip to content

从电脑上传 FPK 到 NAS 并安装 ​

本册是操作手册,不是需求文档。它回答一个具体问题:手里有一个 .fpk,怎么把它装到真实 fnOS NAS 上、怎么确认装对了、出错了怎么定位。

与相关文档的分工:

文档负责什么
本册上传 → 安装 → 验证 → 排障的操作步骤与踩坑记录
应用上架规范上架申请(飞书表单)的字段口径与官方规则自查
发布流程发版(打 Tag → CI 出 Release → 取得 FPK)
生命周期脚本install_callback / upgrade_callback 各自做什么

0. 先看这一屏:最短路径 ​

bash
# 1. 取得 FPK(二选一)
#    a) CI 产物(推荐,与线上同一份)
gh release download v<版本号> --pattern "*.fpk" --dir tmp/fpk-upload
#    b) 本地构建
pnpm run build -- --fpk --app fn-deepseek-harness

# 2. 确认版本与 NAS 上已装的**不同**(相同会被直接拒绝)
# 3. 打开 NAS → 应用中心 → 手动安装 → 从电脑上传 → 选文件 → 同意 → 向导 → 下一步 → 安装
# 4. 装完按第 5 节验证

其余章节是这条路径上每一个「为什么」。


1. 前置条件 ​

条件说明不满足的后果
版本必须与 NAS 上已装的不同fnOS 按版本号判断走「安装」还是「升级」。相同版本会被拒报错「已安装相同或更高版本」,流程走不下去
NAS 登录态有效fnOS 网页端的会话 cookie页面停在登录页,后续步骤全部落空
浏览器能访问 NAS默认 http://<NAS-IP>:5666/找不到应用中心
FPK 完整见第 2 节安装回调缺文件、升级失败
自动化时:Midscene 桥与模型就绪见第 4 节模型调用失败,界面步骤无法执行

版本号必须是「可比较」的 ​

fnOS 的比较逻辑:三段式 x.y.z,x.y.z-m 里的 m 只有数字才有版本对比逻辑,非数字后缀一律「视为同一版本」。

本项目的版本是纯净三段式(如 5.10.0,见版本管理),fnOS 可正常按三段比较判定升级:5.10.0 → 5.10.1 是更高版本,能触发升级路径。

历史注记:5.10.0 之前的版本号带非数字小尾巴(如 5.9.0-0.2.1-rc.2.12),对 fnOS 等价于 5.9.0——同基线内递增小尾巴不触发升级。该形态已被 FNOS-028 取代;若设备上还装着小尾巴版本,升级到 5.10.0 属于正常的大版本升级。

实践做法:每次装机验证前先 pnpm run version 升版本,这是最省事的避坑方式。


2. FPK 从哪来、里面有什么 ​

两种来源 ​

来源命令用途
CI Release 附件gh release download v<版本号> --pattern "*.fpk"装机验证首选 —— 与线上分发的是同一份
本地构建pnpm run build -- --fpk --app fn-deepseek-harness迭代快;产物落在 apps/fn-deepseek-harness/*.fpk

两种来源的产物同名同格式:fn-deepseek-harness-<Tag>-dsh-<DSH 基线>.fpk。本地构建在 fnpack 打完包后自己完成重命名,规则与 CI 的「Rename FPK with DSH version」一致,因此不必再手工改名,也不会出现同名的两份不同产物。注意 dsh- 段是 DSH 运行时基线、不是应用版本;应用版本以 Tag 为准。

本地没有 CI 那层 release_tag 输入,Tag 取当前提交命中的 git tag;没有精确 tag 时退回 manifest 版本并补 v 前缀(即 pnpm run version 打出的形态)。

「瘦包」是设计,不是构建坏了 ​

CI 固定带 --skip-bundle-dsh-plugins,FPK 里不含 bundled-dsh-plugins/(插件改为安装时从 npm 拉取)。所以一个只有 ~160KB 的 FPK 是正常的;本地构建(默认内置插件)会到 2MB 以上。

判断一个 FPK 是否正常,看它的 app.tgz 里有没有 bundled-dsh-plugins/:

bash
tar -xzf <file>.fpk app.tgz && tar -tzf app.tgz | head
  • 有 bundled-dsh-plugins/ → 本地构建(内置插件)
  • 没有 → CI 构建(瘦包,插件走 npm)

两种都能装,区别在升级路径:从「本地内置版」升到「CI 瘦包」时,旧 profile 里 file: 引用的插件归档在新包里不存在,会触发第 6 节的 ENOENT 问题。


3. 人工路径:界面上到底怎么点 ​

fnOS 桌面
 └─ 打开「应用中心」
     └─ 找到目标应用(未安装时列表里没有,需要走「手动安装」)
         └─ 点左下角「手动安装」
             └─ 「从电脑上传」
                 └─ 在文件选择框里选中 .fpk
                     └─ 同意协议 → 向导「下一步」→ 等待安装

三个界面事实(都会让自动化点空):

  1. 「手动安装」在应用中心窗口的左下角,不是顶部工具栏。
  2. 桌面上打开应用中心要单击图标:目标是带 [desktopItemId] 的那个容器(不是里面的文本节点),单击即可打开——在文本节点上派发事件无效,双击也不可靠。
  3. DSH 应用窗口可能正开着并铺满屏幕,把桌面上的「应用中心」图标盖住 —— 此时点桌面图标会点到应用窗口上,表现为「点了没反应」。先关掉正在开着的应用窗口,或直接切到应用中心窗口操作。

4. 自动化路径(Midscene) ​

本节面向用 Midscene 驱动真实 Chrome 的场景。不要为此写一次性探测脚本:每写一个脚本就多一次冷启动(等桥连接)、多一份一次性资产。正确做法是写成 Midscene Test 用例 或一个常驻脚本,一次连接跑完全流程。

4.1 环境就绪 ​

组件默认位置说明
DSH midscene 插件桥126.0.0.1:8787提供模型接入配置:GET /v1/midscene-env 返回四行 MIDSCENE_MODEL_*
Midscene CLI 桥126.0.0.1:3766Chrome 扩展连到这里;同一时刻只能有一个进程占用
Chrome 扩展Bridge 模式扩展 ID gbldofcpkknbggpkmbdaefngejllnief

取接入配置:

bash
curl -s http://126.0.0.1:8787/v1/midscene-env

设成环境变量后即可用 @midscene/web 的 AgentOverChromeBridge 或 midscene-test。

4.2 静默:不要抢前台 ​

后台标签页(不切到前台、不抢用户鼠标):

js
await agent.connectNewTabWithUrl(url, { activateTab: false })   // 新开后台标签页
await agent.page.setActiveTabId(Number.parseInt(String(id), 10), { activate: false })  // 绑定已有标签页
  • activate: false 必须显式传。undefined 透传下去会落到 connectTarget 自己的缺省值 true(抢前台)—— 共享包 defineBridgeSetup 已把缺省改为 ?? false,但自己写脚本时仍要显式传。
  • tabId 必须是数字。传字符串会在后续所有调用上抛 Invalid type: expected integer, found string。

4.3 选标签页:不要取「第一个匹配」 ​

同一个 URL 可能开着多个标签页(其中一份停在登录页)。tabs.find(url => url.includes(...)) 取到的是第一个,很可能正是那张失效页,于是用例被绑到登录页,症状是「当前显示的是登录页面」。

用共享包的 selectTab(优先当前活动页),或显式挑:

js
const tabs = await agent.getBrowserTabList()
const target = tabs.filter(t => String(t.url || '').includes('shrcn9ewk')).at(-1)

4.4 上传文件:直接注入 input[type=file],不要走系统文件选择器 ​

这是本册最重要的一条:不要用 aiAct + fileChooserAccept 上传 FPK。 那条路依赖视觉定位与系统文件选择器,在真实 NAS 上不可靠(2026-10-26 实测)。

正确做法是用共享包的 dsh.uploadFile 节点:Node 侧读文件 → base64 传入页面 → 页面内构造 File 塞进 input[type=file] → 派发完整事件序列。零模型调用,全确定性。

yaml
- dsh.uploadFile:
    label: 注入 FPK 到「从电脑上传」
    # 用例用解析好的完整路径,不写死版本号
    path: ${fpkPath}
    accept: .fpk
    expectText: 未经验证应用的安全提示

为什么旧路径不能用——实测失败全部发生在视觉与文件选择器环节,与被测物无关:

失败形态实测现象
模型限流429 使用量已超出频率限制 → 502 额度已用尽,上传步骤整体失败
扩展断连Error: Connection lost, reason: client namespace disconnect(上传步骤跑 4 分钟后)
桥调用超时Bridge call timeout after 30000ms: getBrowserTabList
重规划死循环planning cycle limit (20) has been reached(模型反复找桌面上的应用中心图标)

界面上必须知道的六个事实(前两条会让注入「看起来成功但应用不消费」):

  1. 必须先切上传来源。对话框默认处于**「从 NAS 添加」形态,此时页面上常驻的两枚 input[type=file] 属于该形态:往里塞文件虽然「成功」(input.files 有值、字节可读),但应用不消费**——等满 30 秒也不会弹安全提示。正确顺序是先点「从电脑上传」切到电脑形态,再往被该点击请求打开选择器的那枚 input 注入。切换时应用会调用 input.click(),先把这个 click() 拦下即可既不弹系统弹窗又完成切换;实测切换后 React 会立刻接管并清空 input,安全提示随即出现。dsh.uploadFile 已内置这一步。
  2. 打开对话框要容忍页面未就绪。项目 setup 会先把复用页导航回目标地址,SPA 渲染需要时间:导航后立刻查找会报「既无『手动安装』也无『应用中心』入口」。脚本内短等轮询 + Node 侧重试才稳(dsh.uploadFile 已内置)。
  3. 「从电脑上传」是「隐藏 input + label」模式:input[type=file] 常驻且一直是 0×0,点按钮不会新建 input。直接给那个隐藏 input 赋 files 即可。
  4. 页面里还有一个 webkitdirectory 的目录 input,必须按属性排除,否则会注进目录选择器。
  5. 事件序列必须完整:pointerdown → pointerup → click → input → change。只派发 input/change 时 fnOS 收不到文件——不弹安全提示,input.files 也不会被清空。
  6. 单次页面脚本不能长等:Bridge 单次调用上限 30 秒。把「开对话框」与「注入」分步,等待判定放到 Node 侧轮询(expectText + timeoutMs 就是干这个的)。

文件被接收的可观察证据:fnOS 立刻弹出「未经验证应用的安全提示」;随后 React 会清空 input(files.length 归 0),这是正常现象,不要据此判失败。

完整可执行用例见 apps/fn-deepseek-harness/tests/e2e/cases/FNOS-013-nas-install-from-computer.yaml。

4.5 点击与坐标 ​

  • aiLocate() 返回的坐标已归一化到 CSS 像素,可直接用于点击。NAS 页面 devicePixelRatio = 2,截图宽 3840 而视口宽 1920。
  • 但视觉模型偶尔会算出越界坐标(实测报 coordinates [...] exceed normalized range [0, 1000]),表现为动作被上游拒绝。多半是导航步骤太重:把「打开应用中心 → 找到应用」合并成一次 aiAct,比拆成 4~6 步更稳,也快得多(每一步都是一次模型调用,15–90 秒)。
  • 小控件(行高 < 40px 的菜单项、页签、图标按钮)优先走页面内 element.click()(共享包的 clickInPage 节点):稳定、零坐标偏差。实测药丸按钮真实位置 (648, 893),而视觉定位落点曾是 (2774, 1817)——完全在视口外,aiAct 还自述「已成功点击」。

4.6 失败要如实报,不要静默通过 ​

javascript 步骤里的 throw 不会让用例失败:底层是 CDP Runtime.evaluate,页面异常被放进 result.exceptionDetails 返回而不转成错误。需要断言时用共享包的 assertInPage 节点(页面内精确判定 + Node 侧抛错传播,零模型调用、毫秒级),或 aiAssert。

4.7 不留痕 ​

  • 不得清除被测站点的 Cookie / localStorage / sessionStorage / IndexedDB。被测页面是用户正在用的真实环境。
  • 不得创建持久会话;需要切换会话的用例必须记住原会话并在收尾切回。
  • 只删用例自己建的探针数据。
  • 临时脚本只放 tmp/(git 忽略)。

5. 装完怎么验证 ​

按「便宜 → 贵」排序,先用便宜的排除问题:

层次检查判据
1. 文件层FPK 里有 manifest、cmd/、app.tgztar -tzf <file>.fpk
2. 清单层FPK 里的 published-dsh-plugins.json项数与 apps/fn-deepseek-harness/app/published-dsh-plugins.json 一致
3. 界面层应用中心详情页版本号 = 目标版本、来源 = 手动安装
4. 模块图NAS 外壳 HTML 里的 plugins/?? 引用引用的插件集合与预期一致
5. 运行层打开应用,界面正常关键面板可打开、插件已加载
6. 日志层应用日志无 [ERROR]无安装回调失败

读 NAS 外壳时注意:不要用 [^"'&]* 之类的字符类截断 —— & 会让匹配提前结束,漏掉后面的插件,得出「不包含 X」的错误结论。核对模块图要用完整的 plugins/?? 引用块。


6. 排障表 ​

现象根因处理
「已安装相同或更高版本」版本与 NAS 上相同或更旧pnpm run version 升版本后重新构建
安装回调报 ENOENT ... bundled-dsh-plugins/@tnnevol/xxx.tgz旧 profile 里 file: 引用了本次不再提供的插件归档(本地内置版 → CI 瘦包)见 6.1
失败升级后 /vol1/@appcenter/<app>/ 缺 manifest、cmd/失败升级留下的半损坏状态,后续升级不会补回应用可运行但目录不完整;介意就卸载后重装
页面停在 fnOS 登录页NAS 会话掉线重新登录(可能需要用户本人操作)
点了「手动安装」没反应DSH 应用窗口盖住了桌面图标先关掉应用窗口
模型报 502 / Connection error上游模型瞬时故障重试;flash 模型做长规划容易翻车,换更强的模型
上传步骤 429 / 502 额度已用尽视觉模型额度或频率限制改用 dsh.uploadFile(§4.4,零模型调用)
上传步骤 Connection lost / Bridge call timeout扩展断连或单次调用超 30 秒同上改用 dsh.uploadFile;单次页面脚本不要长等
注入后不弹安全提示(input.files 却有值)没有先切「从电脑上传」来源——常驻 input 属于「从 NAS 添加」形态,应用不消费;切来源即可(§4.4 事实 1)
注入后不弹安全提示(来源已切)事件序列不完整,或注入到了目录 input补齐 pointerdown → pointerup → click → input → change;排除 webkitdirectory
报「既无『手动安装』也无『应用中心』入口」页面刚被导航回目标地址、SPA 还没渲染完轮询等待(§4.4 事实 2),不要一次性查找
桥端口 EADDRINUSE 126.0.0.1:3766另一个会话/进程占着桥先确认是谁的再决定是否接管;不要直接杀

6.1 孤儿插件与悬空引用 ​

安装回调在重建 Web profile 依赖前会先清理两类问题(实现见 apps/fn-deepseek-harness/app/scripts/install-callback-helper.mjs):

  • orphanOwnPlugins:profile 里已不在发布清单的自有插件(@tnnevol/*)→ 移除;
  • danglingBundledOwnPlugins:插件仍在清单里、但 profile 的 file: 指向的 .tgz 在新包里已不存在(本地内置版 → CI 瘦包)→ 移除该 file: 引用。

只处理 @tnnevol/*;第三方插件(如 dshmarket)的清单项语义是「确保存在」,动它会破坏用户环境。


7. 装完之后:提交上架申请 ​

装机验证通过后,按应用上架规范准备材料并填写飞书表单。要点:

  • 字段取值沿用历次提交:在表单右上角「查看提交记录」里能看到上一条提交的完整取值(应用名称、开发者、发布者、微信昵称、上架类型),保持一致即可。首次提交时没有历史值,按规范给建议值。
  • 版本号填 manifest 的 version,并留意第 1 节的「三段式」规则与本项目四段 prerelease 的冲突;审核方若以「4 段式」打回,改填三段形式(与历次提交写法一致)。
  • 表单是受控组件,只认真实键入:直接改 DOM(native setter、execCommand('insertText'))只改可见文本,不进表单状态,提交时校验与入库拿到的仍是旧值 —— 会出现「界面上填好了,提交后是空的或旧值」。按标签名让模型定位还会把多个值串到同一个输入框。
  • 提交后必须回读「我的提交记录」核对入库值,不能只看页面提示「提交成功」。
  • 若填错需要重来:点「再填一次」拿一张干净表单(控件数量与索引会变),并到提交记录里删除或撤销错误的那条 —— 否则审核方先看到脏数据。

8. 一页速查 ​

bash
# 取得 FPK
gh release download v<版本号> --pattern "*.fpk" --dir tmp/fpk-upload

# 确认版本确实变了(对比 NAS 上已装版本)
tar -xzOf tmp/fpk-upload/*.fpk manifest | grep '^version'

# 桥与模型
curl -s http://126.0.0.1:8787/v1/midscene-env
lsof -iTCP:3766 -sTCP:LISTEN        # 只能有一个占用者

# 装完核对
tar -tzf tmp/fpk-upload/*.fpk | head          # 有没有 bundled-dsh-plugins/
tar -xzOf tmp/fpk-upload/*.fpk manifest | grep -E '^(version|platform)'
记住这几句为什么
版本不同才能装相同版本被直接拒绝
上传用 dsh.uploadFile,不用 fileChooserAccept后者依赖视觉与系统选择器,实测不可靠
事件序列要完整少了指针事件 fnOS 收不到文件
单次页面脚本别长等Bridge 单次调用上限 30 秒
activate: false 要显式传缺省会抢前台
tabId 必须数字、且别取第一个匹配分别是调用失败与「绑到登录页」
DOM 直写不进城书状态提交后是旧值
提交后回读提交记录页面说成功不等于入库正确

基于 VitePress 构建