# Linux POS 装机操作指南(给装机人员)

> 全程只需要一个入口命令:
>
> **门店版(稳定版)**:
> ```bash
> cd ~/Downloads && wget -O openpos_installer.sh https://downloads.openpos.site/linux/openpos_installer.sh && bash openpos_installer.sh
> ```
>
> **前沿版(内部测试机 / 试点店)—— 注意脚本名不一样**:
> ```bash
> cd ~/Downloads && wget -O openpos_installer_edge.sh https://downloads.staging.openpos.site/linux/openpos_installer_edge.sh && bash openpos_installer_edge.sh
> ```
>
> 之后按菜单编号操作。菜单顶部会常显**当前下载源**,非门店版会带 ⚠️。
>
> 两个脚本**内容相同、名字不同**,脚本按自己的文件名决定默认通道(`*_edge*` → 前沿版)。
> 菜单顶部会常显当前下载源和它是谁定的(文件名 / 菜单18 / 环境变量 / 默认)。
>
> 通道优先级:**环境变量 > 菜单 18 记的配置(`~/.openpos-installer.conf`)> 脚本文件名 > stable**。
> 所以同一台机器上想临时换通道,进菜单选 **18) 切换下载通道** 即可,不用重下脚本。
>
> ⚠️ **历史坑(2026-07-30 已修)**:此前两站脚本连名字都一样,「从 staging 取脚本」不影响
> 「去哪取包」,用户按前沿版链接取回来跑,菜单 2/3 静默从**生产站**装了旧壳。现在前沿站上
> `openpos_installer.sh` 这个名字被一个只会指路的提示脚本占住,踩不进去。

## ★ 一键装机(`--auto`,2026-09-11 起,Issue #6391)

新机器不用记菜单编号:取脚本后加 `--auto`,开头回答几个问题,之后不再停,只在最后重启一次。

```bash
# 前沿版(内部 / 试点店)
cd ~/Downloads && wget -O openpos_installer_edge.sh https://downloads.staging.openpos.site/linux/openpos_installer_edge.sh && bash openpos_installer_edge.sh --auto
# 门店版
cd ~/Downloads && wget -O openpos_installer.sh https://downloads.openpos.site/linux/openpos_installer.sh && bash openpos_installer.sh --auto
```

也可以进菜单后输入 `auto`。`--auto --channel stable|edge` 显式换通道(与菜单 18 同一落点)。
⚠️ **不要写成 `wget -O- … | bash`**:脚本靠自己的文件名判通道,管道模式会静默落到门店版。

**问卷**(除店名外全是 y/n,直接回车取默认):

| 问题 | 默认 | 说明 |
|---|---|---|
| 店名(英文/拼音) | 必填 | Tailscale 机器名与装机记录靠它 |
| 加入远程支持(Tailscale + SSH)? | y | 发钥接口不通时只警告、跳过,装完跑菜单 0 补 |
| POS 页面走测试站? | n | **门店一律 n**。通道只决定壳包 / 刷卡服务 / 启动脚本从哪个站取,不决定页面地址 |
| 停用 MenuSifu 旧栈? | 只在审计有痕迹时问 | 新机自动跳过;与菜单 4 同一实现,可回滚 |
| 锁定内核? | y | 与菜单 23 同一实现;已锁过 / 体检未过只警告不拦 |
| 装完自动重启一次? | y | 整条链**唯一**一次重启:让 libvirt 组、内核锁、开机自启一次生效 |

**不问、自动判**:双屏(`xrandr` 数到 ≥2 台就装第二实例,client-id PC1);读卡器(`lsusb` 认到
Moby5500 才装 libvirt + 读卡器 VM + USB 重连,镜像约 10GB);client-id 固定 PC0;
`usb-swiper` 有 Moby 才写。打印机队列装后在 `config.jsonc` 填 `printerN-name`。

**固定会做**:菜单 0(选了才做)→ 1 → 2 → 写 config.jsonc → 11 →(双屏)12 → 17 → 19 →
(选了)4 → 25(/dev/shm watchdog)→(选了)23 →(有 Moby)5 → 9 → 10 → 重启。
**不再中途重启**:装 libvirt 后 `virsh` 走 sudo,不依赖重新登录。

