Comment / issue / chat images uploaded inside the Desktop app rendered
as the broken-image fallback. The editor was persisting a site-relative
`/api/attachments/<id>/download` URL into markdown — that path only
resolves when the document origin proxies /api to the API host (apps/web
via Next.js rewrite). On Electron's file:// origin it never resolved.
Per GPT-Boy's plan, move the durable-URL choice from the client to the
server so the persisted shape is correct regardless of which client
performed the upload.
Server:
- AttachmentResponse gains a markdown_url field, computed by
buildMarkdownURL from the deployment policy:
• storage URL is already absolute + unsigned (public CDN, S3 public
bucket, LocalStorage with MULTICA_LOCAL_UPLOAD_BASE_URL on https) →
use it verbatim;
• CloudFront-signed mode → never expose the raw S3 URL (private
bucket); return cfg.PublicURL + /api/attachments/<id>/download so
the server can re-sign on every request;
• LocalStorage relative + cfg.PublicURL set → same prefixed API
endpoint;
• cfg.PublicURL unset → fall back to site-relative path so web's
Next.js rewrite still works.
- isDurablePublicURL helper rejects URLs carrying CloudFront / S3
signature query params, so a freshly-signed download_url can never
leak into persistence — the original MUL-3130 bug stays closed.
Frontend:
- Attachment type + AttachmentResponseSchema (and apps/mobile mirror)
carry markdown_url. Schema lenient-defaults to '' so a backend old
enough to predate this field doesn't break clients.
- useFileUpload picks markdownLink with three-layer fallback:
(1) att.markdown_url (modern server),
(2) attachmentDownloadPath(att.id) — legacy site-relative shape,
retained for backends old enough to omit markdown_url,
(3) att.url — no-workspace avatar branch with no attachment-row id.
- attachment.tsx keeps the relative→absolute absolutize pass, but
reframed as the legacy-compat fallback for already-persisted
/api/attachments/<id>/download or /uploads/<key> URLs in old
bodies. New content writes absolute URLs and skips this path.
- ContentEditor still tracks freshly-uploaded records into
AttachmentDownloadProvider so Quick Create's editor can swap the URL
via the resolver during the same session even before the server-side
binding lands.
Tests:
- server/internal/handler/file_test.go: 5 new buildMarkdownURL matrix
tests (public CDN passthrough, CloudFront-signed swap, relative
prefixing, PublicURL unset fallback, trailing-slash strip) + 15
table-driven isDurablePublicURL cases.
- packages/core/hooks/use-file-upload.test.ts: new file, 4 cases
covering modern server / legacy server / no-id avatar / oversize.
- packages/views/editor/attachment.test.tsx + content-editor.test.tsx:
10 cases for the absolutize matrix and in-session attachment merge.
- 6 existing test fixtures updated to include markdown_url.
Verification: 1236 @multica/views tests pass; 514 @multica/core tests
pass (4 new); server handler package tests pass for the new matrix
plus all pre-existing TestAttachmentToResponse* and TestDownload*
cases. Typecheck green for views/core/web/desktop. Lint clean on
touched files.
Quick Create attachment_ids binding (orphaned attachment relationship
on the resulting issue) is a follow-up — it requires a new --attachment-id
CLI flag and daemon prompt-template work and is intentionally scoped
out of this PR.
Refs: MUL-3192
Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
Multica Mobile (iOS)
Expo + React Native iOS client for Multica. Independent from web/desktop — shares only types from @multica/core/. See CLAUDE.md for the locked tech-stack baseline and import rules.
Just want to use it on your phone? (no development)
Multica isn't on the App Store yet — until that changes, anyone who wants it on their iPhone builds from source. One command:
pnpm ios:mobile:device:prod:release
This connects to the same backend as multica.ai, so your existing account just works.
Prerequisites: Mac with Xcode, a free Apple ID added under Xcode → Settings → Accounts, iPhone connected via USB with Developer Mode enabled. Walk through Expo's Set up your environment (pick Development build → iOS Device) if any of that is missing.
Xcode signs the build with the "Personal Team" your Apple ID automatically owns — created silently the first time you signed into Xcode, no setup needed. The first build downloads CocoaPods + compiles React Native from source — expect 10–20 minutes. Subsequent builds reuse Xcode's cache.
If Xcode rejects signing with "No matching provisioning profiles found" — rare, happens if someone has claimed the default bundle id ai.multica.mobile on Apple's developer portal. Pick any reverse-domain you own and re-run:
export EXPO_BUNDLE_IDENTIFIER_PROD=com.yourname.multica
pnpm ios:mobile:device:prod:release
7-day signing limit: a free Apple ID signs builds for 7 days. After that, plug back into the Mac and re-run the command to re-sign. An Apple Developer Program account ($99/yr) extends this to 1 year.
Everything below is for app developers — you can ignore the rest if you only wanted a personal install.
Scripts
| Command | What it does | Backend |
|---|---|---|
pnpm dev:mobile |
Metro only (reuse existing install) | local (.env.development.local) |
pnpm dev:mobile:staging |
Metro only (reuse existing install) | staging (.env.staging) |
pnpm dev:mobile:prod |
Metro only (reuse existing install) | production (.env.production) |
pnpm ios:mobile |
Full rebuild + install on iOS Simulator, Debug | local |
pnpm ios:mobile:staging |
Full rebuild + install on iOS Simulator, Debug | staging |
pnpm ios:mobile:prod |
Full rebuild + install on iOS Simulator, Debug | production |
pnpm ios:mobile:device |
Full rebuild + install on USB iPhone, Debug | local |
pnpm ios:mobile:device:staging |
Full rebuild + install on USB iPhone, Debug | staging |
pnpm ios:mobile:device:staging:release |
Full rebuild + install on USB iPhone, Release (standalone) | staging |
pnpm ios:mobile:device:prod |
Full rebuild + install on USB iPhone, Debug | production |
pnpm ios:mobile:device:prod:release |
Full rebuild + install on USB iPhone, Release (standalone) | production |
dev:* runs Metro only — assumes the matching variant is already installed. ios:mobile* does a full native rebuild + install.
Bundle id and display name switch on APP_ENV (see app.config.ts), so Dev / Staging / Production variants can coexist on the same device or simulator.
First-time setup
.env.staging is committed (public staging URL). .env.development.local is gitignored — copy the template once:
cp apps/mobile/.env.example apps/mobile/.env.development.local
# then edit EXPO_PUBLIC_API_URL inside it to your Mac's LAN IP, e.g. http://192.168.1.42:8080
If your Apple ID isn't on the Multica Apple Developer team yet, also uncomment and set EXPO_BUNDLE_IDENTIFIER_DEV to a reverse-domain you own (e.g. com.yourname.multica.dev). This only overrides the dev variant — staging / production bundle ids are intentionally not overridable so variants can coexist.
Build it onto your iPhone
Two paths, depending on what you want to do:
Day-to-day development (Mac in front of you)
pnpm ios:mobile:device:staging
Produces a Debug build with expo-dev-launcher embedded. Every launch the app probes Metro on your Mac and pulls fresh JS — perfect for hot-reload, painful when the Mac is asleep or you're on a different WiFi.
Standalone / "just use it" (walk away from the Mac)
pnpm ios:mobile:device:staging:release
Produces a Release build. No expo-dev-launcher, no Metro probe, no "Downloading…" screen. Splash → app, exactly like an App Store install. Trade-off: every JS change requires re-running this command.
Both paths share the same prerequisites: Mac with Xcode, free Apple ID added under Xcode → Settings → Accounts, iPhone connected via USB with Developer Mode enabled. Follow Expo's Set up your environment — pick Development build → iOS Device — if any of that is missing.
First build of either variant downloads CocoaPods + compiles React Native from source — expect 10-20 minutes. Subsequent builds reuse Xcode's DerivedData cache.
Try it in the iOS Simulator (no iPhone needed)
pnpm ios:mobile:staging
Boots the simulator, builds, installs the dev-client. Faster to iterate than a device build because no signing / provisioning step. Same dev:mobile:staging Metro flow afterward.
7-day signing limit (device only)
A free Apple ID signs builds for 7 days only, Debug and Release both. After that the app refuses to launch on the iPhone. Plug back into the Mac and re-run the corresponding ios:mobile:device* script to re-sign. Simulator builds are unaffected. The only workaround for the device limit is an Apple Developer Program account ($99/yr), which extends to 1 year.
Pointing at a different backend
Edit EXPO_PUBLIC_API_URL in .env.staging, .env.production, or .env.development.local (whichever variant you're running). Then:
- For an installed Debug build: restart Metro (
pnpm dev:mobile:staging) so the next JS bundle picks up the new value. - For an installed Release build: re-run the
ios:mobile:device:staging:releasecommand — the value is baked into the embedded bundle at build time.
For local backend testing, use your Mac's LAN IP (ipconfig getifaddr en0), not localhost.