- Swift 98.8%
- Python 0.6%
- Makefile 0.3%
- Shell 0.2%
- Objective-C 0.1%
|
|
||
|---|---|---|
| .agent | ||
| .githooks | ||
| .woodpecker | ||
| Stash | ||
| StashTests | ||
| StashWidgets | ||
| Tools | ||
| .gitignore | ||
| .swiftlint.yml | ||
| AGENTS.md | ||
| CLAUDE.md | ||
| LICENSE | ||
| Makefile | ||
| project.yml | ||
| README.md | ||
stash-ios
⚠️ THIS REPOSITORY IS PUBLIC
Everything committed here is world-readable forever, including in history after a revert. No secrets, tokens or API keys. No signing certificates, provisioning profiles or team identifiers. No personal audio, transcripts or vault content — not in fixtures, not in tests, not in screenshots. Test fixtures are synthetic.
The iOS client for Stash: record a voice memo reliably, transcribe it on device, and keep it.
There is no sync in this repository and no account system — this is the phone half.
The memo list is the app. It shows every completed capture in the Documents directory, newest first, with the length read off the audio and a permanently not-synced cloud — nothing syncs until M3. The record button drives the real recorder, and the bar above the list meters the audio that is actually reaching the file. Swiping a row and tapping Delete removes the memo, taking its audio, its transcript, its track, its title and any leftover parts folder with it.
Tapping a memo shows what was said. The detail screen reads the .vtt beside the audio and
lists the cues with their timestamps — the file, not anything the app is holding, so a memo from
last week reads the same as one from a minute ago. It is read-only; there is no editor. A memo
with no transcript says why rather than showing a blank screen, a transcript the app knows is
missing speech says so at the top, and words recovered from a crash that the speech engine never
confirmed are marked as unconfirmed.
And plays it. A transport at the bottom of that screen plays the memo back, with a scrubber and the time elapsed and remaining. It is there whether or not the memo has a transcript. Playback and recording take turns with the phone's audio: tapping play during a recording says why it will not, and starting a recording while a memo is playing stops the playback. A recording whose audio is damaged says so instead of offering a button that does nothing, and one whose length cannot be read still plays — the scrubber goes and the clock counts up.
Tapping a line of the transcript plays from those words. A forty-minute walk memo is not navigable by scrubbing blind, and this is what the cue timestamps were for. While it plays, the line being spoken is tinted and the list scrolls to keep it in view — until you scroll yourself, which stops it following until you ask for it back with the button that appears. Tapping a cue during a recording refuses for the same reason the play button does.
The files are the record. There is no database and no index behind the list; it is a reading of the directory. Relaunching shows the same memos because there was never anything else to show. That is what keeps the format portable to every future client, and it means a memo can be removed or added from outside the app without anything needing to be told.
Memos come off the phone by hand. The Documents directory is the user's — visible in Finder over a cable, and under On My iPhone → Stash in the Files app. Nothing syncs until M3; this is how a memo gets anywhere else until then.
Recording your route is opt-in, and nothing is collected unless a recording is running. Settings
has a toggle, off on a fresh install, that asks iOS for location. With it on, a recording collects
where the phone goes for exactly as long as it runs — locked and in a pocket included — and stops
the moment it ends. Turning the toggle off mid-recording stops it there and throws away what that
recording collected — including the parts of it already on disk, because the route is written as the
walk happens rather than only at the end. What a completed recording leaves is <base>_track.gpx
beside the memo, on the phone, sent nowhere. The screen shows what iOS actually allows, not what was asked
for, so a permission revoked in Settings turns the toggle off rather than leaving it claiming
otherwise.
Settings → Diagnostics is what is left of the capture harness: what capture last did, the route collected for the recording in progress or the one just finished, and a ShareLink per file. It stays until Finder and the Files app have been confirmed on a device.
A completed capture is up to four files — <base>_audio.m4a, <base>_transcription.vtt where
somebody spoke, <base>_track.gpx where the route was recorded, and <base>_title.txt where
auto-naming was on and there was a transcript to name from, or where somebody typed a name in the
Name field on the memo's screen. Clearing that field takes the file away and the memo reads as
its recording date again. The middle two are
written in roughly one-minute parts while the recording runs, into a sibling <base>.parts/
folder, so a transcript can be read before the recording ends and a crash costs a minute rather
than a memo. Stopping joins each set of parts into its finished file and clears the folder away.
The route's open part is rewritten every ten seconds rather than only at its boundary, because the open part is the end of the walk: a kill would otherwise take everything since the last boundary, and one before the first boundary would leave no track at all.
The track is GPX 1.1 — an open format every mapping tool already reads, carrying an absolute UTC
time and an altitude on every point natively, with speed and course in Garmin's
TrackPointExtension and the fix's horizontal accuracy under a Stash namespace. It is not written
as <hdop>, which is a different quantity.
A recording the app was killed during never reaches stop, so the next launch finishes the job: one sweep assembles any capture with parts and no finished file beside them, transcripts first and then routes. Parts go only once the file is on disk and they are unbroken — a part that never landed leaves a transcript short by that much speech, or a route short by that much walking, and the part left in the folder is what says so. A missing route part also breaks the track into segments at the gap, so a partial route cannot be read as a whole one.
A route is only recovered while the location toggle still says yes, and it is checked again in the instant before the file is written. A route that cannot be published is removed rather than left for a later launch to find. Turning the toggle off mid-recording leaves a marker beside the parts before it deletes them, so a deletion the app was killed inside is finished on the next launch rather than mistaken for a crash and published.
Each assembly clears its own parts and nothing else. The folder is shared, so whichever of the two publishes last is the one that finds it empty and removes it. A folder that outlives a clean stop is not by itself a sign of anything, and neither is one holding a file somebody dropped in through the Files app. A part left behind is the signal.
Turning the location toggle off mid-recording removes the route already on disk, not only what is in memory. Withdrawal is immediate and it reaches the files.
Capture keeps running with the phone locked and the app in the background, follows the current microphone across route changes, and resumes rather than ending when a call interrupts it. The recording bar names the microphone in use, and says so when the microphone is gone and the file is still open.
While a recording is running there is a Live Activity on the lock screen and in the Dynamic
Island, showing elapsed time and the current microphone, with a stop button that ends the
recording without unlocking the phone. It is declared by the StashWidgets extension; the app
performs the stop.
Recording starts in one tap from outside the app. A Record control can be placed in Control Center, on the lock screen, or on the Action Button. Tapping it brings Stash forward and starts a memo — from the lock screen, that means unlocking first.
It opens the app because iOS will not do anything else: an app cannot start audio recording from
the background, and that holds with the phone unlocked as much as locked. Measured, not assumed —
a Control Center tap on an unlocked phone is refused at AVAudioSession.setActive(true). What the
control saves is finding the app, not the unlock.
It is a button and not a toggle because stopping already has a better affordance: the Live Activity that appears the moment the recording starts. That Live Activity is also required — iOS stops the audio for a recording begun from a control if there is no activity showing — so the control says why it will not record rather than starting something that would be killed.
Recordings are written as a single fragmented .m4a rather than in chunks. An MPEG-4 index is
normally written at the end, on finalise, so an app killed mid-recording leaves a file full of
audio that nothing will open. AVAssetWriter.movieFragmentInterval flushes a playable index
periodically instead: a recording killed at minute 40 still yields 40 minutes of audio, less the
few seconds since the last flush.
Getting memos off the phone
There is no sync, so the Documents directory is the way out. UIFileSharingEnabled and
LSSupportsOpeningDocumentsInPlace are both set, which opens two routes to the same files:
Connect the phone by USB, open it in Finder, and the Files tab has a Stash folder — drag memos out of it. Or, on the phone, the Files app has Stash under On My iPhone, from where a memo can be moved to iCloud Drive or anywhere else. The folder only appears once there is something in it.
A memo is up to four files, and all of them have to travel. <base>_audio.m4a,
<base>_transcription.vtt, <base>_track.gpx and <base>_title.txt are one memo because they
share the <base> — a memo nobody spoke into has no transcript, one recorded with location off has
no track, one recorded before auto-naming was turned on has no title, and all are complete as they
stand. Take only the audio of one that has all four and the rest stays behind; rename any one of
them and it stops being part of the set. Nothing prevents that, because
preventing it would mean a hidden index, which is the thing this format exists to avoid.
The directory is writable from outside, and the app follows it. A memo deleted in Finder is gone from the list on the next read, and one copied in appears. That is the format working: the list is a reading of the directory, not a cache of it.
A recording in progress is visible too. Its .m4a is on disk and growing, with a .parts
folder of transcript fragments beside it. Copying the audio mid-recording gives everything up to
the last index flush, a few seconds behind; deleting either one mid-recording breaks that
recording. Nothing removes that folder recursively: assembly and swipe-to-delete both take only
the transcript parts Stash wrote, and a folder holding anything else stays — deleting the memo
says so and keeps the row, so it can be deleted again once the file has been moved out.
Requirements
Xcode 26.6 or later, and XcodeGen:
brew install xcodegen swiftlint
Build and run
git clone ssh://git@git.wes.today:2224/Stash/stash-ios.git
cd stash-ios
make run # simulator
make install # a connected iPhone
make help lists the rest. The .xcodeproj is generated from project.yml and is not
committed, so every target regenerates it first — there is no separate step to remember and no
way to build a stale project.
To work in Xcode instead, make generate && open Stash.xcodeproj, then ⌘R. Regenerate after
any change to project.yml or after adding or removing a source file.
On device
make install builds signed, installs, and launches on a connected iPhone. Connect it by USB,
unlock it, and tap Trust the first time.
Nothing about signing is committed — this repository is public, so it holds no certificate, no provisioning profile, no team identifier and no device UDID. Two values are needed at build time and both are discovered from the machine you are on:
| Variable | Default | Override when |
|---|---|---|
STASH_DEV_TEAM |
The OU of your Apple Development certificate, read from the keychain | You belong to more than one team |
STASH_DEVICE |
The first connected, paired device | More than one device is attached |
make install STASH_DEVICE=my-iphone STASH_DEV_TEAM=ABCDE12345
If either is missing, make install says which one and how to get it rather than failing
inside xcodebuild.
A first run mints a development provisioning profile through -allowProvisioningUpdates, so
it needs an Apple ID signed in under Xcode → Settings → Accounts. Development builds expire;
when the app stops launching, make install again.
Lint
make lint
CI runs the same rules on every branch, in a Linux container. See .woodpecker/lint.yml.
The simulator build in .woodpecker/build.yml needs a macOS agent, and there isn't one yet, so
it sits pending — build by hand before opening a PR. AGENTS.md has the detail.
Contributing
Read AGENTS.md first — it is the build standard for this repository, and it is
what a PR is reviewed against. Every issue gets a feature branch; main is protected.
Licence
MIT. See LICENSE.