openGym is a self-hosted gym and bodyweight tracker by Duarte Santos, licensed AGPL-3.0-or-later. Its own iOS app is a Capacitor project, and Capacitor cannot be built on Linux — it ships CocoaPods podspecs, no Swift Package Manager manifest, and its Xcode target uses a storyboard and an asset catalog.
This builds it anyway. The app here is a hand-written SwiftPM shell around openGym's own web bundle, compiled and packaged entirely on this Fedora machine with xtool. No Mac was involved at any point.
The result is ios-shell/xtool/openGym.ipa — 61 MB, arm64, iOS 17.4+.
The app installs under the name Stanley's Gym, not openGym. The display name, the icon and
the theme are the parts that differ from upstream. To rename it, edit CFBundleDisplayName
and CFBundleName in ios-shell/Support/Info.plist and
rebuild with xtool dev build.
The bundle id lives in ios-shell/xtool.yml as ch.stanley.opengym. It
is also hardcoded twice in PluginHandlers.swift, for the app id and the Keychain service
prefix, so those have to move with it — change only the plist and xtool.yml and the app will
install alongside the existing one instead of replacing it, keeping two copies of your data.
frontend/src/native/ JavaScript stand-ins for the Capacitor plugins
│ one WKScriptMessageHandler named "ogNative"
▼
ios-shell/Sources/openGym/ SwiftPM shell: WKWebView, scheme handler, the plugins themselves
│ serves the bundle over app://
▼
WebBundle/ openGym's mobile build, VITE_MOBILE=1, with 6554 exercise stills
Every @capacitor/* import in openGym's frontend/src is a dynamic import guarded by MOBILE,
so none of it reaches a web bundle. The surface actually used is small and was measured, not
guessed:
| Shim | Methods | Why it is needed |
|---|---|---|
core |
getPlatform, isNativePlatform, convertFileSrc, registerPlugin |
platform checks and the Print lookup |
filesystem |
readFile, writeFile, appendFile, deleteFile, deleteDirectory, readdir, mkdir, rmdir, getUri, stat, exists |
the opengym-state.json mirror, auto-backups, media, share-sheet exports |
local-notifications |
checkPermissions, requestPermissions, schedule, cancel, getPending |
the 60-day workout reminder and the 7-day missed-workout nudge |
app |
addListener, exitApp, getInfo |
appStateChange, so the reminder window rebuilds on foreground |
share |
share |
WKWebView cannot download a blob URL |
secure-storage |
get, set, remove |
the Coach API key and the Cloudflare Access token, in the Keychain |
Print |
printHtml |
the weekly plan through the iOS print sheet |
VITE_NATIVE_SHIM=1 points those specifiers at src/native/. openGym's own application code is
unmodified: lib/mobile.js still says await import('@capacitor/filesystem') and still gets a
Filesystem with the shape it expects. There is no fork of the app to keep in step — only this
directory, with 25 unit tests over it.
RestAlert, SystemBars, Install, BackupFolder and HealthConnect are Android-only and all
sit behind if (!(await isAndroid())) return null in openGym's own code. On iOS the registerPlugin
proxy rejects them, which is the branch upstream already takes. The notable one is RestAlert: a
foreground service that keeps the rest countdown sounding with the screen locked. iOS has no
equivalent, and lib/rest-alert.js already falls back to a scheduled notification there.
The live camera QR scanner (BarcodeScanner.scan) is likewise unimplemented — ML Kit supplies its
own full-screen UI, which is not something to rebuild. Photo import of a check-in code works: the
shim decodes with jsQR, reusing lib/scan-web.js, the decoder the browser PWA already ships.
A WKWebView loaded from file:// runs in an opaque origin, which gets no localStorage at all. openGym
keeps its entire state there. AppSchemeHandler serves the bundle over a custom scheme so the page
has a real, persistent origin. This is the load-bearing decision in the shell.
Prerequisites, all installed here:
- Swift 6.4.1 (
~/.local/swift, the swift.org Fedora 41 build) - xtool 1.22.2 (
/usr/local/bin/xtool) - Node 20+ and npm
- A Darwin Swift SDK —
xtool sdk install ~/Downloads/Xcode.xip --slim, built from Xcode 27 againstiPhoneOS27.0.sdk
# 1. the web bundle (VITE_MOBILE=1 + the native shim, then staged into the app)
bash scripts/build-web.sh
# 2. the app
cd ios-shell
xtool dev build # -> xtool/openGym.app
xtool dev build --configuration release --ipa # -> xtool/openGym.ipaupstream/ is a shallow clone of openGym at a pinned commit. It supplies frontend/'s origin, the
api/coach core the bundle imports, and catalogue/media. It is never committed.
To install:
xtool auth login # Apple ID; a free one works, with 7-day signatures
xtool devices # the iPhone must be visible over USB
xtool dev run # build, install and launch| What | Licence |
|---|---|
Everything openGym wrote — frontend/, api/, catalogue/, mcp/, web/ |
AGPL-3.0-or-later, © Duarte Santos |
This build's own work — ios-shell/, frontend/src/native/, scripts/, the docs |
AGPL-3.0-or-later, and additionally GPL-3.0-or-later |
catalogue/media/ images |
© Gym visual — not ours to licence, see below |
The repository is AGPL-3.0-or-later as a whole and has to be: it contains openGym's code. The new
work is offered under both, which AGPL-3.0 section 13 permits explicitly. See NOTICE.md.
Full licence texts: LICENSE (AGPL-3.0-or-later) and LICENSE-GPL-3.0.txt (GPL-3.0-or-later).
This is a personal build. Nothing in here is published by default: catalogue/media/ and every
build output are gitignored, and upstream/ — the pristine openGym clone that supplies the
exercise media and the original frontend/ — is gitignored too. A clone of this repository is
therefore source only. It will not build as-is: scripts/build-web.sh reads the 6,554 media
files from upstream/, which anyone using this needs to clone from
openGym themselves.
- openGym's code is AGPL-3.0-or-later, © Duarte Santos, and this derivative keeps that licence.
See
NOTICE.upstreamfor their third-party notices, including the app-store exception under AGPL §7 and the MuscleMap body-diagram attribution. - The exercise media (
catalogue/media/, 13,109 files) is © Aliaksandr Makatserchyk, Gym visual. It is licensed for use within openGym and is explicitly not AGPL. Upstream's notice is unambiguous: third parties have no rights to reuse, extract or integrate it into independent apps, websites or products. It is gitignored here, fetched by cloningupstream/, and staged into a local build only. - This shell —
ios-shell/,frontend/src/native/,scripts/— is the work of Stanley Sujith Nelavala.
The consequence, stated plainly: this build is fine to run on your own phone and is not fine to publish, because publishing the bundle would redistribute media that is not ours to give away. The six thousand exercise animations are the reason that matters, and they are also the app's main visual feature — so this is a real constraint on any future public version, not a technicality.
cd frontend && npm test # openGym's own suite
npx vitest run src/native/ # the shim's 25 testsThe upstream suite runs 5070 passing, 28 skipped, against one genuine failure in
src/sheets.day-note.test.jsx. That failure is upstream's: the same test fails
identically in an untouched checkout of DuarteSantos8/openGym, and the file here is
byte-for-byte the same. Nothing in this build causes it.
Two more files can report as failed on a loaded machine —
src/store/useStore.adopt-hold.test.jsx and src/store/useStore.mobile-remote.test.jsx.
Both time out in a beforeAll that dynamically imports useStore.js, against a 10s hook
limit. They pass on their own:
npx vitest run src/store/useStore.adopt-hold.test.jsx src/store/useStore.mobile-remote.test.jsxThat is upstream's slow-import problem, not a regression from this work.