ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

openGym 自托管实战指南:4 条命令搭好自己的健身记录 App,含手机配对与历史迁移

openGym 自托管实战指南:4 条命令搭好自己的健身记录 App,含手机配对与历史迁移

openGym 自托管实战指南:4 条命令搭好自己的健身记录 App,含手机配对与历史迁移

【免费下载链接】openGymOpen-source gym & body-weight tracker, now in the App Store (iPhone + Apple Watch) and as an Android APK. Plan routines, log workouts, see which muscles are trained, import from Strong/Hevy/FitNotes. Self-host it or use it on the phone alone.项目地址: https://gitcode.com/GitHub_Trending/op/openGym

openGym 是一款开源、可自托管的健身房与体重训练记录 App:按周计划常规动作、逐组记录训练、查看肌肉训练与恢复情况,并支持从 FitNotes、Strong、Hevy 导入历史数据。数据存放在你自己服务器上的一个文件夹里,没有订阅、没有广告、没有遥测。本文以仓库源码与官方文档为据,把自托管部署、三种使用形态的取舍、手机 App 配对、历史迁移、数据备份与常见故障排障各讲一遍,每一步都能在仓库里查证。

读完这篇,你可以做到:

  • 用 4 条命令完成 Docker 自托管部署,创建第一个 Passkey(指纹/面容登录)档案
  • 分清"浏览器 PWA / 手机纯本地 / 手机配对服务器"三种形态,选对适合自己的那种
  • 完成"服务器地址 + 配对码"的手机配对,并处理 401、409、CORS、离线四类故障状态
  • 把 FitNotes / Strong / Hevy / Apple Health 的历史数据迁进来,并用一条tar命令备份全部数据

1. 从零开始:4 条命令跑起自托管 openGym

前提只有一台装了 Docker(含 Compose 插件)的机器。

git clone https://gitcode.com/GitHub_Trending/op/openGym cd openGym cp .env.example .env docker compose pull docker compose up -d

仓库自带的 docker-compose.yml 里只有两个容器:api(纯node:http的 Node 服务,负责 Passkey 认证与每用户数据)和web(nginx,构建并托管前端,同时把/api代理到 api 容器)。动作动画(180 px,约 80 MB)打包在 web 镜像内,首次启动没有任何额外下载。

验证部署:

docker compose ps curl http://localhost:8080/api/health # 返回 {"ok":true,...}

浏览器打开http://localhost:8080,点Create profile即可创建档案——跑 Docker 的这台机器本身就是localhost,Passkey 可以直接用。

为什么 nginx 必须把/api代理到同一域名?Passkey 绑定"单一来源"(应用所在域名),浏览器只认这一个域。官方把前端与 API 放在同一 origin 下,正是为了满足这条硬性要求(见 README.md "How it works")。实用推论:以后你换反向代理时,也要保证前端页面和/api/从同一个 origin 出去,否则登录直接失效。

2. 三种使用形态怎么选:浏览器 PWA、手机本地、配对模式

openGym 从同一份代码构建出三种形态:自托管版是默认形态,手机原生版则靠VITE_MOBILE=1开关构建(见 frontend/package.json 的build:mobile脚本,该脚本执行 Vite 构建后自动cap sync进android/与ios/两个原生工程)。差异汇总自 docs/MOBILE.md:

浏览器 PWA(自托管)手机纯本地模式手机配对模式
登录方式Passkey,每人一个档案无账号,手机即账号一次性配对码 + bearer token
数据存放服务器./data文件夹手机私有存储里的文件服务器(手机保留本地镜像)
训练日提醒服务器发 Web Push原生本地通知,完全不经过服务器原生本地通知(配后台核对)
适用场景多设备同步、家人共用实例不想装服务器想要原生体验又想要同步

手机形态默认不与任何后端通信:没有登录页、没有同步、没有遥测。为对抗 iOS 在存储压力下清空 WebViewlocalStorage的行为,每次状态变更都会再写一份opengym-state.json到 App 私有目录,启动时从该文件恢复(机制见 frontend/src/lib/mobile.js 头部注释与nativeSave)。实用推论:纯本地模式下手机是唯一副本——建议打开 Settings → Data & backup →Auto-backup on changes,之后每完成一次训练就在Documents/openGym/下生成当天日期的 JSON 副本(每个安装只保留最新 14 个),可以直接把 Syncthing 或云文件夹指向它。