**失败了怎么办**:任一步失败就停下并打印「从这一步重跑」。答案与已完成的步骤记在
`~/.openpos-installer-auto.env`(0600);重跑 `bash openpos_installer_edge.sh --auto --resume`
(或再跑 `--auto`,它会问「接着上次?」)跳过已完成的步骤。各步本就幂等,重跑安全。
装完文件自动删除。

## 独立工具：阻止已知问题内核 `6.8.0-136-generic`

这不是 MenuSifu 清理的一部分，也不会由菜单 4 自动执行。先只读检查：

```bash
cd ~/Downloads
wget -O kernel_guard.sh https://downloads.staging.openpos.site/linux/kernel_guard.sh
bash kernel_guard.sh --check
```

确认输出的目标内核与 GRUB 判断正确后，维护窗口再执行：

```bash
sudo bash kernel_guard.sh --apply
```

如果 `--check` 显示 `Legacy broad freeze detected: yes`，说明机器已经执行过旧版“关闭全部
自动更新 + hold HWE/MySQL”的脚本。普通 `--apply` 会拒绝继续，必须先核对输出，再显式迁移：

```bash
sudo bash kernel_guard.sh --apply --migrate-legacy-freeze
```

迁移会删除旧 apt drop-in、解除旧脚本加的 HWE meta/MySQL broad holds，并恢复两个 apt timer；
若仍有独立理由冻结 MySQL，可同时加 `--hold-mysql`。`--rollback` 会把迁移前的旧 drop-in、holds
和 timer 状态原样恢复，不会把旧冻结悄悄丢掉。

- 136 尚未安装：只写精确 apt pin，**不改 GRUB、不要求重启**；137+ 仍可更新。
- 136 已安装且当前不高于 136：选择最高的可用低版本并固定下次启动；只有当前正跑 136
  才提示需要人工重启，脚本自身绝不 reboot。固定期间只 hold 目标内核的具体 image/modules/
  headers，防 autoremove 删掉 saved entry 指向的 boot assets；不 hold HWE meta。
- 不关闭 `apt-daily`/unattended security updates；默认不 hold MySQL。确有独立理由才加
  `--hold-mysql`，它只记录并回滚本脚本新增的 hold。
- 回滚：`sudo bash kernel_guard.sh --rollback`。状态保存在
  `/var/lib/openpos/kernel-guard/active/`，回滚后移入 `history/` 留痕。

营业机器只跑 `--check`；`--apply` 和人工重启必须另约维护窗口。

## 场景 A:新装(单屏)

1. 菜单 **1**(装依赖)
2. 菜单 **2**(装 CEF 壳全量包,约 390MB)—— 装完会自动放一份 `config.jsonc` 模板
3. **编辑配置**:`~/Downloads/cef_ubuntu/Release/config.jsonc`
   - `url`:模板默认 router 统一登录(生产 `https://router.openpos.site`);要开机直落某个
     app 才改成该 app 子域(如 `https://pos.openpos.site`)
   - `client-id`:本机编号(主机一般 PC0)
   - `printerN-name`:打印机(CUPS 名,`lpstat -p` 查)
   - 要刷卡的机器:打开 `"usb-swiper": "MOBY5500_net"`
   - ⚠️ 注释只能整行写,行尾注释会让壳子解析失败;最后一个键别留尾逗号
4. 菜单 **11**(装启动脚本 + 桌面快捷方式)
5. 菜单 **17**(开机自启)—— 门店机重启后能自己把 POS 打开,不用店员操作
6. **从 MenuSifu 改装的旧机器**跑菜单 **4**(完整停用旧栈)。它会先备份再停用旧 cron、
   Demons/POS 管家、watchdog、Device Manager、Tomcat、MySQL、Webmin 和 MenuSifu OpenVPN，
   同时归档旧快捷方式与重复的 `restmesh-pos.desktop`。它**不删除旧数据目录，也不碰**
   TeamViewer、Tailscale、SSH、CUPS、libvirt/RUA 或 OpenPOS。纯新机跳过。
   - 备份在 `~/menusifu-disable-backups/<时间戳>/`；可先只读检查：
     `bash openpos_installer.sh --audit-menusifu`
   - 远程非交互执行必须显式确认：
     `bash openpos_installer.sh --disable-menusifu --yes`
   - 菜单 **19**仍保留为“只删除 `/sbin/reboot`”的窄操作，不等于完整清理。
