nanoMuse on the iPhone is the iOS half of OpenMinis 1.13 under nanoMuse's name, built by CI on a Mac runner and handed to TestFlight. This page says what is in the tree, how it is built, what the TestFlight pipeline needs, and what is still to be ported from the Android app.
Status. The tree, the branding, the nanoMuse Cloud sign-in, the hub client and the pipeline
were written on a Linux machine. The app builds, signs and is on TestFlight: the first build,
0.1.31 (2), went through the iOS · TestFlight workflow on 2026-10-03; 0.1.40 is build 13, with
the internal testers and submitted to Apple's beta review for the public link
(https://testflight.apple.com/join/ZHexbDqc, which delivers the build once the review has
passed — Where it stands below). The testers' reports have driven the fixes in the release
notes (the composer, onboarding, sign-out); the Devices section and notifications from other
devices still have the fewest hours on a real device. The first archive taught the pipeline
that automatic signing wants a registered device, which is why it signs manually now.
android/ is the whole OpenMinis repository as a git subtree, not just the Android app. The
iOS half was deleted when the project started and is now restored inside that subtree, at
android/src/ios, together with the iOS dependency scripts under android/deps/ and the
android/deps/ish submodule (the ARM64 iSH fork). Putting it back where upstream keeps it, rather
than in a second subtree, is what keeps git subtree pull working for both platforms at once.
android/src/ios/ the Xcode project: Minis.xcodeproj, app, extensions, tests
android/src/ios/NanoMuse/ ours: nanoMuse Cloud client and screens (a synchronized folder
of the Minis target; drop a .swift file in, it is compiled)
android/src/ios/fastlane/ the TestFlight lane
android/deps/build_lame.sh LAME → FFmpeg → iSH → Alpine rootfs → rclone, in that order
android/deps/build_ffmpeg.sh
android/deps/build_ish.sh
android/deps/prepare_alpine_rootfs.sh
android/deps/build_rclone_ios.sh
scripts/rebrand.py the `ios()` step: ids, names, versions, strings, links, colours
scripts/gen-ios-icons.py the app icon and the four alternates, from assets/brand/
.github/workflows/ios-testflight.yml
The rules of CONTRIBUTING.md apply unchanged: new code in new files (here:
the NanoMuse/ folder), an edit inside an upstream Swift file carries a // nanoMuse: comment,
and rebranding is the script, never a hand edit.
Applied by python scripts/rebrand.py (idempotent; run after every upstream pull):
- Identity. Bundle ids
io.github.nanomuse.app(+.ShareExtension,.AgentWidget,.FileProvider), the app groupgroup.io.github.nanomuse.app, the iCloud containeriCloud.io.github.nanomuse.app, the background-task, UTType and URL-scheme ids that carry the bundle id. Unlike Android there is no source-package constraint on iOS, so the whole family moves. Theminis://andminis-mcp://schemes stay: they are upstream's contract with its own sandbox. - Names. Display name nanoMuse, "Share to nanoMuse", "nanoMuse Files"; "Minis" → "nanoMuse"
in Swift string literals, in
Localizable.xcstrings(keys renamed, all nine translations updated) and in the Info.plist usage descriptions in every language. The OpenRouterHTTP-Refererkeeps pointing at OpenMinis, as on Android: it is attribution, not identity. - Versions.
MARKETING_VERSIONandCURRENT_PROJECT_VERSIONfollow the AndroidversionName/versionCode; CI overrides the build number with the run number. - Look. AccentColor
#015CFB/#58A6FFinstead of iOS blue; the one-stroke N as the app icon (white tile, brand gradient; a dark tile for the dark appearance) and as the four skins of the in-app icon picker; the agent's header glyph 🐾 instead of ✨. - About. Links to this repository and its issues, the privacy page, nanoMuse's own tagline, and a Credits section: based on OpenMinis 1.13 (GPL-3.0), not affiliated with Meta.
Ours, in NanoMuse/:
- nanoMuse Cloud. Providers opens with a "nanoMuse Cloud" row: a phone number or an
e-mail address, a code, and the relay (cloud.md) is an ordinary OpenAI-compatible
provider in the app, with a model group of its own that becomes the default when there is none.
Same wire format and the same rules as the Android client (
io.github.nanomuse.cloud): one instance per relay, nothing of the user's own replaced, a 401 on refresh removes the provider (and keeps the account's data aside — Contract C12 below). Any build can point at another relay (Use a different server, below). Since 0.1.32 the page is the whole account — the password as the other way in, a friend's invite code, the pool in yuan with the ways on when it runs low (your own key, an invitation, a star once), usage by kind and by model, the devices holding a key, the timeline, deletion — the same sections as the phone'sCloudAccountScreen. - The agent's steps, the status line. Settings → Chat → Steps keeps the tool capsules out of
finished messages unless asked for; the typing line reads 〈name〉 is on it, not is thinking.
Both are one-line edits in upstream's
AssistantBlockView/ContentView, marked// nanoMuse:. - The Muse shell (0.1.33; iPad too since 0.1.34).
NanoMuseRootreplaces upstream'sContentViewat the root; Settings → nanoMuse has the two switches (Muse home, Face and name in the chat header), and the OpenMinis layout stays one tap away in the drawer. The chat's navigation title becomes face · name · status line (NanoMuseHeader.swift): the status reads waiting for you while a question or an approval is up, then the running tool'stool_title— the model's own words for the step, 打开携程网站 — then writing the reply, then On it: 〈brief〉, and the model's name when idle; while a new face is being drawn it reads the avatar flow's line. The face (NanoMuseFaces.swift, the drawn face from Application Support or the dragon from the bundle, five moods with the breath, bob, tilt, pop and shake of the other clients) is tapped into the agent's page. The drawer holds the sessions, search, a new side chat, Pin as the main chat, All chats and the nanoMuse settings. - The agent's page (0.1.34) (
NanoMuseAgentPage.swift, Androidui/profile): the big face with the pen badge (Change avatar puts "Change your avatar to " in the main chat's composer, Edit name, Avatar studio), the name, online, and four panes — Activity (what you asked and what the agent did, from the last two days of sessions), Approvals (the standing "always allow" answers, revocable), Daily (the routines and the goal check-ins with their next run, Manage routines), Soul & memory (SOUL.md and GLOBAL.md, editable) — and the share sheet (NanoMuseAvatarShareSheet, Androidui/avatar/AvatarShareSheet.kt). - The avatar, from the chat (0.1.34) (
NanoMuseAvatarFlow.swift, Androidavatar/AvatarFlow.kt): "换成一只橘猫" / "change your avatar to a red panda" is read before the model sees it (the same regexes, tested inMinisTests/NanoMuseLogicTests.swift); four candidates come up as a card in the chat, picked by tap or by words (the second one, 第三个, regenerate); then Finalizing poses…, the new face on, the profile pushed, a line in the memory, the share card. Through the relay with a cost card first (/v1/estimate), or through the person's own Bailian key (NanoMuseImageGen.swift, DashScope's native image endpoint) when the phone has one; the studio (NanoMuseAvatarStudio.swift) uses the same path and lets the image model be picked. - The scheduler (0.1.34) (
NanoMuseScheduler.swift): routines — a label, a prompt, a time, daily / weekdays / once or every N hours — run as a headless turn in a conversation of their own (NanoMuseHeadless). Honestly: the iPhone runs them when the app is open (every due one on becoming active), asks iOS for aBGAppRefreshTask(io.github.nanomuse.app.scheduler) when it goes to the background and runs what is due if iOS grants it, and posts a local notification at each due time (Check-in: 〈goal〉 — open to run it) whose tap opens the conversation. Every piece of copy about routines says so. Settings → nanoMuse → Scheduled tasks lists them all, the goal check-ins and the feed's included. - Goals (0.1.34) (
NanoMuseGoals.swift, Androidgoals/): Create a goal › category sends the opener to the main chat with a system addendum for a few turns; the model's```nanomuse-goalblock becomes the goal and its check-in routine,```nanomuse-goal-updateblocks move the progress; cards in the chat (Goal created, Goal update), the Goals room with progress, steps, pause / check now / done / delete, and the routines beneath. - Feed (0.1.34) (
NanoMuseFeed.swift, Androidfeed/): a daily routine writes a few short posts from the memory, the diary and the goals intofeed/<day>/<n>.mdwith front matter; the```nanomuse-feedfence is how the model hands them over. The Feed room shows them by day, Discuss opens a side chat on the post, the sliders hold what the feed is about, the time, the switch and Write it now. Ideas gained Create routine (scheduled at the idea's time, the editor opens) and Start goal beside Send to chat. - First run and settings (0.1.34) (
NanoMuseFirstRun.swift,NanoMuseSettings.swift,NanoMuseCoding.swift,NanoMuseSystemFiles.swift): the four pages of the Android first run (welcome → sign in — free → a password once for a fresh account → which model answers → meet 〈name〉), with I have my own API key opening the own-key sheet (the whole catalogue — see Your own key below), and the first conversation afterwards (the opening lines, "what should I call you?", the name chooser from the model's```nanomuse-namingblock, GLOBAL.md and SOUL.md written). Settings → nanoMuse: the account, Coding agents (Cursor / Codex / Claude Code sessions on the computers of the account over the hub'scoding.*actions, a message to any of them), Scheduled tasks, System files (SOUL, GLOBAL, the diary, the feed's instruction, the routines' schedule, Import memory from another assistant), Connectors, Data controls, the two shell switches, Show the welcome again. - Models (0.1.34) (
NanoMuseModels.swift): the relay's menu opens ondeepseek-v4.1-flash(the one marked recommended for chat;qwen3.8-27bis the hands' model and never the chat default); a pick made in the chat's picker moves to the front of the Cloud group so new chats follow it, as Android'sfollowPick. DeepSeek ids are text-only unless they namev4.1or later,visionorocr— a// nanoMuse:step at the top ofLLMModel.withInferredModality(). - Connections across devices (0.1.34) (
NanoMuseSharedConnectorsinNanoMuseConnectors.swift): the profile'sconnectorslist says which device connected what (id, label, address without the query string, how it signs in, enabled, when, the device) and never a token; this phone puts its own entries, reads the others' back, and the Connectors page lists them under On your other devices — Connected on 〈device〉 — sign in here to use it on this phone. The ways-on card in the account follows the region: Bailian first in mainland China, Sign in with OpenRouter first elsewhere. - Connectors (
NanoMuseConnectors.swift): the desktop's catalogue from the bundledconnectors.json(node scripts/connectors-json.mjskeeps it current,--checkin CI), the MCP authorization flow (initialize → 401 → protected-resource metadata → authorization-server metadata → RFC 7591 registration → upstream'sMCPOAuthController), key / open / auto services, and the client-id ask with the callback address to copy for the eight services that register no clients. The Settings row that was MCP Integrations is Connectors; Your own servers at its end is upstream's MCP page. - Data controls (
NanoMuseDataControls.swift): the relay's switch, the kept-turns count, the privacy page, deletion with a confirmation. Reach (NanoMuseReach.swift): a sheet per device of the account — open a link, send a note, a shell line, a screenshot — over the hub. - When the provider cannot be reached (
NanoMuseProviderReach.swift,NanoMuseProviderReachCard.swift): a failed turn whose error is the transport's — Could not connect to the server, A server with the specified hostname could not be found, a TLS or timeout line — or OpenAI's region not supported shows a card instead of the red banner: what happened, what helps (a VPN, the proxy under Settings → Network, another provider with a key of your own), Try again, the raw line behind Details.LLMErrorappends the failing host as[host: …]to a network error so the card can name it; the 401/403/429 whose bodiesmapHTTPErrordrops are kept for a few seconds byNanoMuseReachSignaland written into the message as a canonicalnm_reach:line byfriendlyErrorMessage— an expired ChatGPT sign-in reads Sign in again, a spent plan window The ChatGPT plan has nothing left for now with OpenAI's sentence and the reset time. The same kinds, lines and rules as Android'sProviderReach. - When the relay refuses a turn (
NanoMuseRelayRefusal.swift,NanoMuseRelayRefusalCard.swift): every refusal nanoMuse Cloud sends is one plain sentence in the phone's language and the button that fits — never a status code, the relay's JSON or upstream's Rate limited.NanoMuseReachSignalhands each non-2xx reply of a model call toNanoMuseRelaySignalfirst (the same existing// nanoMuse:spot inmapHTTPError, nothing new in upstream's files); a reply withtype: nanomuse_cloudor one of the relay's codes — or a bare 413 / 401 / 5xx from the relay's host — becomes a canonicalnm_relay:line in the message, whichNanoMuseProviderReachCarddraws as the refusal card. The sentence isNanoMuseCloud.describe's, the same the sign-in and account pages use, in all nine locales:413 too_large→ That message is too large for the model. Shorten it, leave out some attachments, or start a new chat. with New chat;401 bad_key/account_deleted→ Sign in;403 not_invited/account_disabled/signup_closed→ Open Settings;429 too_many_in_flight/locked/rate_limitedandprovider_busy(with the wait fromretry_after) → Try again;404 model_not_offered→ Open Settings;503 service_paused,sync_paused,hub_pausedand any 5xx or empty answer → Try again. - The allowance in the chat (
NanoMuseAllowance.swift,NanoMuseAllowanceCard.swift; AndroidAllowanceSignal+AllowanceWaysCard): a turn refused with429 allowance_exhaustedordaily_cappins a card under the chat header the way the star card is pinned (the shell's top inset, no new link onAIChatView.body): the lead — The free allowance is used up., Today's share …, or withpaused: true(relay 0.22) paused on this relay for now — not used up and that what is left stays as it is — then the ways on and Try again (the open chat's last turn once more). At 80 % of the pool (spend.warnfrom/v1/me) one line sits above the composer inNanoMuseChatCardsHost— Nearly used up: ¥… of ¥… left … — once per pool size (a new grant says it again), waved away with the ×; the card replaces it. - Your own key (
NanoMuseCatalogue.swift,NanoMuseVendorSheet.swift,NanoMuseOwnKeySheetinNanoMuseModels.swift; own-key.md): the vendors are the bundledNanoMuse/Resources/providers.json(node scripts/providers-json.mjskeeps it current,--checkin CI) — and the relay'sspend.guidancefirst when it sent one (relay 0.21; also beside a429 allowance_exhausted), kept inUserDefaultsbetween runs, so a vendor added on the relay shows before the app is updated.NanoMuseWaysListis the one list on the pinned card, Settings → nanoMuse Cloud and the own-key sheet: Use your own model key — the region's lead first (Alibaba Cloud Bailian on the mainland, OpenRouter and OpenAI elsewhere), three at a time behind More providers, every row saying what it covers (chat · screen · pictures · clips), Get a key opening the vendor's key page, Add openingNanoMuseVendorSheeton that vendor (the key pasted, the instance added with the vendor's endpoint and/v1setting, its models fetched, a group named after it the default when there was none); A subscription you already pay for — ChatGPT through upstream'sCodexOAuthManager, Claude, OpenRouter, Kimi's device code throughKimiDeviceLoginSheet— with the line under the ChatGPT row that OpenAI's terms cover a plan inside OpenAI's own Codex, that other apps have had this access cut off before (OpenCode, January 2026), and that an API key works if it stops (the relay's own wording when it sent one); On a computer of your own — Ollama, LM Studio, vLLM — takes the address. No vendor is recommended. - Network (
NanoMuseProxy.swift,NanoMuseNetworkView.swift, Settings → nanoMuse → Network): the HTTP proxy for own providers — host, port, optional user name and password, off by default, inUserDefaultson this phone only.URLSessionhas no per-host proxy switch, so the proxy reaches a session as a proxy auto-configuration script inconnectionProxyDictionarythat answers the proxy for the catalogue's hosts,chatgpt.com,auth.openai.comand the provider instances' custom hosts (never the relay's, never a LAN address) andDIRECTfor the rest;NanoMuseProxy.apply(to:)is one line in each of upstream's provider sessions,NanoMuseProxy.sessionstands in forURLSession.sharedon the Codex sign-in and the model list. The credentials go to the sharedURLCredentialStoragefor the proxy's protection space. Test fetcheshttps://chatgpt.com/through the proxy as entered. - The phone's chrome (0.1.35) (
NanoMuseChrome.swift,NanoMuseAppearance.swift, Androidui/chat/MuseHeader.ktandui/settings): the Muse header — the face disc, the name pill with the live status line under it, round drawer and ••• buttons — on the chat and on Feed, Ideas, Goals and Library; the side drawer after Android's; the agent's grey bubble for its replies (NanoMuseAssistantBubbleinNanoMuseChatCards.swift); the agent page's toolbar as round buttons; Settings → nanoMuse rebuilt as Muse cards in the Android order (NanoMuseSettingsHomeView: Image & video models, Avatar, Computers, Appearance — avatar size, the model under the name, the steps, the theme — Notifications, Account, Coding, Scheduled tasks, Shared folders, Chat files, System files, Version). AnmOnChangehelper keepsonChangeon iOS 16, the app's deployment target. - Motion clips (0.1.35) (
NanoMuseVideoGen.swift,NanoMuseAvatarMotion.swift,NanoMuseMediaModels.swift; Androidavatar/VideoGen.kt,avatar/AvatarMotion.kt): a drawn face gets four 4-second clips — idle, working, waiting, happy — fromwan2.2-i2v-flash(DashScope's async API through the relay, or your own Bailian key), the phone's prompts word for word, kept per device underavatar/motion/; the face plays them with anAVQueuePlayerand anAVPlayerLooper(suspended in the background), the dragon's clips come from the bundle. Image & video models lists the models, Animate the avatar after a change (on by default) and Make / Redo clips; the studio's cost estimate counts the clips. - Version, star asks, the feed's first day (0.1.35) (
NanoMuseUpdateCheck.swift,NanoMuseNudges.swift): the Version row shows the installed build and the latest release (nanomuse.cn/dl/index.jsonfirst, GitHub second, a day's cache — Latest 0.1.x — you have it / 0.1.x is out / Could not check — tap to try again). The star asks follow the relay's policy (/v1/nudges,nudgesin/v1/me, the same defaults built in): never in the first conversation, then the 3rd / 10th / 30th task (NanoMuseStarWatch: a person-started session leavingactiveSessionswithout an error), the 7th / 30th day, a goal reached, a new look, sign-in, the allowance spent — seven days apart, four per phone — as a card pinned under the header (the message list is a UICollectionView, so nothing can be placed under the last message); the card's sentence is the one set in the relay's console (star.text,star.text_zhfor a Chinese UI) when there is one, else the app's own line for the moment — the title and the buttons stay the app's. The Feed opens on the intro card, says when the daily routine runs while it is empty, and writes its first day after the first conversation; a Notifications page joined the first run. - The composer (0.1.38, 0.1.40) (
NanoMuseShell.swift→NanoMuseHomeView.body,NanoMuseChatModifiers.swift,NanoMuseComposerField.swift,NanoMuseComposerWatch.swift,NanoMuseComposerCheck.swift, the// nanoMuse:lines inViews/Chat/AIChatView.swift). Four releases reported a chat with no input field — 0.1.36 and 0.1.37 on the maintainer's iPhone after the first conversation's naming, 0.1.39 on the iPad — and the 0.1.37 watchdog, the 0.1.38 native field and the 0.1.38 fail-safe (a second host when the overlay reported nothing) were all written against screenshots of a composer that was, in fact, there. What the maintainer saw in the end: the field shows when the app opens and is gone for good once the keyboard has been dismissed; the 0.1.36 screenshot has the top edge of the composer peeking out above the bottom bar. The shell's bottom bar was asafeAreaInseton theNavigationStack's ancestor, hidden while the keyboard is up; the chat respected that inset at launch and no longer after the bar had left and come back, so the whole chat ran under the bar and the composer — bottom-aligned, laid out, healthy by every measure the watchdog had — sat behind it. Since 0.1.40 the bar is a row under the rooms (aVStack, as on Android), and the composer column (the cards, the tool strip, the input bar) is likewise a row under the message list:NanoMuseComposerHostis a plainVStack, the list is passed a bottom inset of 0, the/and@popup is an overlay of the list's bottom edge and so stands on the column's top edge by layout. Nothing lies over the UIKit list and there is no second host to switch to. The watch stays as the last net (a column that detaches or measures 0 while the chat is on screen is rebuilt through.id(rebuildTick), at most twice per appearance so a mis-measured healthy composer cannot take the keyboard away repeatedly), and it keeps what it sees: Settings → Appearance → Composer check draws a red frame around the column (the probe view in its background) and writes a report — the window and its safe area, the column's frame, every UIKit ancestor of the probe with its frame, hidden flag and alpha, the text fields and collection views in the window, the watch's events — with a Copy row for a bug report. That page is the view hierarchy a device can give without a Mac. The pill's field is SwiftUI's ownTextField(axis: .vertical)with@FocusStateandlineLimit(1...6), not upstream'sUIViewRepresentabletext view. What the representable did and the native field does not: an image pasted straight into the field (the plus menu has Paste image when the pasteboard holds one), the select-and-replace capture for the correction learner, the exact caret for the@menu (now the end of the text), swipe-to-send on texts estimated longer than six lines, and hardware arrow / Tab navigation of the popups on iOS 16 (iOS 17+ has them viaonKeyPress). Return on a hardware keyboard sends, Shift+Return breaks the line, the on-screen Return follows Settings → Appearance → Return sends; dictation still ends in Back to typing. - The launch screen (0.1.38) (
NanoMuse/NanoMuseLaunch.storyboard,NanoMuse/NanoMuseLaunch.xcassets): the mark on the system background, no words, light and dark. Upstream's storyboard stays in the tree;scripts/rebrand.pypointsINFOPLIST_KEY_UILaunchStoryboardNameat ours. iOS caches launch screens per install: after an update the old one can show a few more times, a reinstall shows the new one at once. - Side chats and presence (0.1.38, Contract C9) (
NanoMuseSync.swift,NanoMusePresence.swift,NanoMuseSyncSettings.swift,NanoMuseFromDeviceCaption.swift): Data controls → Also sync side chats is per device and off by default — only the main conversation goes up and the pull asksscope=main; side rows that still arrive are ignored. Turning it on pulls once fromsince=0&scope=all&tail=300(idempotent bymid) and the side chats go up with the next push; turning it off narrows the traffic again and deletes nothing. A table's first pull issince=0&tail=300: the newest 300 texts, not the whole history. Presence:POST /v1/sync/working {cid, working}after the user line is pushed and after the turn's end is; the hub'sworkingframe and/v1/sync/state'sworkinglist fill a map that expires after ten minutes, and the chat shows 〈device〉 is working… under the last message when it is another device's user line (ChatMessage.nmWorkingDevice). A remote user line as the tail is never this phone's interrupted turn:recheckCanResumeFromHistoryleavescanResumeoff for it, so no banner, no Resume, nothing re-sent. - Sign-in and the relay (0.1.38) (
NanoMuseCloud.swift,NanoMuseCloudView.swift,NanoMuseRelayPicker.swift): a phone number with a country code other than +86 is told Text-message codes reach mainland-China numbers only. Use an e-mail address instead. before the code is requested, and the relay'sphone_regionerror reads the same. Use a different server on the sign-in page opens a sheet: the address, Check (GET /healthz), Use this server; https is required unless the host is on one's own network (10.,192.168.,172.16–31.,localhost,*.local,*.ts.net). Signed in, the account page shows Server: 〈host〉 with Change, which signs this phone out first — a key belongs to the relay that issued it. - One account's conversations, not the last person's (0.1.39, Contract C10)
(
NanoMuseSync.swift,NanoMuseShell.swift): a conversation belongs to the account that first pushed or pulled it — the sync table that maps it is that account's, and every account that has synced on the phone keeps its table (nanomuse-sync-accounts.json, keyed by the relay's opaqueaccount.id, never the phone number or e-mail). Signed in, the chat lists and the Chat tab show the account's own conversations and the ones no account has synced yet; another account's stay on the phone, hidden, and are never pushed under the signed-in account — an unowned one becomes the account's with its first push. Signing in as a different account than last time starts that account's cursor over (thetail=300pull) and forgets presence; signing back in as the first account brings its conversations and its main chat back. Signed out, everything on the phone shows and nothing moves. The agent's name and look already follow the account; the local memory files do not yet. - Nothing of one account for the next (0.1.40, Contract C12) (
NanoMuseAccountData.swift,NanoMuseSignOutSheet.swift): every chat has an owner row (nanomuse-owners.json), synced or not, and the lists, the Chat tab, the Library, Today's chats, the Siri shortcuts and the push show the signed-in owner's only; signed out, only the chats made while signed out. A sign-out — here, everywhere, Use a different server — is a sheet with one switch, Keep this account's chats on this device, off by default: off, the account's chats, memory, feed, goals, routines and face leave the phone (its sync table too, so a later sign-in never tombstones them on its other devices); on, they are put aside underMinisConfig/nanomuse/accounts/<hash>/and come back with the account. Signing in as another account goes through the same path. Delete the account removes all of it with no question. A key the relay refuses on a refresh (401 bad_key— revoked from another device, a relay reset) takes the keep path instead: the data goes aside, the key goes, and the sign-in form says Your sign-in on this phone was ended — sign in again to continue; your chats are kept on this device until then until the next sign-in (NanoMuseCloud.signInEnded); only401 account_deleteddeletes (NanoMuseAccountData.keepOnRefusedKey, tested inNanoMuseAccountsTests). The relay's key is saved on this device only (kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly, never iCloud Keychain); a fresh install with no marker, no provider and no chat sweeps the device-only Keychain items a previous install left. The full table is sync.md. - The audit's small things (round 9) (
NanoMuseCloudView.swift,NanoMuseFirstRun.swift,NanoMuseAvatarFlow.swift,NanoMuseDevicesSection.swift,NanoMuseShell.swift): the sign-in sheet opens with the app's mark above the field, the field says Mainland China phone number or e-mail, and a number with another country code gets Text-message codes reach mainland-China numbers only… under the field while it is typed, not after a tap; the footer carries Android's fine print (what the relay keeps — an account id, a masked identifier, usage counts, the agent's name and look) and links the privacy policy, not the GitHub page. The Notifications page of the first run shows the mark, as the welcome and sign-in do. After a new face is picked with a video model set, the reply says the four clips follow in the background (Android's sentence). In the account's Devices list an offline device shows when it was last seen and a tap on an online one opens the main chat with@〈name〉in the composer (the Reach sheet's Ask this device).NanoMuseRootdraws nothing until it knows whether the setup is due, so a fresh install never shows the shell for a frame before the welcome page. The first conversation's fourth line no longer says the messages go only to the model: signed in, the main conversation also follows the person to their other devices, and Data controls switches that off — the same words on Android, in every language.
Requirements are upstream's, in android/BUILDING.md: a recent Xcode
(the project is on the iOS 26 SDK), Homebrew ninja meson llvm lld libarchive pkg-config, Go 1.25
for rclone.
git clone --recurse-submodules https://github.com/nano-muse/nanoMuse.git && cd nanoMuse/android
./deps/build_lame.sh && ./deps/build_ffmpeg.sh # FFmpeg links against LAME: this order
./deps/build_ish.sh && ./deps/prepare_alpine_rootfs.sh # the sandbox kernel and its rootfs
./deps/build_rclone_ios.sh # Rclone.xcframework
cp src/ios/Configs/ProviderCustomization.xcconfig.example src/ios/Configs/ProviderCustomization.xcconfig
open src/ios/Minis.xcodeprojPick the Minis scheme, set your team under Signing & Capabilities (the project ships with an
empty DEVELOPMENT_TEAM), build for a device: the native libraries are device-only, so the
simulator does not link — see the troubleshooting section of BUILDING.md.
One rule of the chat screen, learnt from build 9 of 0.1.38: nothing new goes on the end of
AIChatView.body's modifier chain. The body is one expression of some sixty chained
modifiers; its getter keeps copies of the growing value on the stack, and the four links 0.1.38
added were enough to overflow the main thread's 1 MB on an iPad the moment the chat appeared
after onboarding — a crash on every launch, in AIChatView.body.getter →
__swift_instantiateConcreteTypeFromMangledNameV2. Chat-wide behaviour of ours lives in
NanoMuse/NanoMuseChatModifiers.swift (NanoMuseChatHooks, NanoMuseComposerHost): add to
those, or add a third modifier — one link. The composer stack is an AnyView there on purpose.
MinisTests/NanoMuseRound6Tests.swift measures the body's value size and type depth and fails
when either grows past its ceiling.
Distribution is TestFlight, internal testers: the people you add as users of your App Store Connect team (up to 100), who get every build minutes after it is processed, with no App Review. An external group (public link, up to 10,000 testers) needs Apple's beta review once per version; the group nanoMuse Beta and its public link exist, and each build is added to it and submitted for review after the internal testers have had it. The pipeline is the same either way.
Set up on 2026-10-03, all of it under the account holder's developer account (team
TN43QYW8K4), nothing of which is in the repository:
- the app record nanoMuse, iOS, bundle id
io.github.nanomuse.app, SKUnanomuse-ios, Apple ID6818802049; the three extension bundle ids…app.ShareExtension,…app.FileProvider,…app.AgentWidget; the app groupgroup.io.github.nanomuse.appand the iCloud containeriCloud.io.github.nanomuse.app; the capabilities the four.entitlementsask for (App Groups, HealthKit with clinical records, HomeKit, iCloud/CloudKit, NFC tag reading, WeatherKit) turned on on the identifiers; - the API key nanoMuse CI (role App Manager), the Apple Distribution certificate Apple Distribution: Guangyi Liu (valid to 2027-10-03) and the four App Store profiles nanoMuse App Store, nanoMuse ShareExtension App Store, nanoMuse FileProvider App Store, nanoMuse AgentWidget App Store;
- the six repository secrets of the table below;
- the internal TestFlight group nanoMuse Core with automatic distribution, so every processed build reaches its testers by itself; the device list is empty on purpose (nothing here needs one);
- the first build: 0.1.31 (2), archived and uploaded by the workflow on 2026-10-03, processed
by App Store Connect (
VALID, export compliance answered by the Info.plist key) and in beta testing with the internal group — the first thing that can be installed from TestFlight; - the test information an external group needs, in English and Simplified Chinese: the beta app
description, the feedback address, the marketing and privacy-policy links, and What to Test
on each build (none of it names other products); the external group nanoMuse Beta with its
public link,
https://testflight.apple.com/join/ZHexbDqc; the beta-review contact and a review account on the relay in Beta App Review Information; - build 13 (0.1.40) is with the internal testers and was added to nanoMuse Beta and submitted to Apple's beta review on 2026-10-06; the public link delivers a build only once a review has passed, so until then it shows the TestFlight page without an app. Each later version repeats the step (the review is per version). Nothing towards the App Store: the app is not going there.
- The app record. Apps → + → New App: platform iOS, name nanoMuse, bundle id
io.github.nanomuse.app, a SKU. Automatic signing registers identifiers, but the app record itself is created once, by hand. If the bundle id is not offered in the list, register it first under Certificates, Identifiers & Profiles → Identifiers. - Capabilities on the identifier. The entitlements ask for App Groups, iCloud (CloudKit),
HealthKit (with clinical records), HomeKit, NFC tag reading and WeatherKit. Automatic signing
turns most of these on by itself; if the first archive fails on a capability, enable it on the
identifier by hand, and create the iCloud container
iCloud.io.github.nanomuse.appand the app groupgroup.io.github.nanomuse.appthere. WeatherKit also has to be enabled on the Services tab of the identifier. - An API key. Users and Access → Integrations → App Store Connect API → Team Keys → +,
role App Manager (Developer is not enough to upload). Download the
.p8once; note the Key ID and the Issuer ID shown above the table. - Testers. TestFlight → Internal Testing → +: a group, and the team members in it.
| Secret | What |
|---|---|
APP_STORE_CONNECT_KEY_ID |
The key's ID, 10 characters |
APP_STORE_CONNECT_ISSUER_ID |
The issuer ID, a UUID |
APP_STORE_CONNECT_KEY_P8 |
The full text of AuthKey_<ID>.p8, -----BEGIN PRIVATE KEY----- to the end |
APPLE_TEAM_ID |
The 10-character team id (Membership details in the developer account) |
IOS_DIST_P12_BASE64 |
The team's Apple Distribution certificate with its private key, a .p12, base64 in one line |
IOS_DIST_P12_PASSWORD |
That .p12's password |
Xcode's automatic signing (-allowProvisioningUpdates with the key) was the first plan and does
not work for a team like this one: an archive is signed with an iOS App Development profile
before the export re-signs it for the store, and a development profile has to list at least one
device — a team that only ships through TestFlight has registered none, so the archive stops at
Your team has no devices from which to generate a provisioning profile. The lane therefore
signs manually with the store's own material, which needs no devices: the Apple Distribution
certificate from the two secrets above, and the four App Store provisioning profiles (the app
and its three extensions) that get_provisioning_profile downloads from the account with the key
at the start of every run — and repairs there if the certificate they name is not the one in the
keychain.
The certificate was made without a Mac, and can be made again the same way when it expires or the key is lost (a team may hold two or three distribution certificates; revoke the old one in Certificates, Identifiers & Profiles → Certificates first if the limit is reached):
umask 077 && cd ~/.private/apple/dist # anywhere outside the repository
openssl genrsa -out dist.key 2048
openssl req -new -key dist.key -out dist.csr -subj "/emailAddress=<account e-mail>/CN=nanoMuse CI distribution/C=CN"
# POST /v1/certificates { certificateType: DISTRIBUTION, csrContent: <dist.csr> } with a JWT signed
# by the API key (the key's role must be App Manager or Admin); save certificateContent, base64, as dist.cer
openssl x509 -inform DER -in dist.cer -out dist.pem
openssl rand -base64 24 | tr -d '\n' > dist.p12.pass
openssl pkcs12 -export -inkey dist.key -in dist.pem -out dist.p12 -passout file:dist.p12.pass # with OpenSSL 3 add -legacy
base64 -w0 dist.p12 | gh secret set IOS_DIST_P12_BASE64 -R nano-muse/nanoMuse
gh secret set IOS_DIST_P12_PASSWORD -R nano-muse/nanoMuse < dist.p12.passThe private key stays in that folder on the maintainer's machine (and in the secret); nothing
of it goes into the repository, a log or a chat. The profiles were created once in the account
with the same API (POST /v1/profiles, type IOS_APP_STORE, one per bundle id, each naming the
certificate); the lane makes them again if they are missing.
Actions → iOS · build check → Run workflow (.github/workflows/ios-check.yml) builds the app
for a device on a Mac runner with signing turned off — no Apple account, no secrets. It shares
the native-dependency cache with the TestFlight workflow, so run it first: a compile error costs
minutes there, not an upload. The full xcodebuild log is attached to the run.
Actions → iOS · TestFlight → Run workflow, or push a tag ios-<anything>. The job builds the
native dependencies (cached on the scripts and the iSH revision; the first run takes about an
hour, later ones a few minutes plus the archive), archives with fastlane beta
(android/src/ios/fastlane/Fastfile), uploads, and attaches the .ipa and the dSYMs to the run.
The build number is the workflow run number, so every upload is newer than the last; the version
is MARKETING_VERSION from the project. ITSAppUsesNonExemptEncryption is already false in the
Info.plist, so builds do not wait for the export-compliance question.
The runner is macos-26; if the label is not available on your GitHub plan, macos-15 with
xcode-version: latest-stable is the fallback, at the cost of the iOS 26 SDK the project asks for.
The Android app is where nanoMuse's shape lives; since 0.1.34 the iPhone carries the same shape over OpenMinis — the agent's page, the chat-driven avatar, goals, feed, routines, the first run — with iOS's limits on background work spelled out in the copy. Where the Android code is, and what became of it here:
Android (io.github.nanomuse.*) |
On iOS |
|---|---|
cloud — relay client, sign-in, account |
Done: NanoMuse/NanoMuseCloud*.swift, NanoMuseAccount*.swift (0.1.32: password, invite code, the allowance in yuan, usage, sessions, timeline, delete) |
ui.onboarding — the first run with Sign in — free |
Done (0.1.34): NanoMuseFirstRun.swift, the first conversation included; 0.1.35 the Notifications page; no Hands page on iOS |
community.StarPrompt, community.Nudges — the star asks from the relay's policy |
Done (0.1.35): NanoMuseNudges.swift, NanoMuseStar — the moments, the cooldown and the cap from /v1/nudges |
community.UpdateCheck, the Version row |
Done (0.1.35): NanoMuseUpdateCheck.swift — installed and latest, nanomuse.cn/dl/index.json then GitHub |
avatar.VideoGen, avatar.AvatarMotion — the motion clips |
Done (0.1.35): NanoMuseVideoGen.swift, NanoMuseAvatarMotion.swift, NanoMuseMediaModels.swift |
nm.show_steps — the agent's steps off by default |
0.1.32: NanoMuseSteps.swift; finished messages keep to the conversation, a running one shows its steps |
connectors — the catalogue, SharedConnectors |
Done: NanoMuseConnectors.swift (0.1.33 the catalogue, 0.1.34 the other devices' entries) |
ui.home, ui.chat, ui.settings, ui.profile — the shell, header, agent page |
Done: NanoMuseShell.swift, NanoMuseHeader.swift, NanoMuseAgentPage.swift, NanoMuseSettings.swift; 0.1.35 the Muse header on every room, the settings as Muse cards (NanoMuseChrome.swift, NanoMuseAppearance.swift) |
avatar — the drawn face, the chat-driven change, the studio |
Done (0.1.34): NanoMuseAvatarFlow.swift, NanoMuseAvatarStudio.swift, NanoMuseImageGen.swift |
goals, feed, the scheduler |
Done (0.1.34) as far as iOS allows: foreground catch-up, BGAppRefreshTask, local notifications. No alarm-exact runs while the app is asleep — the copy says so |
coding — the computers' coding agents over the hub |
Done (0.1.34): NanoMuseCoding.swift; the computer must run nanoMuse signed in with the same account |
reach — the phone drives the computer |
Done: NanoMuseReach.swift over the hub |
hands — the phone's own screen |
No equivalent: iOS does not let an app drive another. App Intents / Shortcuts are the door there |
| Widgets | Upstream's AgentWidget as it is |
Still to check on a device, in order: the first run end to end with a fresh account; a chat-driven avatar change through the relay and through a Bailian key, and the four clips it draws afterwards; a routine coming due with the app in the background (does iOS grant the refresh on the tester's phone, and how often); a coding session against a computer of the account; the connectors list after a second device signs in.
Not in the plan: App Review. TestFlight internal is the distribution until the shell is ported and the store listing can be honest about what the iPhone app is.