不想装服务器也有路:Android 的官方签名 APK 随版本发布(每个文件旁附.sha256,应用内更新器会保持在同一规格内升级);iPhone 可用 App Store 版,或自托管后从 Safari"添加到主屏幕"。

3. Passkey 的硬性要求:先定死 .env 里三件事

浏览器对 WebAuthn 只有两条规则:Passkey 绑定到一个精确的主机名(RP_ID),且只在HTTPS下生效——唯一例外是http://localhost。由此推出两个结论:

  • 跑 Docker 的机器上,http://localhost:8080开箱即用;
  • 手机走http://<局域网IP>:8080时 Passkey 弹不出来——它既不是 localhost 也不是 HTTPS。要从手机用 Passkey,必须上真实 HTTPS 域名。

官方给出的做法(.env.example 注释与 docs/SELF_HOSTING.md):把实例放到 Cloudflare Tunnel / Caddy / Traefik / nginx 之后做 TLS 终结,然后只改两行:

RP_ID=gym.example.com ORIGIN=https://gym.example.com WEB_PORT=8080

这两个值的写法是最常见的登录故障来源,要求如下:

变量正确常见错误
RP_IDgym.example.comhttps://gym.example.com、带端口、带尾斜杠
ORIGINhttps://gym.example.com裸域名、带尾斜杠

RP_ID是裸主机名,ORIGIN是带协议的完整来源,两者都必须与浏览器地址栏逐字一致。还有两条容易踩:

改了RP_ID之后,已有 Passkey 全部失效(它们绑定在旧主机名上)——先定好域名再邀请别人注册。

docker compose restart不会重新读取.env,改完配置必须用docker compose up -d。

.env里其余值得知道的开关(默认值出自 README.md 配置表):

变量默认作用
SESSION_DAYS90登录 / 手机配对的有效期(天)
FIRST_USER_ADMIN关1:空实例上第一个注册的档案成为管理员
INVITE_ONLY关1:新建档案需要邀请码
ALLOW_GUEST开"Continue without account" 访客模式(数据只留在那个浏览器)
PASSWORD_LOGIN关1:在 Passkey 旁启用用户名 + 密码登录

4. 手机为什么不能用 Passkey 登录:配对码 + 半寿 token 的完整链路

这是整套系统里设计含量最高的部分。App 的 WebView 运行在自己的 origin 上,永远匹配不上 WebAuthn 需要的真实主机名,所以 Passkey 在物理上走不通(docs/SELF_HOSTING.md 第 2 节明确说明了这一点)。官方替代方案是"从一个已登录的浏览器完成配对":

  1. 浏览器里打开 Settings → Account →"Pair the mobile app",生成一个 8 位一次性码:有效期 5 分钟,字母表刻意去掉0/O/1/I(方便对着屏幕念出来)——生成逻辑见 api/server.js 的makePairCode;
  2. 在手机(首次启动界面,或之后 Settings →"Connect to my server")输入服务器地址与该码;
  3. 服务端POST /api/pair/redeem不需要会话——码本身就是凭据,取出后立即删除(防重放),校验未过期且账号未被禁用后,签发一枚与浏览器会话同类的 session token 返回。

手机端的地址输入很宽容:裸主机名、缺协议、尾斜杠、多余空白都接受,默认补https://(见 frontend/src/lib/remote.js 的normalizeServerUrl)。

Token 半寿续期:用过的手机永不掉线

配对拿到的 token 寿命为SESSION_DAYS(默认 90 天),但它会自我续期:手机每次启动都请求GET /api/me,服务器发现 bearer token 剩余寿命不足一半时,就在响应里附一枚新 token(见 api/server.js);客户端的renewToken把新 token 落回配对文件(见 frontend/src/lib/remote.js)。api/test/server-token-refresh.test.js 用一个SESSION_DAYS=10的实例把这条行为钉死在测试里。

实用推论:

  • 手机只要还在被使用就永远不会过期;只有超过 90 天完全没打开过才需要重新配对;
  • 浏览器里的"Sign out everywhere" 会连带杀掉已配对手机——它们用的是同一枚签名会话 token,只是经请求头而不是 cookie 交付。手机没有 Passkey 可以重登,只能重新配对;期间手机上的数据分毫不动,重新配对同账号后自动合并。