7. 触摸屏机器加菜单 **14**(屏幕键盘)
8. 验证:双击桌面「OpenPOS POS」,应先**全屏播 4 秒品牌视频**(米白底、无声,菜单 11
   会一并下发视频并补装 `mpv` + `xdotool`;没视频直接起 CEF 也算正常),视频结束后
   后面应该**已经是**打开的 CEF(CEF 在视频后面并行启动),然后**全屏**打开 router
   统一登录页(壳子默认全屏,见 `main_context_impl.cc` 的 `g_bFullWindow`;要窗口模式在
   config.jsonc 写 `"full-window": false`)。**不是 CEF 示例页** —— 看到示例页说明
   `config.jsonc` 没生效,回第 3 步。读卡器 VM 重启不再弹终端窗口,要看进度
   `tail -f /tmp/openpos-restart-rua.log`

## 场景 B:新装(双屏)

1. 先按场景 A 装好主实例并验证
2. 菜单 **12**(装第二实例):
   - client-id 直接回车用默认 PC1(或按店里规划输入)
   - 自动完成:复制第二实例、主实例钉屏 0、副实例钉屏 1
3. 副屏要显示不同页面(如自助点单 / 客显):编辑 `~/Downloads/cef_ubuntu2/Release/config.jsonc` 的 `url`
4. 验证:双击桌面「OpenPOS POS」(**只有这一个图标**)→ 主屏出 POS、副屏出第二实例。
   只接了一块屏时,同一个图标只启动主实例。

## 场景 C:老机器升级

1. 重新下载 installer(见顶部入口命令,直接覆盖旧的)
2. 菜单 **3**(更新 CEF 壳)——**会保留 `config.jsonc` 和启动脚本**,机器上已有第二实例会一起更新
3. 菜单 **11**(更新启动脚本 / 快捷方式)
4. 从 MenuSifu 改装的机器跑菜单 **4**；已经清过或纯 OpenPOS 新机可跳过
5. **刷卡的机器:菜单 3 之后必须跑菜单 13**(读卡器保持插着)。新壳配旧的 VM 刷卡服务
   = 读卡器读到卡、页面却每笔都拒(2026-09-03 Seven Tea 真实事故)。菜单 3 跑完会自动检查,
   看到红色「刷不了卡」提示直接回车就是跑 13;想单独查用菜单 **24**
6. 验证:双击桌面图标,POS 正常起来;刷卡机器再真刷一笔


### ⚠️ 只有「手工整个换目录」才会丢登录态 —— 走菜单 3 是安全的(2026-08-04 核实)

壳子的浏览器 profile 在 **`Release/cef/`**(登录 token、选中门店、设备 License 都在里面)。

**菜单 3 不会碰它** —— 它是 `tar -xzf … -C Release`(解压覆盖,**不删旧目录**),
而 CI 的包里**根本没有 `cef/`**(实测条目数 0),所以 profile、`config.jsonc`、
启动脚本都原样留着。**日常升级请走菜单 3。**

⚠️ 但如果你**手工把整个 `Release/` 换掉**(rename 旧目录、解开新包),那就会丢:
新包解开是一个不含 `cef/` 的目录,那台机器要重新登录、**重新激活设备(烧一个 License 槽)**。
症状很像「新包坏了」(壳能起、页面能开,就是要求重新登录),容易被误判成升级失败而回滚。

真要手工换,先备份再搬回来:

```bash
cd ~/cef_ubuntu                      # 或壳子所在目录
cp -a Release/cef  /tmp/cef-backup
cp -a Release/config.jsonc /tmp/     # 新包不含它,但手工换目录一样会丢
#   …换新包…
cp -a /tmp/cef-backup Release/cef && cp -a /tmp/config.jsonc Release/
chown -R "$USER:$USER" Release
```

## 场景 D:无头打印主机(不接显示器的专职打印服务器,2026-08-13)

> 治「iPad/安卓平板当打印主机,熄屏就断打印」:一台不接显示器的 Linux 盒子常驻打印。
> 与门店收银机互不相干(独立目录 ~/openpos-printhost,不碰 cef_ubuntu),同机可共存但
> 产品上建议专机专用。

