远程看 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、视频或触摸会话,也没有替读者开启共享、安装服务或修改网络权限。

图 1:自管 Mac 上的两条远程查看路径
图 1:自管 Mac 上的两条远程查看路径

来源:Apple:在模拟器或设备上运行 · Apple:开启与限制屏幕共享 · Apple:连接、查看与断开屏幕共享 · Expo fork:安装、CLI 与传输

2. 先在自管 Mac 上启动目标应用

Mac 上需要完整 Xcode、对应平台支持与 iOS Simulator runtime。独立的 Command Line Tools 包不能代替整套 iOS 模拟器环境。若选择下文的 Expo 浏览器预览服务,还需要 Apple silicon arm64:该包的原生组件仅提供 arm64 版本,这个限制不要泛化成所有 Apple 屏幕共享功能都要求相同硬件。

shell
# 以下命令都在运行模拟器的 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 不直接适用于模拟器;归档能解压也不代表架构和最低系统要求匹配。

shell
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 密钥链与团队权限管理,不要为了传递画面把证书、私钥复制给观看者。

图 2:先核对 Mac、工具链、模拟设备和应用
图 2:先核对 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。

shell
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 配置;下面示例只适合没有其他根路径服务的专用主机,不能直接覆盖共享机器的已有路由。

shell
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;输出可能含控制凭据,分享前删除敏感字段。

shell
# 在捕获端所在 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/readyz

healthz 表示服务在响应,readyz 等待设备和捕获会话就绪。随后仍要看到帧被解码并呈现,操作一个可逆的界面控件,再观察界面变化。HTTP 200、网络面板有字节和 WebSocket 已连接分别覆盖不同环节;任何一个都不能单独作为远程操作完成的证据。

首帧验收最好选带时钟或可改变状态的测试画面,同时记下浏览器端观察时刻与操作结果。反复呈现同一张图片仍可能是应用卡住,所以帧计数之外还要确认内容按预期变化。跨机器时间若未同步,不直接相减算延迟;可以在同一观察端记录点击与后续像素变化的时间差,并明确其中包含网络、排队、应用处理和显示等待。

图 3:从进程响应到操作结果的五层验收
图 3:从进程响应到操作结果的五层验收

来源:Expo fork:端点发现 · Expo fork:安装、CLI 与传输

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、候选类型、收发包和丢包统计。两种结果都要连同观察时长记录,停止变化的界面不适合代表连续滚动的负载。

图 4:MJPEG、AVCC 与 WebRTC 的画面路径和公共输入通道
图 4:MJPEG、AVCC 与 WebRTC 的画面路径和公共输入通道

来源:native-sim:runner 工作流 · Expo fork:WebRTC 架构 · Expo fork:安装、CLI 与传输

7. 点击偏移时检查内容矩形和方向

浏览器窗口尺寸、预览容器尺寸和设备内容尺寸是三组数。为画面保留比例后,容器常出现留白。把整张网页的像素坐标直接除以设备分辨率,按钮位置就会偏离。对于未旋转的内容矩形,先减去画面左上角,再分别除以内容宽高,得到零到一之间的坐标;落在留白中的点击应忽略。

旋转、缩放、切换设备后要重新读取配置。官方客户端已有方向补偿,继续使用它时不要再叠加一次旋转;自写客户端则依据当前画面几何和源码映射处理。连续拖动的 begin、move、end 应留在同一条有序连接中,中途重连后放弃旧手势,确认当前界面再重做。

先用无副作用的测试页验证四角、中心和滚动方向,再测试输入框。键盘焦点、快捷键拦截、键盘布局与输入法都可能影响结果。多名浏览者能同时连接并不代表拥有独占控制权,当前 WebRTC 架构没有跨观看者的控制仲裁,协作时要先约定谁在操作。

操作超时后先回看界面和事件记录,再决定是否重试。一次点击可能已经送达,只是画面回传延迟;重复点击会把相同动作执行两次。涉及支付、发布、删除或账号设置的界面,应使用专门的测试环境并保留人工确认。用于联调的屏幕控制链接也不应连着个人 Apple 账号或含私人数据的模拟器。

图 5:归一化坐标必须以画面内容区域为基准
图 5:归一化坐标必须以画面内容区域为基准

来源:Expo fork:旋转与坐标映射 · Expo fork:手势约定 · Expo fork:WebRTC 架构