配对模式下的三个注意点

  • 尽量用https://:传输的是 bearer token 而不是 cookie,明文 http 下 token 会裸奔过网。Android 自 v1.3.11 起也接受http://<局域网地址>,仅限可信网络。
  • 不要在 openGym 前面自己加 CORS 处理:App 的 origin 是https://localhost(Android)/capacitor://localhost(iOS),每个请求前都有 preflight;openGym 自己应答这个 preflight。如果反向代理抢先应答、或额外加了一个Access-Control-Allow-Origin(出现两个同名字段浏览器也拒收),配对会直接失败而 openGym 容器日志一片干净。任意机器上可以自检:
curl -si -X OPTIONS https://HOST/api/pair/redeem \ -H 'Origin: https://localhost' \ -H 'Access-Control-Request-Method: POST' \ -H 'Access-Control-Request-Headers: content-type'

看到204且恰好一行access-control-allow-origin即正常;200、重定向或登录页说明代理在捣乱。

  • 断开前先核对同步状态:Settings 里的 Disconnect 会先检查服务器是否已拥有手机的全部变更。若还没有,它会告诉你还差几条,并给出Try again / Export backup / Disconnect anyway——最后一项把未同步变更留在手机(opengym-stash.json),下次与同一服务器、同一账号重新配对时自动补回。

5. 迁移训练历史与数据备份:数据在哪、怎么备

多设备同时编辑:两台设备为什么不会互相覆盖

服务器上每个档案的数据是一份文档 + 一个 rev 修订号。设备保存时把自己最后见过的baseRev一起提交;若期间另一台设备写过,服务器以409拒绝并回传当前文档,设备按字段合并后重试——每条变更(细到单个设置项、单个常规字段)都带自己的时间戳,合并时逐条取最新,删除保持删除(机制见 README.md "How sync works"):

实用推论:手机上记组数的同时用笔记本改计划是安全的——字段级的后编辑者胜,而不是整文档覆盖;离线期间的变更永不丢弃,恢复连接后自动补上,App 会在离线时挂出提示横幅。

从 FitNotes、Strong、Hevy 迁入历史

入口都在Settings → Data & backup。从旧 App 导出 CSV 再导入:动作名与内置库(5600+ 动作)匹配,不认识的自动变成"你自己的动作",文件里没有任何一条会被丢掉;已有记录的日子保持不动,所以同一个文件导入两次也不会重复。以下 App 的导出无需任何编辑即可识别(对照表见 docs/DATA_IMPORTS.md):

旧 App导出位置
FitNotes(Android)Settings → Backup/Export → Spreadsheet Export
FitNotes 2(iOS)Export workouts as CSV
StrongSettings → Export Data
HevyProfile → Settings → Export & Import Data
Apple Health(体重)健康 App → 头像 → Export All Health Data,导入其中的export.xml

Hevy Pro 可以跳过 CSV:把 API key 直接粘进Import from Hevy(仅本次导入使用,不保存),常规会作为新计划导入、不覆盖已有内容。自己的 CSV 也行,最小要求是"日期 + 动作名 + 一个测量值":

workout name,exercise,date,weight kg,reps Leg Day,Squat,2026-08-21,120,5

注意:历史与计划是分开的两条线——导入训练记录不会创建或改动你的常规。

数据在哪里,怎么备份

服务器侧一切都在宿主机的./data(docker-compose.yml 把它挂进容器作为/data):db.json(档案 + Passkey 公钥)、state-<user>.json(每人的计划/训练/体重/设置)、secret(会话签名密钥)、audit.log(活动日志),以及uploads/(自建动作的照片与视频——上传前手机已完成脱敏:照片重编码、EXIF/GPS 不存活,按 SHA-256 命名)。一条命令完成全量备份:

tar czf opengym-backup-$(date +%F).tar.gz data/

secret是一票否决项:迁栈时丢了或替换了data/secret,所有会话与手机配对立即失效(症状就是手机提示 "Your server no longer accepts this phone")。整包备份data/是唯一保险。 备份要送第三方云时留意两点:audit.log里有所有人的登录时间记录;uploads/占大头,不送出去可以tar czf opengym-backup.tar.gz --exclude=data/uploads data/。

