Conversation
Adds a viewport-branched mobile UI (< 720 px) without touching the
desktop layout. Desktop 1024 px renders identical output all
mobile work lives behind the new DesktopShell / MobileShell split in
App.vue and a data-platform attribute on <html>.
New:
- src/stores/platform.ts breakpoint + iOS/Android detection
- src/components/shell/{DesktopShell,MobileShell}.vue
- src/components/mobile/{MobileTabBar,MobileAppBar,
MobileIconButton,FolderDrawer,ComposeSheet}.vue
- src/views/MobileThreadView.vue /mail/thread/:id detail
- src/views/OnboardingView.vue zero-account first-run
Completes the Tauri mobile scaffold from the previous commit. The app now
builds and runs on an Android emulator and the iOS simulator, showing the
onboarding screen.
- Split src-tauri into lib.rs + main.rs: `run()` is the mobile_entry_point,
main.rs delegates. Declared `chithi_lib` [lib] with staticlib/cdylib/rlib
crate-types so `cargo build --lib --target *-android/*-apple-ios*` works.
- Move data-dir resolution into the Tauri .setup() closure and use
`app.path().app_data_dir()` on mobile. The old `dirs::data_local_dir()`
path is not writable inside the Android app sandbox and was panicking at
boot with EACCES.
- Add `openssl = { vendored }` for `cfg(target_os = "android")` so the IMAP
/ reqwest / lettre TLS stack can cross-compile without a system OpenSSL.
(iOS uses Security.framework via native-tls, so no change needed there.)
- vite binds to 0.0.0.0 when TAURI_DEV_HOST is unset so that
`adb reverse tcp:1420 tcp:1420` can reach it from the emulator. IPv6-only
localhost binding was silently breaking the mobile dev loop.
- Initialized src-tauri/gen/{android,apple} via `pnpm tauri {android,ios}
init`.
- Pin @rolldown/binding-darwin-arm64 so pnpm installs the native binding
rolldown needs on macOS arm64 (the optional dep was being skipped).
…s screens
Implements PATCHES-MOBILE sections 6-15 on top of the mobile chrome scaffold:
- MessageListItem gets a `mode="mobile"` branch with 2-line comfortable
rows, per-account corner dots, and swipe-to-archive/delete gestures;
MessageList forwards the mode and wires the archive/delete handlers.
- New MobileThreadView -- app bar with back/archive/trash/overflow,
per-account chip in the subject block, reused MessageReader body,
and a sticky Reply / Reply-all / Forward action dock.
- ContactsView mobile branch: large-title bar, account filter chips,
sticky letter headers, per-account corner dots, edge index rail.
- CalendarView mobile branch: Day / Week / Month views with
calendar-colored events, now-indicator, month cells and today list.
- SettingsView mobile branch: large-title bar, sectioned cards
(Accounts / General / Privacy / About), dashed "Add account"
button, and the edit-account modal restyled as a bottom sheet.
- uiStore.openCompose now takes { replyTo, kind } so the thread view
can launch Reply / Reply-all / Forward with the right seed state.
- Status-bar theming on both platforms (iOS Info.plist
UIStatusBarStyle; Android themes.xml + colors.xml).
Fixes surfaced while verifying on device:
- SettingsView.vue was missing its `</style>` closing tag at EOF, so
the SFC parser reported "Element is missing end tag" at the <style>
line (not a parser bug, just an unclosed block).
- The Add/Edit and Delete Teleports lived inside the `v-else` desktop
branch, so they never mounted on mobile -- pulled up to the template
root so both chromes share them.
- The router guard redirected `/settings?addAccount=<provider>` back
to `/onboarding` on first run (zero accounts), making the provider
tap look like a no-op. Whitelisted the hand-off query and taught
SettingsView to auto-open the new-account form with the matching
provider preselected.
…works on iOS tauri-plugin-shell::open spawns a subprocess via the ::open crate, which on iOS has no /usr/bin/open to spawn and fails with "No such file or directory (os error 2)". The shell plugin is also explicitly deprecated since 2.1.0 in favour of tauri-plugin-opener, which bridges to UIApplication.open(_:) on iOS and Intent.ACTION_VIEW on Android. Swap: - src-tauri/Cargo.toml, src-tauri/src/lib.rs: tauri-plugin-shell -> tauri-plugin-opener - src-tauri/capabilities/default.json: replace "shell:allow-open" with "opener:allow-open-url" + "opener:allow-default-urls" (the opener plugin denies by default; the default-urls preset scopes it to http/https/mailto/tel which is what the OAuth flows need) - package.json: @tauri-apps/plugin-shell -> @tauri-apps/plugin-opener - src/views/SettingsView.vue: import openUrl from plugin-opener, swap the three OAuth entry points (Google, Microsoft, JMAP OIDC) to call it. Renamed a clashing local openUrl variable to verificationUrl. Also quiet an unrelated noise surfaced by verifying the flow: db::accounts was warn!-logging on every startup for OAuth-only accounts because it unconditionally tried to read an IMAP/SMTP password from the keyring under the non-.oauth service and those entries never exist for JMAP+OIDC or Microsoft 365 accounts. keyring::get_password now returns Result<Option<String>> so the caller can distinguish "no entry" (debug) from a real keyring failure (warn)
CI's rustfmt breaks the retrieval-error format! across multiple lines; ours didn't. Match CI so the fmt job passes.
There was a problem hiding this comment.
Pull request overview
This PR introduces a mobile-first UI shell and onboarding flow, and adjusts the Tauri backend/project setup to support mobile (iOS/Android) builds and platform detection.
Changes:
- Add mobile UI chrome (tab bar, app bar, drawer, compose sheet scaffold) and a pushed mobile thread reader route.
- Add onboarding route + router guard redirecting first-run users without accounts.
- Refactor Tauri entrypoint into a library (
chithi_lib::run) and add mobile-support plugins/capabilities (os/opener) plus generated iOS/Android project files.
Reviewed changes
Copilot reviewed 70 out of 112 changed files in this pull request and generated 8 comments.
Show a summary per file
| File | Description |
|---|---|
| vite.config.ts | Change dev server host binding (mobile testing related). |
| src/views/OnboardingView.vue | New onboarding provider picker UI. |
| src/views/MailView.vue | Add mobile mail layout + app bar + FAB compose. |
| src/stores/ui.ts | Add mobile UI state (compose sheet, drawer, compose context). |
| src/stores/platform.ts | New platform/breakpoint store for mobile vs desktop shells. |
| src/router/index.ts | Add onboarding + mobile thread route + first-run guard. |
| src/main.ts | Initialize platform store. |
| src/components/shell/MobileShell.vue | New mobile shell wrapper + tab bar/drawer/sheet composition. |
| src/components/shell/DesktopShell.vue | New desktop shell wrapper extracted from App.vue. |
| src/components/mobile/* | New mobile components (tab bar, app bar, icon button, drawer, compose sheet). |
| src/components/mail/MessageList.vue | Add mobile mode + swipe archive/delete wiring. |
| src/assets/styles/main.css | Add mobile CSS tokens and mobile overrides. |
| src/App.vue | Switch to platform-driven DesktopShell/MobileShell. |
| src-tauri/src/main.rs | Delegate to library entrypoint for mobile compatibility. |
| src-tauri/src/lib.rs | New unified Tauri run() with mobile plugins + mobile-safe data dir. |
| src-tauri/src/keyring.rs | Make missing keyring entry non-fatal via Option return. |
| src-tauri/src/db/accounts.rs | Update keyring password retrieval handling. |
| src-tauri/capabilities/default.json | Update permissions for opener + os platform detection. |
| src-tauri/Cargo.toml / Cargo.lock | Add lib crate type + new plugins + android vendored OpenSSL. |
| src-tauri/gen/* | Add generated iOS/Android project scaffolding and schemas/capabilities updates. |
| pnpm-lock.yaml / package.json | Add new Tauri plugins and additional dev dependency. |
Files not reviewed (2)
- pnpm-lock.yaml: Language not supported
- src-tauri/gen/apple/chithi.xcodeproj/project.xcworkspace/contents.xcworkspacedata: Language not supported
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| if (hideTabBarRoutes.has(String(route.name))) return false; | ||
| // The reader detail route uses `/mail/thread/:id` (name: "reader") | ||
| if (route.path.startsWith("/mail/thread/")) return false; | ||
| return true; |
There was a problem hiding this comment.
The inline comment says the thread detail route name is "reader", but the router registers it as name: "mobile-reader". Update the comment to match the actual route name to avoid confusion during future routing changes.
| const archive = foldersStore.folders.find( | ||
| (f) => f.path.toLowerCase() === "archive" || f.name.toLowerCase() === "archive", | ||
| ); |
There was a problem hiding this comment.
Mobile swipe-archive looks for an Archive folder by matching path/name to the literal string "archive". The backend already normalizes special folders via folder_type (e.g., folder_type === "archive"), which is more reliable across providers/locales and avoids missing archive folders like "All Mail"/"Archiv". Consider using folder_type (and traversing children if needed) for this lookup.
| // Redirect to onboarding if no accounts are configured. The guard only | ||
| // runs on the main window — standalone compose/reader windows have | ||
| // their own param-driven initialization and should pass through. | ||
| router.beforeEach(async (to) => { | ||
| if (to.name === "onboarding") return true; | ||
| if (to.name === "compose" || to.name === "reader") return true; |
There was a problem hiding this comment.
PR metadata discrepancy: the title says "Tested against iOS and Android", but the description says Android login hasn’t been tried yet. Please either update the title/description to be consistent or add a brief note of what was actually validated on Android (e.g., build runs, navigation, login, sync).
| port: 1420, | ||
| strictPort: true, | ||
| host: host || false, | ||
| host: host || "0.0.0.0", |
There was a problem hiding this comment.
server.host now defaults to "0.0.0.0" when TAURI_DEV_HOST is unset, which binds Vite to all interfaces and can unintentionally expose the dev server on the local network. Consider keeping the previous default (false/localhost) and only using 0.0.0.0 when explicitly requested (env var/CLI flag) for mobile testing.
| const mod = await import("@tauri-apps/plugin-os"); | ||
| const p = mod.platform(); | ||
| if (p === "ios") kind.value = "ios"; | ||
| else if (p === "android") kind.value = "android"; | ||
| else kind.value = "desktop"; |
There was a problem hiding this comment.
@tauri-apps/plugin-os APIs (including platform()) are async; const p = mod.platform(); will be a Promise, so the comparisons will never match and kind will fall back to desktop. Await the call (or import the named platform function and await platform()), then compare the resolved string.
| }, | ||
| "packageManager": "pnpm@10.27.0", | ||
| "devDependencies": { | ||
| "@rolldown/binding-darwin-arm64": "1.0.0-rc.13", |
There was a problem hiding this comment.
Adding @rolldown/binding-darwin-arm64 as a normal devDependency is platform-specific and will likely break installs/CI on non-macOS-arm64 environments. If a binding is needed, it should be an optional dependency handled by the bundler, or gated via optionalDependencies/postinstall rather than being required for every platform.
| <div class="folder-drawer" :class="{ open: drawerOpen }" aria-hidden="false"> | ||
| <div class="scrim" @click="onScrimClick" /> | ||
| <aside class="pane" role="dialog" aria-label="Folders"> | ||
| <header class="brand"> |
There was a problem hiding this comment.
The drawer container is always marked aria-hidden="false", even when closed. This makes the hidden dialog content reachable to assistive tech. Bind aria-hidden to !drawerOpen (or use v-if/inert) and consider adding aria-modal="true" on the dialog element when open.
| <nav class="mobile-tab-bar" role="tablist" aria-label="Primary"> | ||
| <button | ||
| v-for="t in tabs" | ||
| :key="t.id" | ||
| class="mobile-tab" | ||
| :class="{ active: active === t.id }" | ||
| role="tab" | ||
| :aria-selected="active === t.id" | ||
| :aria-label="t.label" | ||
| @click="onTabClick(t)" |
There was a problem hiding this comment.
MobileTabBar uses role="tablist"/role="tab", but these roles imply a tabpanel relationship and specific keyboard interactions that aren’t implemented here (this is primary navigation). Consider using semantic navigation (e.g., <nav> with buttons/links) and aria-current="page" for the active item instead of tab roles.
Three issues were stacking up: on Android the token poll would bail the moment the system backgrounded the app for Chrome; on both mobile targets the tokens that *did* arrive were silently dropped before sync; and on the iOS simulator the verification URL never opened at all. - oauth.rs: the device-code poll now treats any reqwest `.send()` error as transient and keeps retrying until the overall device-code deadline. `.send()` cannot return HTTP status errors (those flow through `resp.status()` below), so the only thing it can hand us are network/body errors -- and on mobile those are virtually always the OS tearing down an in-flight socket while the app is backgrounded. Logs the error kind flags at INFO so future non-transient cases are identifiable. - commands/oauth.rs + MainActivity.kt + app/build.gradle.kts: new `open_oauth_url` command. On Android it JNIs through `webview.with_webview(...).jni_handle().exec(...)` into a Kotlin `openCustomTab` on MainActivity that fires an `androidx.browser.customtabs.CustomTabsIntent`. The tab overlays the task instead of replacing it with Chrome, so the app stays foreground and the poll loop never gets suspended. Requires `androidx.browser:browser:1.8.0` and the `jni` crate under the Android target. - oauth.rs: Android file-backed fallback for `store_tokens` / `load_tokens` / `delete_tokens` under `app_data_dir/oauth_tokens/`. keyring-rs v3 has no Android backend and silently falls back to its non-persistent mock -- every `Entry::new` gets a fresh in-memory slot, so the temp->real account-id migration in `commands::accounts::add_account` was erasing the tokens it had just been handed. The Android app sandbox scopes that directory to the app UID; we can swap in EncryptedSharedPreferences later without changing callers. Wired via `oauth::init_token_store(&data_dir)` from the setup hook in lib.rs. - SettingsView.vue: dispatch the verification URL through `api.openOauthUrl` only on Android (Custom Tabs path); iOS and desktop stay on the JS `@tauri-apps/plugin-opener` `openUrl`, which already goes through UIApplication / OS defaults correctly. The Rust free-function equivalent shells out to `uiopen` on iOS, which does not exist on the simulator -- that's what was making the iOS retry silently drop every poll.
Yet to try login in Android. But, from now on we must update any UI for mobile too.