远程看 iOS Simulator:自管 Mac 与浏览器预览
在 Mac 上运行 iOS 工程,在另一台电脑查看画面;逐步确认设备、权限、视频和输入都能正常工作。
- 先在自管 Mac 跑通目标应用
- 屏幕共享与浏览器预览分开配置
- 私网和应用门禁都要验证
- 逐层验证有帧、输入与界面变化
- 停止本次服务,不清理其他人的设备
1. 让 Mac 负责运行,把画面带到另一台电脑
把 Mac mini 留在固定位置运行 Xcode,人在另一台电脑上查看 Simulator,是很实用的 iOS 开发安排。代码编译、模拟设备和应用进程都在那台 Mac 上;远程连接传递画面与输入。先把本机运行做好,再增加远程入口,出问题时就能区分是应用、捕获还是网络。
自管 Mac 有两条常见查看路径。另一端也是 Mac 时,可以使用 Apple 的屏幕共享,查看整个桌面和 Xcode。需要在浏览器里查看模拟器时,可以额外部署带认证的 @expo/serve-sim。它是第三方预览服务;屏幕共享本身并不会生成浏览器页面。本文先讲这两条自管路径,末尾再补充 GitHub Actions 的临时 runner 方案。
这些步骤适合界面评审、交互复现和协作排错。相机、功耗、触觉及性能结论仍要回到真机验证。资料核对到 2026 年 10 月 5 日,完成了源码核查和离线测试,未运行真实 Mac、视频或触摸会话,也没有替读者开启共享、安装服务或修改网络权限。
来源:Apple:在模拟器或设备上运行 · Apple:开启与限制屏幕共享 · Apple:连接、查看与断开屏幕共享 · Expo fork:安装、CLI 与传输
2. 先在自管 Mac 上启动目标应用
Mac 上需要完整 Xcode、对应平台支持与 iOS Simulator runtime。独立的 Command Line Tools 包不能代替整套 iOS 模拟器环境。若选择下文的 Expo 浏览器预览服务,还需要 Apple silicon arm64:该包的原生组件仅提供 arm64 版本,这个限制不要泛化成所有 Apple 屏幕共享功能都要求相同硬件。
# 以下命令都在运行模拟器的 Mac 上执行
uname -m
xcode-select -p
xcodebuild -version
xcrun simctl list runtimes
xcrun simctl list devices available
xcrun simctl help
若列表缺少可用 runtime,在 Xcode 的 Components 设置中安装所需版本。确认 developer directory 指向选定 Xcode,记录 Xcode 版本、runtime、设备 UDID 与应用源码提交。先在 Xcode 选择正确 scheme 和模拟设备,运行一个已审核的测试应用,确认本机能操作,再开始远程配置。
已有 Simulator 构建时,可按下面顺序安装。APP 指向可信的 .app,BUNDLE_ID 要与它一致,UDID 从设备列表抄取。设备已启动就跳过 boot,不要把每个启动错误都忽略。真机 IPA 不直接适用于模拟器;归档能解压也不代表架构和最低系统要求匹配。
UDID=REPLACE_WITH_DEVICE_UDID
APP=/absolute/path/MyApp.app
BUNDLE_ID=com.example.MyApp
xcrun simctl boot "$UDID"
xcrun simctl bootstatus "$UDID" -b
xcrun simctl install "$UDID" "$APP"
xcrun simctl launch "$UDID" "$BUNDLE_ID"
使用专用测试账号与设备,避免预览服务接触个人数据。若工作还包含真机签名或发布,签名材料继续按原有 Mac 密钥链与团队权限管理,不要为了传递画面把证书、私钥复制给观看者。
来源:Apple:Xcode 命令行工具 · Apple:安装 Xcode 组件 · Apple:在模拟器或设备上运行 · Expo fork:安装、CLI 与传输 · native-sim:runner 工作流
3. 先验证 Apple 屏幕共享这条路径
在主机的系统设置中进入通用、共享,查看屏幕共享的详情。由维护者启用后,把访问范围限制为明确的用户。Apple 屏幕共享与远程管理不能同时开启,已有受管设置时应先询问管理员。不要为方便临时开启所有用户或通用 VNC 密码。
另一台 Mac 打开屏幕共享 App,新建连接并输入主机名,再按提示完成该 Mac 的用户认证。首次连接先确认主机身份和桌面内容,检查能否查看 Simulator,再执行一个可逆的测试动作。观察模式只能观看,控制模式会影响远端桌面。共享剪贴板和文件拖放按需要启用,避免无意传出资料。
跨网络访问应走已经获准的私网。使用 Tailscale 时,两端设备先由各自所有者安装官方客户端、登录获准的 tailnet,并由管理员将访问规则限制到指定用户、机器和服务。加入同一个 tailnet 不等于已经完成最小权限配置。不要在路由器上把 VNC 端口裸映射到公网。
连接失败先检查主机是否在线、两端网络与访问规则、屏幕共享允许列表,以及主机是否睡眠。保持必要的交互登录状态,并与维护者约定无人值守时的锁屏和唤醒方式。不要用关闭系统保护或开放更大网段替代排查。
来源:Apple:开启与限制屏幕共享 · Apple:连接、查看与断开屏幕共享 · Apple:屏幕共享排错 · Tailscale:访问控制规则
4. 需要浏览器时,单独部署带门禁的预览
本文核查的是 Expo 维护的 @expo/serve-sim 0.3.4 源码,提交 c91e75b3;原作者 EvanBacon 的 serve-sim 是另一个仓库,命令和内部结构不能混用。选择仍受维护的 Node.js LTS。包声明的最低版本为 20,但核对时 Node 20 已结束支持,可选受维护的 22 或 24。
node --version
npm view @expo/serve-sim@0.3.4 version
npx @expo/serve-sim@0.3.4 --help
# UDID 沿用前面选定的设备;在专用终端前台运行
npx @expo/serve-sim@0.3.4 "$UDID" --require-token --codec mjpeg \
--port 3200 --mjpeg-fps 30 --max-dimension 900 --mjpeg-quality 0.7
指定版本已从 npm 官方元数据确认存在,未下载工件核对与该源码提交的逐字一致性。安装前核对发布来源,安装后检查所需选项;缺少选项就停止,不静默换成 latest。先在主机自己的浏览器中使用服务输出的带 token 链接验收画面。该凭据会交出控制权,不放进公开日志或截图。
受控远程浏览可由 Tailscale Serve 把 loopback 的预览端口代理到 tailnet 内的 HTTPS 地址。前提是管理员已批准相应设备、访问规则和 HTTPS 配置。先查看现有 Serve 配置;下面示例只适合没有其他根路径服务的专用主机,不能直接覆盖共享机器的已有路由。
tailscale serve status
tailscale serve --help
# 仅在获准的专用主机上启用;首次先以前台方式运行
tailscale serve --https=443 http://127.0.0.1:3200
命令会给出当前私网 HTTPS 地址。维护者在获准浏览器里使用该地址,并保留预览链接原有的路径和 token 参数完成登录;不要公开粘贴完整链接。Serve 的访问规则仍然生效,应用自己的 token 门禁也要保留。Funnel 面向公网,不能在这一步误用。首次 HTTPS 配置若弹出授权页面,应先检查管理员允许的范围。
远程页面里的视频和控制 WebSocket 都应指回同一 HTTPS 主机,不能指向观看者自己的 localhost。代理后的 Host 与 X-Forwarded-Proto 等信息要正确。若页面有图却无法点击,先检查 upgrade 转发、认证和实际 wsUrl。本文没有实测这组集成,必须在目标 Mac 与浏览器完成下节验收。
来源:Expo fork:安装、CLI 与传输 · Expo fork:API 与认证 · Expo fork:端点发现 · Node.js:受维护的发行版本 · Tailscale Serve:私网HTTPS与访问规则 · Tailscale Serve:命令与关闭入口
5. 确认有帧,并验证操作结果
先确认目标应用已在指定模拟器中启动。当前 Expo fork 使用进程内原生捕获,旧版固定独立 3100 服务的图不宜照搬。用 --list -q 发现实际 url、streamUrl 和 wsUrl,多设备时明确 UDID;输出可能含控制凭据,分享前删除敏感字段。
# 在捕获端所在 Mac 上发现实际端点,不推断固定 helper 端口
npx @expo/serve-sim@0.3.4 --list -q
# 本例显式选择了预览端口 3200;从该 Mac 本机检查
curl --fail --show-error http://127.0.0.1:3200/healthz
curl --fail --show-error http://127.0.0.1:3200/readyzhealthz 表示服务在响应,readyz 等待设备和捕获会话就绪。随后仍要看到帧被解码并呈现,操作一个可逆的界面控件,再观察界面变化。HTTP 200、网络面板有字节和 WebSocket 已连接分别覆盖不同环节;任何一个都不能单独作为远程操作完成的证据。
首帧验收最好选带时钟或可改变状态的测试画面,同时记下浏览器端观察时刻与操作结果。反复呈现同一张图片仍可能是应用卡住,所以帧计数之外还要确认内容按预期变化。跨机器时间若未同步,不直接相减算延迟;可以在同一观察端记录点击与后续像素变化的时间差,并明确其中包含网络、排队、应用处理和显示等待。
6. 选择传输时先检查有没有帧
上面的自管示例选择 HTTP MJPEG,每帧是一张 JPEG,经长连接送到浏览器。--mjpeg-fps 控制这条路线;--video-fps 面向 H.264 与 WebRTC,改错参数可能对当前画面没有作用。调整时一次只改分辨率、质量或帧率中的一项,并记录相同交互下的结果。
Expo fork 同时实现 AVCC/H.264 和 WebRTC。AVCC 需要服务端编码与浏览器 WebCodecs 解码都可用;native-sim 作者报告过 GitHub macOS runner 上 H.264 请求返回 200 却没有输出字节的情况,本文未复测。出现黑屏先确认实际传输、帧计数和解码错误,默认路线退回 MJPEG,再检查编码能力。
WebRTC 用 HTTP 交换信令,视频通过 ICE 建立的媒体路径传输。页面能穿过 HTTPS 隧道,不代表 RTP 也走同一个隧道;无法直连时需要可用的 TURN。当前实现的输入仍走 WebSocket,并且以 WebRTC 模式启动后会锁定传输,不能假定失败时自动回落 HTTP。没有真实网络测试,不据此许诺更低延迟。
评估卡顿时保留浏览器版本、窗口大小、缩放比例和观看人数。比较的是实际呈现帧数,而不只是配置里的目标 FPS。MJPEG 的传输量可以从同一时间窗内接收字节数得到,WebRTC 则查看当前协商 codec、候选类型、收发包和丢包统计。两种结果都要连同观察时长记录,停止变化的界面不适合代表连续滚动的负载。
来源:native-sim:runner 工作流 · Expo fork:WebRTC 架构 · Expo fork:安装、CLI 与传输
7. 点击偏移时检查内容矩形和方向
浏览器窗口尺寸、预览容器尺寸和设备内容尺寸是三组数。为画面保留比例后,容器常出现留白。把整张网页的像素坐标直接除以设备分辨率,按钮位置就会偏离。对于未旋转的内容矩形,先减去画面左上角,再分别除以内容宽高,得到零到一之间的坐标;落在留白中的点击应忽略。
旋转、缩放、切换设备后要重新读取配置。官方客户端已有方向补偿,继续使用它时不要再叠加一次旋转;自写客户端则依据当前画面几何和源码映射处理。连续拖动的 begin、move、end 应留在同一条有序连接中,中途重连后放弃旧手势,确认当前界面再重做。
先用无副作用的测试页验证四角、中心和滚动方向,再测试输入框。键盘焦点、快捷键拦截、键盘布局与输入法都可能影响结果。多名浏览者能同时连接并不代表拥有独占控制权,当前 WebRTC 架构没有跨观看者的控制仲裁,协作时要先约定谁在操作。
操作超时后先回看界面和事件记录,再决定是否重试。一次点击可能已经送达,只是画面回传延迟;重复点击会把相同动作执行两次。涉及支付、发布、删除或账号设置的界面,应使用专门的测试环境并保留人工确认。用于联调的屏幕控制链接也不应连着个人 Apple 账号或含私人数据的模拟器。
8. 权限边界覆盖画面、控制和数据
屏幕共享能操作整个 Mac 桌面,浏览器预览也带有输入与调试能力。把仅需观看的人授予控制入口之前,先确认目的和范围。当前 Expo fork 的 --require-token 保护除健康检查外的路线;不带该选项不能假定已经启用认证。
TLS 保护传输,tailnet 规则限制网络访问,预览 token 保护应用会话。三者各自检查,不以 CORS 代替身份授权。从没有 token 的浏览器验证预览页面被拒绝,再由管理员验证未获准设备无法访问私网服务。健康检查可匿名成功,不能拿它作为全部路线受保护的证明。
查看者可能看到屏幕、应用日志和调试信息。会话只使用测试数据,分享材料前去掉 token、账号、地址和业务秘密。源代码、签名材料、构建归档应按原有权限保存;视频传输成功不会自动证明这些文件的可见性安全。
来源:Apple:开启与限制屏幕共享 · Expo fork:API 与认证 · Tailscale Serve:私网HTTPS与访问规则
9. 处理后台、重启与会话结束
浏览器切到后台后,解码、渲染或计时回调可能改变节奏。回到前台先观察新帧,再验证控制连接。设备关机或捕获进程重启时,旧画面可能留在页面上,要重新发现设备与端点,清空旧就绪证据,再完成验收。
应用回到桌面时,核对前台 bundle ID、启动步骤和崩溃记录。完全没有画面时,依次查看主机、readyz、传输字节与解码错误;有画面不能点时,查看授权、控制连接、焦点与坐标。连接慢则分别记录网络、编码和应用负载,不靠不断新建会话掩盖问题。
关闭浏览器不会自动停止自管 Mac 上的服务。前台运行时,在对应的 Serve 与捕获终端结束本次进程;若仍有捕获实例,先列出并停止选定设备。只有确认该模拟器没有其他人使用时才关闭它。屏幕共享在 App 中断开即可,是否关闭主机共享开关另按维护约定处理。
npx @expo/serve-sim@0.3.4 --list -q
# 明确本次设备后,停止它的捕获实例
npx @expo/serve-sim@0.3.4 --kill "$UDID"
# 如果对应 Serve 配置仍在,移除本次开启的同一入口
tailscale serve --https=443 http://127.0.0.1:3200 off
tailscale serve status
# 仅在无人继续使用此设备时执行
xcrun simctl shutdown "$UDID"
不要为清理一次会话执行 erase all、删除共享状态目录或重置全机 Serve 配置。保留排错所需日志,按既定保留期清除临时归档。下一次运行从设备与应用身份开始确认,端口相同也不能当作上一次会话仍然有效。
来源:Expo fork:端点发现 · Expo fork:WebRTC 架构 · Tailscale Serve:命令与关闭入口 · Apple:连接、查看与断开屏幕共享
10. 可选分支:用 GitHub Actions 提供临时 Mac
没有常驻 Mac、需要短期评审环境时,可以另行评估 native-sim。本文核查 0.2.2,提交 923026c9。它在 GitHub macOS runner 上构建或安装应用,启动 Expo 捕获、3199 门禁与临时隧道,把画面送回浏览器。它不连接前面那台 Mac mini,关闭浏览器也不会取消 job。
此命令会提交和推送工作区、生成 workflow,必要时创建仓库。先核对 git status、远端和忽略规则,确认允许上传的内容以及 Actions 用量。已有公有仓库不会因省略 --public 变成私有。模板仍使用 Node 20 与浮动捕获包,团队要在审查模板时更新受维护版本并固定工件。
npm view native-sim@0.2.2 version
npm install -g native-sim@0.2.2
export DO_NOT_TRACK=1
gh auth status
git status --short
git remote -v
native-sim doctor
native-sim init
git diff -- .github
# 审查并提交获准的配置之后再启动
native-sim up --minutes 20 --runner macos-26 --codec mjpeg --no-open
native-sim status
native-sim down
捕获服务可能先于应用构建完成,拿到链接后仍须验收目标应用。预编译 --app-file 会上传到已发布的 Release,公有仓库资产可被任何人下载。完整会话链接也是控制凭据,不进入公开状态或日志。down 定位最近一次会话,多次启动时按 run ID 检查并取消对应 job,再回读终态。费用、冷启动和网络表现都以实际记录为准。
来源:native-sim 0.2.2:项目说明 · native-sim:启动与发布动作 · native-sim:runner 工作流 · native-sim:认证网关 · GitHub:托管 runner
11. 离线复盘:只分析明确记录过的证据
下面的 Python 标准库示例读取自定义 NDJSON 日志。它汇总 HTTP 状态、帧呈现报告、输入反馈、前后台切换和重启代次,不连接任何服务,也不解析视频。frame 中的 decoded 与 presented 必须由实际观察或采集端填写;程序信任这些字段,不能据此验证报告者是否诚实。
同一会话的 t 使用同一单调时钟,seq 是当前代次的帧报告序号。重复或倒序帧不能刷新最后呈现时间,restart 会清空旧证据。默认五秒阈值只是本例的复盘参数,可按实验窗口调整,不是 serve-sim 的产品保证。control 被接收时只记录 accepted_only,观察到预期界面变化才填写 effect_observed。
python3 -m unittest -v
python3 session_log.py artificial-session.ndjson --at 10
配套输入是人工示例:第 2 秒报告一帧呈现,第 4 秒进入后台,第 9 秒回到前台,第 10 秒复盘时画面证据已经八秒未更新。预期 verdict 为 stale,输入仍是 accepted_only。这只演示判断规则,不代表发生过真实远程会话。
{"session":"d","t":0,"event":"start"}
{"session":"d","t":1,"event":"http","status":200,"ok":true}
{"session":"d","t":2,"event":"frame","seq":1,"bytes":12,"decoded":true,"presented":true}
{"session":"d","t":3,"event":"control","accepted":true,"effect_observed":false}
{"session":"d","t":4,"event":"visibility","visible":false}
{"session":"d","t":9,"event":"visibility","visible":true}
27 项自动化测试已在离线环境执行通过,覆盖有 HTTP 无帧、解码失败、未呈现、陈旧帧、重复帧、重启、前后台、输入回执、结束状态与非法数据。完整程序和测试如下,可直接复制为同名文件;页面不依赖外部代码附件才能复现。
"""Offline NDJSON evidence summary. No network, video decoder, or device access."""
import argparse
import json
import math
import sys
FIELDS = {
'start': set(), 'restart': set(), 'stop': set(),
'http': {'status', 'ok'},
'frame': {'seq', 'bytes', 'decoded', 'presented'},
'control': {'accepted', 'effect_observed'},
'visibility': {'visible'},
}
BASE = {'session', 't', 'event'}
def number(value, name, minimum=0):
if type(value) not in (int, float) or abs(value) > 1e15:
raise ValueError(f'{name}: bounded number required')
if not math.isfinite(value):
raise ValueError(f'{name}: finite number required')
if value < minimum:
raise ValueError(f'{name}: below minimum')
return value
def boolean(value, name):
if type(value) is not bool:
raise ValueError(f'{name}: boolean required')
def read_events(lines):
def unique(pairs):
result = {}
for key, value in pairs:
if key in result:
raise ValueError('duplicate JSON key')
result[key] = value
return result
for line_no, line in enumerate(lines, 1):
if line_no > 50000 or len(line) > 65536:
raise ValueError('input limit exceeded')
if not line.strip():
continue
try:
yield json.loads(line, object_pairs_hook=unique)
except (ValueError, TypeError) as exc:
raise ValueError(f'line {line_no}: invalid JSON') from exc
def summarize(events, check_at, stale_after=5):
number(check_at, 'check_at')
number(stale_after, 'stale_after', minimum=0.001)
started = False
ended = False
session = None
last_t = -1
generation = 0
visible = True
frames = duplicates = failed = presented = 0
last_seq = -1
last_frame = first_frame = None
http_ok = False
control = 'not_observed'
for e in events:
if not isinstance(e, dict):
raise ValueError('event must be an object')
kind = e.get('event')
if not isinstance(kind, str) or kind not in FIELDS:
raise ValueError('unknown event')
if set(e) != BASE | FIELDS[kind]:
raise ValueError('missing or unexpected field')
sid = e['session']
if not isinstance(sid, str) or not 1 <= len(sid) <= 64:
raise ValueError('invalid session')
t = number(e['t'], 't')
if t < last_t or t > check_at:
raise ValueError('time out of order or beyond check_at')
last_t = t
if ended:
raise ValueError('event after stop')
if kind == 'start':
if started:
raise ValueError('duplicate start')
started, session = True, sid
elif not started or sid != session:
raise ValueError('start required; sessions cannot be mixed')
if kind in ('start', 'restart'):
generation += 1
frames = duplicates = failed = presented = 0
last_seq = -1
last_frame = first_frame = None
http_ok, control = False, 'not_observed'
elif kind == 'http':
if type(e['status']) is not int or not 100 <= e['status'] <= 599:
raise ValueError('invalid HTTP status')
boolean(e['ok'], 'ok')
http_ok = 200 <= e['status'] < 300 and e['ok']
elif kind == 'frame':
for field in ('seq', 'bytes'):
if type(e[field]) is not int or e[field] < 0:
raise ValueError(f'{field}: nonnegative integer required')
boolean(e['decoded'], 'decoded')
boolean(e['presented'], 'presented')
if e['presented'] and not e['decoded']:
raise ValueError('presented requires decoded')
if e['decoded'] and e['bytes'] == 0:
raise ValueError('decoded requires bytes')
if e['seq'] <= last_seq:
duplicates += 1
continue
last_seq = e['seq']
frames += 1
failed += not e['decoded']
if e['presented']:
presented += 1
last_frame = t
if first_frame is None:
first_frame = t
elif kind == 'control':
boolean(e['accepted'], 'accepted')
boolean(e['effect_observed'], 'effect_observed')
if e['effect_observed'] and not e['accepted']:
raise ValueError('effect requires accepted')
control = ('effect_observed' if e['effect_observed'] else
'accepted_only' if e['accepted'] else 'rejected')
elif kind == 'visibility':
boolean(e['visible'], 'visible')
visible = e['visible']
elif kind == 'stop':
ended = True
if not started:
raise ValueError('empty log or missing start')
age = None if last_frame is None else check_at - last_frame
verdict = ('ended' if ended else 'hidden' if not visible else
'no_presented_frame' if age is None else
'stale' if age > stale_after else 'recent_frame_reported')
return {
'session': session, 'generation': generation, 'verdict': verdict,
'http_ok': http_ok, 'unique_frame_reports': frames,
'presented_reports': presented, 'decode_failures': failed,
'duplicate_or_old_reports': duplicates,
'first_presented_t': first_frame, 'last_presented_age': age,
'control': control,
'limit': 'Trusts supplied reports; does not verify video or UI effects.',
}
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('path')
parser.add_argument('--at', required=True, type=float)
parser.add_argument('--stale-after', type=float, default=5)
args = parser.parse_args()
try:
with open(args.path, encoding='utf-8') as source:
result = summarize(read_events(source), args.at, args.stale_after)
print(json.dumps(result, ensure_ascii=False, indent=2))
except (OSError, ValueError) as exc:
print(f'Invalid log: {exc}', file=sys.stderr)
return 2
return 0
if __name__ == '__main__':
sys.exit(main())
import json
import unittest
from session_log import read_events, summarize
def event(kind, t, **values):
return dict(session='demo', event=kind, t=t, **values)
def frame(t=2, seq=1, **values):
fields = dict(seq=seq, bytes=100, decoded=True, presented=True)
fields.update(values)
return event('frame', t, **fields)
class SessionLogTests(unittest.TestCase):
def run_log(self, *items, at=3, **options):
return summarize([event('start', 0), *items], at, **options)
def test_200_does_not_prove_frame(self):
r = self.run_log(event('http', 1, status=200, ok=True))
self.assertTrue(r['http_ok'])
self.assertEqual(r['verdict'], 'no_presented_frame')
def test_body_false_overrides_200(self):
r = self.run_log(event('http', 1, status=200, ok=False))
self.assertFalse(r['http_ok'])
def test_non_success_status(self):
r = self.run_log(event('http', 1, status=503, ok=True))
self.assertFalse(r['http_ok'])
def test_recent_presentation(self):
r = self.run_log(frame())
self.assertEqual(r['verdict'], 'recent_frame_reported')
self.assertEqual(r['last_presented_age'], 1)
def test_decode_without_presentation(self):
r = self.run_log(frame(presented=False))
self.assertEqual(r['presented_reports'], 0)
def test_decode_failure(self):
r = self.run_log(frame(decoded=False, presented=False))
self.assertEqual(r['decode_failures'], 1)
def test_stale_boundary(self):
self.assertEqual(self.run_log(frame(), at=7)['verdict'],
'recent_frame_reported')
self.assertEqual(self.run_log(frame(), at=7.1)['verdict'], 'stale')
def test_duplicate_does_not_refresh(self):
r = self.run_log(frame(), frame(t=8), at=9)
self.assertEqual(r['duplicate_or_old_reports'], 1)
self.assertEqual(r['verdict'], 'stale')
def test_out_of_order_frame_sequence(self):
r = self.run_log(frame(seq=3), frame(t=3, seq=2))
self.assertEqual(r['unique_frame_reports'], 1)
def test_restart_resets_evidence(self):
r = self.run_log(frame(), event('restart', 3), at=4)
self.assertEqual(r['generation'], 2)
self.assertEqual(r['verdict'], 'no_presented_frame')
def test_sequence_can_restart(self):
r = self.run_log(frame(seq=5), event('restart', 2.5),
frame(t=3, seq=1))
self.assertEqual(r['presented_reports'], 1)
def test_hidden_tab(self):
r = self.run_log(frame(), event('visibility', 3, visible=False))
self.assertEqual(r['verdict'], 'hidden')
def test_return_to_foreground_preserves_age(self):
r = self.run_log(frame(), event('visibility', 3, visible=False),
event('visibility', 10, visible=True), at=10)
self.assertEqual(r['verdict'], 'stale')
def test_control_ack_is_not_effect(self):
r = self.run_log(event('control', 1, accepted=True,
effect_observed=False))
self.assertEqual(r['control'], 'accepted_only')
def test_control_effect(self):
r = self.run_log(event('control', 1, accepted=True,
effect_observed=True))
self.assertEqual(r['control'], 'effect_observed')
def test_ended(self):
self.assertEqual(self.run_log(event('stop', 1))['verdict'], 'ended')
def test_strict_schema(self):
for change in ({'url': 'secret'}, {'bytes': True}, {'bytes': -1},
{'decoded': 'yes'}, {'presented': True, 'decoded': False},
{'bytes': 0}, {'seq': 1.5}):
with self.subTest(change=change), self.assertRaises(ValueError):
self.run_log(frame(**change))
def test_time_validation(self):
for t in (float('nan'), float('inf'), True, -1, 10, 10 ** 1000):
with self.subTest(t=t), self.assertRaises(ValueError):
self.run_log(frame(t=t))
with self.assertRaises(ValueError):
self.run_log(frame(t=2), frame(t=1, seq=2))
def test_invalid_transitions(self):
for items in ([frame()], [event('start', 0), event('start', 1)],
[event('start', 0), event('stop', 1), frame()], []):
with self.subTest(items=items), self.assertRaises(ValueError):
summarize(items, 3)
def test_sessions_cannot_mix(self):
e = frame()
e['session'] = 'another'
with self.assertRaises(ValueError):
self.run_log(e)
def test_invalid_metadata(self):
for e in ([], {}, event('unknown', 1), event('http', 1, status=True,
ok=True), event('visibility', 1, visible=1)):
with self.subTest(e=e), self.assertRaises(ValueError):
self.run_log(e)
def test_invalid_threshold(self):
for value in (0, -1, float('inf'), True):
with self.subTest(value=value), self.assertRaises(ValueError):
self.run_log(stale_after=value)
def test_ndjson_and_blank_lines(self):
rows = [json.dumps(event('start', 0)), '', json.dumps(frame())]
self.assertEqual(summarize(read_events(rows), 3)['presented_reports'], 1)
def test_duplicate_json_keys(self):
with self.assertRaises(ValueError):
list(read_events(['{"event":"start","event":"stop"}']))
def test_malformed_json(self):
with self.assertRaisesRegex(ValueError, 'line 1'):
list(read_events(['{']))
def test_line_limit(self):
with self.assertRaises(ValueError):
list(read_events(['x' * 65537]))
def test_log_limit(self):
with self.assertRaises(ValueError):
list(read_events([''] * 50001))
if __name__ == '__main__':
unittest.main()12. 留下能够复现的验收记录
一次可交付的会话记录至少包含源码与工件版本、执行 Mac 架构、Xcode/runtime、UDID、目标应用、实际传输、首帧呈现时间和一个可逆操作的结果。时间以本次采集为准,排队、构建、安装、首帧和可操作状态分别记载,不把别人的测试数字套到当前环境。
最后再确认陌生访问者被拒绝、目标会话已停止、敏感链接未进入公开日志,构建归档的可见性符合预期。本文提供的是有版本依据的搭建与验收步骤;真实 Mac 的兼容性、视频质量和网络表现,需要按这份记录在实际项目中验证。
- native-sim 0.2.2:项目说明
- native-sim:启动与发布动作
- native-sim:runner 工作流
- native-sim:认证网关
- Expo fork:安装、CLI 与传输
- Expo fork:API 与认证
- Expo fork:端点发现
- Expo fork:WebRTC 架构
- Expo fork:旋转与坐标映射
- Expo fork:手势约定
- Apple:在模拟器或设备上运行
- Apple:安装 Xcode 组件
- Apple:Xcode 命令行工具
- GitHub:托管 runner
- Node.js:受维护的发行版本
- Cloudflare:Quick Tunnels 的适用范围
- native-sim:统计开关实现
- Apple:开启与限制屏幕共享
- Apple:连接、查看与断开屏幕共享
- Apple:屏幕共享排错
- Tailscale Serve:命令与关闭入口
- Tailscale Serve:私网HTTPS与访问规则
- Tailscale:访问控制规则
屏幕共享可以直接在浏览器打开吗?
Apple屏幕共享使用客户端。浏览器预览需要另外部署相应服务,本文以Expo维护的serve-sim作为可选实现。
浏览器是否直接运行 iOS?
iOS Simulator 在远端 Mac 上运行;浏览器显示传来的画面并发送输入。
HTTP 200 能证明画面正常吗?
不能,还需要验证有效帧被解码呈现,以及操作后预期界面变化。
H.264 和 WebRTC 一定优于 MJPEG 吗?
取决于编码器、浏览器和实际网络。native-sim 默认使用 MJPEG,本文没有给出未经测试的性能排名。
可以直接转发 3200 端口吗?
不应暴露无认证端口。使用受控私网或有身份校验的 HTTPS/WSS 入口,验证 HTTP 与 WebSocket 均受保护。
已经在真实 Mac 上验证了吗?
尚未运行真实 Mac 会话。已完成固定版本源码核查与27项离线测试,人工日志仅演示复盘规则。