1. 菜单 **21**(一键装:依赖 + 壳 + 配置 + systemd 常驻;url 按当前下载通道自动指
   staging / 生产的 printhost 站点)
2. 建 CUPS 队列并回填 `~/openpos-printhost/Release/config.jsonc` 的 `printerN-name`
   (队列建法同「标签打印机」节的 lpadmin;改完 `sudo systemctl restart openpos-printhost`)
3. 一次性上岗:菜单 **22**(SSH 免 VNC,全在终端问答)——
   推荐**配对码**:老板在掌中宝「设备配对」页生成 6 位码给装机人,admin 密码不经过盒子,
   码即绑店;脚本自动激活 License(普通员工的码/账号会 403,要店级 admin 的)+
   引导换专职低权限账号常驻。备选:x11vnc 看虚拟屏人肉点(见下载站 `PRINT_HOST_INSTALL.md` 方式 B)
4. **切主机顺序不能反**:先停旧主机(iPad 退出 pos)→ 后台 → 设备 切到新设备 →
   状态面板横幅 60s 内变绿 ✅ → 逐槽「测试打印」验真出纸
5. 排障:`journalctl -u openpos-printhost -f`;完整手册 = 下载站 `PRINT_HOST_INSTALL.md`

## ⚠️ 换壳会丢登录态和设备激活(2026-08-04 实测)

壳子的浏览器 profile 在 **`Release/cef/`**(登录 token、选中的门店、设备 License 都在里面)。
新包解开是一个**空的** `cef/`,直接覆盖 = 那台机器要重新登录、重新激活设备(**会烧一个 License 槽**)。

升级前先备份、升级后搬回来:

```bash
cd ~/cef_ubuntu            # 或壳子所在目录
cp -a Release/cef /tmp/cef-profile-backup
#   …换新包…
rm -rf Release/cef && cp -a /tmp/cef-profile-backup Release/cef
chown -R "$USER:$USER" Release/cef
```

`config.jsonc` 同理(新包里那份是模板,不含本店的 `client-id` / 打印机名 / `usb-swiper`),
一并备份还原。

## 标签打印机(Zebra ZD411)额外步骤

标签机和小票机一样都走 CUPS,只是**必须建成 raw 队列**(打印机自己认 ZPL 指令,不能让
CUPS 再套一层驱动去转换)。打印机先在路由器上拿到固定 IP,然后:

```bash
# 队列名随便起,但要和 config.jsonc 里填的一致;IP 换成打印机实际地址
sudo lpadmin -p zd411 -E -v socket://192.168.1.50:9100 -m raw
lpstat -p zd411          # 应显示 idle / enabled
```

然后 `config.jsonc` 里把这个队列挂到对应槽位,例如标签槽是 3 号:

```
  "printer3-name": "zd411",
```

pos 侧还要把该槽位的**类型选成 Zebra ZPL**(设置 → 打印机),否则发过去的是小票用的
ESC/POS 位图,标签机会打出乱码。

**装纸后必须做一次介质校准(SmartCal)**:同时按住 **PAUSE + CANCEL 约 2 秒**,打印机会走几张
纸学会标签间距。不校准的现象是出空白标签或内容偏移半张 —— 这是打印机安装步骤,不是软件问题。

> ⚠️ 手上没有 ZD411 实机验证过按键组合(2026-07-25):这里写的是 Zebra ZD 系列文档的 SmartCal
> 组合。**issue #1973 验收清单里写的 `FEED+CANCEL` 是错的**,别照那条做。装机时如果 PAUSE+CANCEL
> 没反应,以随机附的 Quick Start 卡片为准,并回来改这一行。

验证:pos 设置页对该槽点「测试打印」,应出一张带店名和时间的标签。没反应就看壳子
终端日志里有没有 `printraw:` 开头的错误行(队列名写错会打印 `CUPS queue not found`)。

## 刷卡机器(Moby5500)额外步骤

Linux 上的读卡服务跑在一个 Windows 虚拟机里(`rua_server`,壳子通过 `127.0.0.1:15678` 连它)。