手机侧三个文件值得知道(详见 docs/MOBILE.md):

  • opengym-state.json——手机上一切数据的持久副本,每次变更即写;
  • opengym-remote.json——配对凭据。为什么单独存:opengym-state.json的内容就是原样PUT给服务器的同步体,设备自己的连接秘密绝不能伪装成训练数据被同步出去;
  • opengym-stash.json——"Disconnect anyway"或换账号配对时留下的变更,等待它们各自的服务器与账号。

6. 排障对照表:自托管与手机配对的常见故障状态,以及自己构建 APK

排障对照表

综合 docs/SELF_HOSTING.md 排障节与 docs/MOBILE.md 连接状态表:

现象可能原因处理
手机上没有 Passkey 提示走http://局域网 IP 打开,不是 HTTPS上 HTTPS 域名(或用访客模式 /PASSWORD_LOGIN=1)
登录时 "verification failed"RP_ID/ORIGIN与地址栏不一致先查docker compose logs api \| grep 'gym-api on'看服务器实际加载的值,再up -d重启
改了.env没生效容器没有重读环境docker compose restart不重载.env,用docker compose up -d
端口 8080 被占用端口冲突.env里设WEB_PORT=9090(本地测试时同步改ORIGIN)
docker compose pull报 denied预构建镜像不可用改从源码构建:docker compose up -d --build
照片/视频上传 "refused as too large"前置代理的 body 上限或超时太小提高/api/media/的 body 限制与读超时(内置 web 镜像已按 48 MB 放行)
手机:"answered with something other than openGym (HTTP 200)"代理登录页或兜底路由替/api/*应答让/api/*原样透传到 API,包括Authorization头
手机:"Your server no longer accepts this phone"服务器回 401:"sign out everywhere"、账号被禁用、secret被换、token 超期、代理自带登录在已登录浏览器重新配对;手机本地数据不丢
手机配对被拒(CORS)但浏览器正常代理自己应答了 preflight / 多了一个Access-Control-Allow-Origin移除代理的 CORS 处理,用第 4 节的 curl preflight 自检

自己构建并签名 Android APK

前提:Node 20+、Android Studio(自带 SDK)、Java 21。

cd frontend && npm install npm run build:mobile # VITE_MOBILE=1 构建 + cap sync 进原生工程 cd android && ./gradlew assembleSideloadRelease # 产物:app/build/outputs/apk/sideload/release/app-sideload-release-unsigned.apk # 一次性:创建 keystore。务必保管好——换密钥签名的新版本装不到旧版本之上 keytool -genkeypair -keystore my.keystore -alias opengym -keyalg RSA -validity 10950 # 对齐 + 签名(zipalign / apksigner 随 Android SDK build-tools 附带) zipalign -f -p 4 app-sideload-release-unsigned.apk aligned.apk apksigner sign --ks my.keystore --ks-key-alias opengym --out openGym.apk aligned.apk

build:mobile之后,frontend/dist里装的是手机 bundle:如果你要拿这个dist部署到服务器,先重新跑一次普通的npm run build,否则网站会带上手机版。 Release APK 只带 ARM 原生代码(arm64-v8a、armeabi-v7a),覆盖所有手机;x86 构建只服务模拟器,会多约 12 MB。每次发布必须严格递增versionCode(当前为 27,见 frontend/android/app/build.gradle),否则更新装不到现有 APK 之上。

结语

openGym 把"你的数据,你的服务器"落到了每一层:./data一个 tar 全量可备、Passkey 私钥永不出设备、手机文件镜像扛住 iOS 存储清理、配对凭据与训练数据刻意隔离存储。抓住三条主线——RP_ID/ORIGIN决定你能不能登录,配对码 + 半寿 token 决定手机能不能一直连着,rev/409 合并决定多设备如何不打架——你就能完整地部署、配对、迁移并备份这套应用,后续升级也只需git pull && docker compose pull && docker compose up -d,./data原封不动。

【免费下载链接】openGymOpen-source gym & body-weight tracker, now in the App Store (iPhone + Apple Watch) and as an Android APK. Plan routines, log workouts, see which muscles are trained, import from Strong/Hevy/FitNotes. Self-host it or use it on the phone alone.项目地址: https://gitcode.com/GitHub_Trending/op/openGym

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进