8. 权限边界覆盖画面、控制和数据

屏幕共享能操作整个 Mac 桌面,浏览器预览也带有输入与调试能力。把仅需观看的人授予控制入口之前,先确认目的和范围。当前 Expo fork 的 --require-token 保护除健康检查外的路线;不带该选项不能假定已经启用认证。

TLS 保护传输,tailnet 规则限制网络访问,预览 token 保护应用会话。三者各自检查,不以 CORS 代替身份授权。从没有 token 的浏览器验证预览页面被拒绝,再由管理员验证未获准设备无法访问私网服务。健康检查可匿名成功,不能拿它作为全部路线受保护的证明。

查看者可能看到屏幕、应用日志和调试信息。会话只使用测试数据,分享材料前去掉 token、账号、地址和业务秘密。源代码、签名材料、构建归档应按原有权限保存;视频传输成功不会自动证明这些文件的可见性安全。

图 6:传输保护、身份授权和数据范围分别检查
图 6:传输保护、身份授权和数据范围分别检查

来源:Apple:开启与限制屏幕共享 · Expo fork:API 与认证 · Tailscale Serve:私网HTTPS与访问规则

9. 处理后台、重启与会话结束

浏览器切到后台后,解码、渲染或计时回调可能改变节奏。回到前台先观察新帧,再验证控制连接。设备关机或捕获进程重启时,旧画面可能留在页面上,要重新发现设备与端点,清空旧就绪证据,再完成验收。

应用回到桌面时,核对前台 bundle ID、启动步骤和崩溃记录。完全没有画面时,依次查看主机、readyz、传输字节与解码错误;有画面不能点时,查看授权、控制连接、焦点与坐标。连接慢则分别记录网络、编码和应用负载,不靠不断新建会话掩盖问题。

关闭浏览器不会自动停止自管 Mac 上的服务。前台运行时,在对应的 Serve 与捕获终端结束本次进程;若仍有捕获实例,先列出并停止选定设备。只有确认该模拟器没有其他人使用时才关闭它。屏幕共享在 App 中断开即可,是否关闭主机共享开关另按维护约定处理。

shell
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 配置。保留排错所需日志,按既定保留期清除临时归档。下一次运行从设备与应用身份开始确认,端口相同也不能当作上一次会话仍然有效。

图 7:设备或捕获重启后,重新建立会话证据
图 7:设备或捕获重启后,重新建立会话证据

来源: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 与浮动捕获包,团队要在审查模板时更新受维护版本并固定工件。

shell
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。

shell
python3 -m unittest -v
python3 session_log.py artificial-session.ndjson --at 10

配套输入是人工示例:第 2 秒报告一帧呈现,第 4 秒进入后台,第 9 秒回到前台,第 10 秒复盘时画面证据已经八秒未更新。预期 verdict 为 stale,输入仍是 accepted_only。这只演示判断规则,不代表发生过真实远程会话。

artificial-session.ndjson · 人工示例
{"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 无帧、解码失败、未呈现、陈旧帧、重复帧、重启、前后台、输入回执、结束状态与非法数据。完整程序和测试如下,可直接复制为同名文件;页面不依赖外部代码附件才能复现。

session_log.py · 完整源码
"""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())
test_session_log.py · 完整测试
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 的兼容性、视频质量和网络表现,需要按这份记录在实际项目中验证。

  1. native-sim 0.2.2:项目说明
  2. native-sim:启动与发布动作
  3. native-sim:runner 工作流
  4. native-sim:认证网关
  5. Expo fork:安装、CLI 与传输
  6. Expo fork:API 与认证
  7. Expo fork:端点发现
  8. Expo fork:WebRTC 架构
  9. Expo fork:旋转与坐标映射
  10. Expo fork:手势约定
  11. Apple:在模拟器或设备上运行
  12. Apple:安装 Xcode 组件
  13. Apple:Xcode 命令行工具
  14. GitHub:托管 runner
  15. Node.js:受维护的发行版本
  16. Cloudflare:Quick Tunnels 的适用范围
  17. native-sim:统计开关实现
  18. Apple:开启与限制屏幕共享
  19. Apple:连接、查看与断开屏幕共享
  20. Apple:屏幕共享排错
  21. Tailscale Serve:命令与关闭入口
  22. Tailscale Serve:私网HTTPS与访问规则
  23. 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项离线测试,人工日志仅演示复盘规则。