1. 菜单 **5**(装 libvirt)→ 菜单 **6**(重启)→ 重新打开 installer
2. 菜单 **9**(下载约 10GB 镜像 + 定义 VM + 修自启)——**读卡器要插在机器上**
3. 菜单 **10**(USB 拔插自动重连)。装两样东西,都幂等、重跑覆盖:
   - **udev 规则**:插回读卡器时先 detach 域里的幽灵 hostdev 条目再 attach
     (旧版只 attach,拔插一次后永远失败,2026-08-08 实锤);
   - **libvirt qemu hook**:`rua_server` started 后 ~10s 补挂读卡器 —— udev 只在
     ADD 触发,覆盖不了「VM 重启后 live 挂载丢失」的盲区(2026-08-10 实锤)。已有
     hook 文件(比如来电识别那节装的)会**追加合并**不覆盖;VM XML 里带开机 hostdev
     的机器(设备已挂好)脚本会自动跳过,不会白白弹跳设备。
   - 老机器升级:**重跑一次菜单 10 即可**拿到两个修复,不用重装。
4. 验证:`sudo tail -f /var/log/libvirt/rua_app.log`,应先后出现 `started on port` 和
   `InterfaceDeviceSerialNumber`;拔插重连和 VM 重启补挂看
   `/var/log/openpos-reattach-reader.log`,应有 `attach ok`(或门店机形态的
   `already attached and healthy`)

## 来电识别(Caller ID)额外步骤

店里插一个 USB 电话调制解调器(HiRO V.92 一类),来电时收银屏弹出号码 + 认出老客 + 一键开外带单。
**modem 只监听,不摘机不拨号** —— 座机照常响、照常接。

接线:**电话线 → modem 的 `LINE` 口,座机接 modem 的 `PHONE` 口**(盒子上那两个 RJ-11 就是干这个的,
不用另买分线器)。

### 前提:先做完「刷卡机器」那节

来电识别的服务和读卡服务是**同一个** `rua_server`(跑在那台 Windows VM 里)。所以菜单 5/6/9/10
必须先做完 —— 没有那台 VM 就没有来电识别。

### 步骤

1. **先验硬件**(不需要 VM,两分钟):

   ```bash
   cd ~/Downloads && wget -O cid-probe.sh https://downloads.openpos.site/linux/cid-probe.sh
   sudo bash cid-probe.sh
   ```

   敲完 `ATZ` / `AT+VCID=1` 都回 `OK` 后打个电话进来,看到 `NMBR = ...` 就是通了。
   两条 AT 都回 `ERROR` = 这块 modem 不支持来电识别,换一块。

2. **把 modem 透传进 VM**:

   ```bash
   lsusb | grep -i modem            # 记下 VID:PID,例如 0572:1349
   wget -O setup-cid-modem.sh https://downloads.openpos.site/linux/setup-cid-modem.sh
   sudo bash setup-cid-modem.sh 0572:1349
   ```

   脚本会装 udev 规则 + libvirt hook + **重启 libvirtd**(不重启的话 hook 等于没装,而且不报错)。

3. **验证**:

   ```bash
   sudo tail -f /var/log/libvirt/rua_app.log
   ```

   应出现 `CID: modem found via WMI: "..." on COM3` 和 `CID armed on COM3 via AT+VCID=1`。
   然后打个电话,应出现 `CID: incoming call: number="..."`。

4. 重启 VM 后 modem 会**自动挂回去**(hook 那层),不用手动做什么。

### ⚠️ 装完之后别碰菜单 13(除非知道自己在做什么)

菜单 13「更新 VM 内刷卡服务」会**优先从我们自己的下载站**取 `rua_server.exe`(含来电识别),
下载站上没有才**落回上游版本 —— 那份不含来电识别**,更新完来电弹窗就消失了
(读卡器照常工作,不报错)。落回上游时脚本会打印警告并要求输入 `yes` 确认,
**看到那段警告就停下来问一句**。

> ✅ 2026-08-06 起下载站上确实有我们的版本了(此前脚本去错了目录,一直落回上游)。
> 跑菜单 13 会看到 `源:…/win/rua_server.exe(本仓构建,含来电识别)` —— 看到这行就对了。
> 看到「落回上游」那段警告才需要停下来问。

## /dev/shm watchdog(CEF 白屏自愈,2026-08-14 起每台必装)

CEF135 在 Intel iGPU(J6412/N95/N100 均已复现)+ Ubuntu 22.04 上有光栅 tile 泄漏:renderer 以
~2MB/min 累积「已删除仍 mmap」的 /dev/shm 段,约 30 小时吃满 3.8G 后 GPU 分不出命令缓冲,
**白屏但进程不死**(与壳版本/页面/参数无关,详见 `docs/known-issues.md`「/dev/shm 100%
而目录零文件」条)。在有根治(升 Mesa / 升 CEF)之前,每台机装这个 watchdog 兜底:

```bash
cp shm_watchdog.sh /home/menu/shm_watchdog.sh && chmod +x /home/menu/shm_watchdog.sh
(crontab -l 2>/dev/null | grep -v shm_watchdog; echo "*/5 * * * * /home/menu/shm_watchdog.sh") | crontab -
```

行为:每 5 分钟查一次,`/dev/shm ≥95%` 时按实例目录逐个走 `run.sh` 重启 CEF(单/双屏
自适应),30 分钟冷却防抖;平时只采样。`~/shm_usage.log` 会同时记录 renderer RSS、
deleted-shm/全部 2MB map 数、GPU 实际加载的 iris/libGL/libgbm 路径与 Mesa 旁路环境；触发时
`~/shm_watchdog_evidence/` 另存压缩的完整 maps、smaps_rollup 和段大小直方图，便于区分
tile 资源池泄漏、普通 RSS 增长和驱动 A/B 没有真正生效。恢复日志在
`~/shm_watchdog.log`。office-mx 与 Seven Tea 已于 2026-08-14 手动装过,新装机器跑上面
两行即可；旧机更新脚本不需要改 cron。

## 常见问题

- **打开是 CEF 示例页不是 POS**:`config.jsonc` 没被读到。确认它和 `cefclient` 同目录、
  文件名没写成 `config.json`、没有行尾注释、没有尾逗号。
- **双屏没分开、都挤在主屏**:确认两份 `config.jsonc` 里分别有 `"preferred_monitor": 0` 和 `1`
  (菜单 12 会自动写);确认壳子是 2026-07-20 之后的版本(更早的 Linux 壳没实现 preferred_monitor)。
- **点图标只出一个实例**:`xrandr | grep " connected"` 确认系统认到两块屏;确认 `cef_ubuntu2` 目录存在。
- **菜单 13 报 hibernation / NTFS 拒绝写入**:VM 里的 Windows 开着快速启动。进 VM
  (virt-manager)以管理员运行 `powercfg /h off`,彻底关机后重试。
- **菜单 13 / 16 报 `VM did not shut off within 2 minutes`**:VM 里的 Windows 熄屏了不理会关机
  信号。脚本现在会先「按一下键盘」唤醒再关(两次机会);还不行就按提示截屏看 VM 卡在哪,
  **不要 `virsh destroy` 硬断电**(会把盘弄脏,接下来的写入全报 Read-only)。⚠️ 这个报错之后
  **exe 没有换**,不算跑过 13。
- **升壳(菜单 3)之后刷卡机读到卡但页面每笔都失败 / 报 empty callback**:VM 里的刷卡服务
  没跟着升。跑菜单 **24** 确认,再跑菜单 **13**。
- **VM 起不来报 `Did not find USB device 26f1:56b0`**:读卡器没插。插上后
  `virsh -c qemu:///system start rua_server`。
- **刷卡不工作、日志显示 `accepting connections from localhost only`**:VM 自启少了 `--anyip`,
  跑菜单 **16** 修。

## 装不下去怎么办

**先跑菜单 0「远程支持」** —— 它把这台机器加进 Tailscale 网络并打开 SSH,技术支持就能
直接连进来看,不用你在电话里念报错。中途会问**店名**(用英文或拼音,比如 `seven-tea`),
填了后台就能直接认出是哪家店;不填按回车也能继续。跑完屏幕会打印一个 `100.x.x.x` 的
地址和机器名,**把那两行发给技术支持**即可。

> 需要联网;装完之后这台机器对技术支持长期可达。不想留这个入口时,跑
> `sudo tailscale logout`,并从 `~/.ssh/authorized_keys` 里删掉 `restmesh-remote-support`
> 那一行(**两步都要做**)。

菜单 0 也连不上时,再把屏幕上的报错、`~/Downloads` 下的日志、以及
`sudo tail -50 /var/log/libvirt/rua_app.log`(刷卡问题)发给技术支持。
