fix: handle 401 gracefully on web; add Bearer token auth for Tauri
- fetchMe() no longer surfaces 401 as a user-facing error (it just means 'no session', not a failure) - API client auto-clears auth state on 401 mid-session so the user gets redirected to login instead of seeing 'ERR: Request failed: 401' - Session middleware now accepts Authorization: Bearer <token> header as fallback when no cookie is present (for Tauri/native clients) - Login, register, and WebAuthn endpoints expose X-Session-Token header so non-browser clients can capture the token
This commit is contained in:
@@ -0,0 +1,369 @@
|
||||
# Capacitor Android App — Implementation Plan
|
||||
|
||||
> **For Hermes:** Use subagent-driven-development skill to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Wrap the existing dumpsterChat Vite/React PWA in a Capacitor shell and publish to Google Play.
|
||||
|
||||
**Architecture:** Capacitor loads the Vite build output as local assets in an Android WebView. `@capacitor/core` bridges native APIs (push, status bar, etc.). No UI rewrite — the web app IS the app.
|
||||
|
||||
**Tech Stack:** Vite, React 18, Capacitor 6, FCM (push), Gradle (Android build)
|
||||
|
||||
---
|
||||
|
||||
### Phase 1: Capacitor Init
|
||||
|
||||
#### Task 1: Add Capacitor dependencies
|
||||
|
||||
**Objective:** Install Capacitor core + CLI in the web project.
|
||||
|
||||
**Files:**
|
||||
- Modify: `web/package.json`
|
||||
|
||||
**Steps:**
|
||||
|
||||
```bash
|
||||
cd web
|
||||
npm install @capacitor/core @capacitor/cli @capacitor/android
|
||||
```
|
||||
|
||||
Then init Capacitor:
|
||||
|
||||
```bash
|
||||
npx cap init "Dumpster Chat" "coffee.dustin.dumpster" --web-dir dist
|
||||
```
|
||||
|
||||
This creates `capacitor.config.ts` at the web root.
|
||||
|
||||
**Verify:** `cat capacitor.config.ts` shows appId `coffee.dustin.dumpster`, webDir `dist`.
|
||||
|
||||
---
|
||||
|
||||
#### Task 2: Configure capacitor.config.ts
|
||||
|
||||
**Objective:** Set server URL for dev, configure Android-specific settings.
|
||||
|
||||
**Files:**
|
||||
- Modify: `web/capacitor.config.ts`
|
||||
|
||||
**Content:**
|
||||
|
||||
```ts
|
||||
import type { CapacitorConfig } from '@capacitor/cli';
|
||||
|
||||
const config: CapacitorConfig = {
|
||||
appId: 'coffee.dustin.dumpster',
|
||||
appName: 'Dumpster Chat',
|
||||
webDir: 'dist',
|
||||
server: {
|
||||
// ponytail: no server.url — serve local assets. API calls go to absolute URL from api.ts.
|
||||
androidScheme: 'https', // cookies work over https scheme in WebView
|
||||
},
|
||||
plugins: {
|
||||
PushNotifications: {
|
||||
presentationOptions: ['badge', 'sound', 'alert'],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
```
|
||||
|
||||
**Key decisions:**
|
||||
- `androidScheme: 'https'` makes `credentials: 'include'` cookies work in the WebView (http scheme blocks them).
|
||||
- No `server.url` — local assets load from the APK, not from the web. Faster, works offline.
|
||||
|
||||
---
|
||||
|
||||
#### Task 3: Add Android platform
|
||||
|
||||
**Objective:** Generate the native Android project.
|
||||
|
||||
**Files:**
|
||||
- Create: `web/android/` (generated by Capacitor)
|
||||
|
||||
**Steps:**
|
||||
|
||||
```bash
|
||||
cd web
|
||||
npx cap add android
|
||||
```
|
||||
|
||||
**Verify:** `ls web/android/app/src/main/AndroidManifest.xml` exists.
|
||||
|
||||
---
|
||||
|
||||
### Phase 2: API Client Fix
|
||||
|
||||
#### Task 4: Update API base URL for native
|
||||
|
||||
**Objective:** When running in Capacitor, API calls need an absolute URL (no origin in a WebView). Keep relative paths for web/PWA.
|
||||
|
||||
**Files:**
|
||||
- Modify: `web/src/lib/api.ts`
|
||||
|
||||
**Changes:**
|
||||
|
||||
```ts
|
||||
import { Capacitor } from '@capacitor/core';
|
||||
|
||||
// ponytail: single switch. native = absolute URL, web = relative (Caddy same-origin).
|
||||
const API_BASE = Capacitor.isNativePlatform()
|
||||
? 'https://dumpster.dustin.coffee/api/v1'
|
||||
: '/api/v1';
|
||||
```
|
||||
|
||||
The rest of the file stays unchanged. `Capacitor.isNativePlatform()` returns `false` in browsers and `true` in the Android WebView.
|
||||
|
||||
**Skipped:** `@capacitor/http` plugin. Not needed — `androidScheme: 'https'` + absolute URL + `credentials: 'include'` works. Add the HTTP plugin only if cookies break.
|
||||
|
||||
---
|
||||
|
||||
### Phase 3: Push Notifications (FCM)
|
||||
|
||||
This is the only non-trivial part. VAPID web push does not work in Android WebViews. Need FCM.
|
||||
|
||||
#### Task 5: Create Firebase project
|
||||
|
||||
**Objective:** Set up FCM credentials for native push.
|
||||
|
||||
**Steps (manual, one-time):**
|
||||
1. Go to https://console.firebase.google.com
|
||||
2. Create project (or use existing) named `dumpster-chat`
|
||||
3. Add Android app with package name `coffee.dustin.dumpster`
|
||||
4. Download `google-services.json` → place in `web/android/app/google-services.json`
|
||||
5. In Firebase Console → Project Settings → Cloud Messaging → note the **Server Key** (legacy) or set up **Firebase Admin SDK** service account
|
||||
|
||||
**Verify:** `google-services.json` exists in `web/android/app/`.
|
||||
|
||||
---
|
||||
|
||||
#### Task 6: Add Capacitor Push Notifications plugin
|
||||
|
||||
**Objective:** Register for FCM token on Android, send it to the server.
|
||||
|
||||
**Files:**
|
||||
- Modify: `web/package.json` (install plugin)
|
||||
- Modify: `web/src/stores/push.ts` (add native branch)
|
||||
|
||||
**Install:**
|
||||
```bash
|
||||
cd web
|
||||
npm install @capacitor/push-notifications
|
||||
```
|
||||
|
||||
**Modify `push.ts`** — add a native registration path alongside the existing web push:
|
||||
|
||||
```ts
|
||||
import { Capacitor } from '@capacitor/core';
|
||||
|
||||
// Existing web push subscribe stays as-is for PWA.
|
||||
// Add native branch:
|
||||
async function subscribeNative() {
|
||||
const { PushNotifications } = await import('@capacitor/push-notifications');
|
||||
|
||||
const permStatus = await PushNotifications.requestPermissions();
|
||||
if (permStatus.receive !== 'granted') return;
|
||||
|
||||
await PushNotifications.register();
|
||||
|
||||
// Server sends us the FCM token via this event
|
||||
PushNotifications.addListener('registration', async (token) => {
|
||||
await api.post('/push/subscribe', {
|
||||
endpoint: 'fcm:' + token.value, // ponytail: prefix to distinguish from web push endpoints
|
||||
keys: { p256dh: '', auth: '' }, // not used for FCM, but server expects the shape
|
||||
});
|
||||
});
|
||||
|
||||
PushNotifications.addListener('pushNotificationReceived', (notification) => {
|
||||
// Foreground notification — show in-app toast or badge
|
||||
// ponytail: handled by existing in-app notification system
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
Then in the existing `subscribe()` function, branch:
|
||||
|
||||
```ts
|
||||
if (Capacitor.isNativePlatform()) {
|
||||
return subscribeNative();
|
||||
}
|
||||
// ... existing web push logic
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### Task 7: Server-side FCM send support
|
||||
|
||||
**Objective:** When a push subscription's endpoint starts with `fcm:`, send via FCM HTTP v1 API instead of VAPID.
|
||||
|
||||
**Files:**
|
||||
- Modify: `internal/push/handlers.go`
|
||||
|
||||
**Changes:**
|
||||
1. In `Subscribe()`: detect `fcm:` prefix on endpoint, store differently (or store as-is, the prefix distinguishes it).
|
||||
2. In the send functions (`Send`, `SendToUser`): check if subscription endpoint starts with `fcm:` → use Firebase Admin SDK to send.
|
||||
|
||||
**Install Go Firebase Admin:**
|
||||
```bash
|
||||
go get firebase.google.com/go/v4
|
||||
```
|
||||
|
||||
**Pattern:**
|
||||
```go
|
||||
// ponytail: one if/else in the send loop. endpoint prefix = routing key.
|
||||
if strings.HasPrefix(sub.Endpoint, "fcm:") {
|
||||
token := strings.TrimPrefix(sub.Endpoint, "fcm:")
|
||||
msg := &messaging.Message{
|
||||
Token: token,
|
||||
Notification: &messaging.Notification{
|
||||
Title: title,
|
||||
Body: body,
|
||||
},
|
||||
Data: map[string]string{"url": url},
|
||||
}
|
||||
_, err = fcmClient.Send(ctx, msg)
|
||||
} else {
|
||||
// existing VAPID webpush send
|
||||
}
|
||||
```
|
||||
|
||||
**Config:** Add `FIREBASE_CREDENTIALS_FILE` env var (path to service account JSON) to the systemd unit / Docker compose.
|
||||
|
||||
**Skipped:** Topic-based broadcast. Per-device tokens is fine for now. Add topics when channel count grows.
|
||||
|
||||
---
|
||||
|
||||
### Phase 4: Gradle / Build Config
|
||||
|
||||
#### Task 8: Configure Android build
|
||||
|
||||
**Objective:** Set minimum SDK, app icon, theme.
|
||||
|
||||
**Files:**
|
||||
- Modify: `web/android/app/build.gradle`
|
||||
- Modify: `web/android/app/src/main/res/values/strings.xml`
|
||||
|
||||
**Changes in `build.gradle`:**
|
||||
```gradle
|
||||
minSdkVersion = 24 // ponytail: Android 7+ covers 99% of Play Store. lower = more compat bugs.
|
||||
```
|
||||
|
||||
**App name in `strings.xml`:**
|
||||
```xml
|
||||
<string name="app_name">Dumpster Chat</string>
|
||||
```
|
||||
|
||||
**App icon:** Copy existing PWA icons into Android mipmap directories:
|
||||
```bash
|
||||
# Capacitor can sync icons automatically if placed at web/public/icon.png (1024x1024)
|
||||
# or manually: web/android/app/src/main/res/mipmap-*/
|
||||
npx cap assets generate # if a 1024x1024 source icon exists
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Phase 5: Build & Publish
|
||||
|
||||
#### Task 9: Sync and build debug APK
|
||||
|
||||
**Objective:** Verify the app runs on a real device or emulator.
|
||||
|
||||
**Steps:**
|
||||
```bash
|
||||
cd web
|
||||
npm run build # builds Vite → dist/
|
||||
npx cap sync android # copies dist/ into android/assets, syncs plugins
|
||||
cd android
|
||||
./gradlew assembleDebug
|
||||
```
|
||||
|
||||
**Output:** `web/android/app/build/outputs/apk/debug/app-debug.apk`
|
||||
|
||||
**Verify:** Install on Android device:
|
||||
```bash
|
||||
adb install app-debug.apk
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### Task 10: Build signed release AAB for Play Store
|
||||
|
||||
**Objective:** Create a signed Android App Bundle (.aab) for Google Play upload.
|
||||
|
||||
**Steps:**
|
||||
1. Generate keystore (one-time):
|
||||
```bash
|
||||
keytool -genkey -v -keystore dumpster-release.jks -keyalg RSA -keysize 2048 -validity 10000 -alias dumpster
|
||||
```
|
||||
Store `dumpster-release.jks` securely. Back it up. Lose it = can't update the app.
|
||||
|
||||
2. Add signing config to `web/android/app/build.gradle`:
|
||||
```gradle
|
||||
android {
|
||||
signingConfigs {
|
||||
release {
|
||||
storeFile file('dumpster-release.jks')
|
||||
storePassword System.getenv('KEYSTORE_PASSWORD')
|
||||
keyAlias 'dumpster'
|
||||
keyPassword System.getenv('KEY_PASSWORD')
|
||||
}
|
||||
}
|
||||
buildTypes {
|
||||
release {
|
||||
signingConfig signingConfigs.release
|
||||
minifyEnabled true
|
||||
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
3. Build AAB:
|
||||
```bash
|
||||
cd web/android
|
||||
KEYSTORE_PASSWORD=xxx KEY_PASSWORD=xxx ./gradlew bundleRelease
|
||||
```
|
||||
|
||||
**Output:** `web/android/app/build/outputs/bundle/release/app-release.aab`
|
||||
|
||||
4. Upload to Google Play Console → your new developer account → Create app → Upload AAB.
|
||||
|
||||
---
|
||||
|
||||
#### Task 11: Clean up Tauri dependencies
|
||||
|
||||
**Objective:** Remove unused Tauri packages (pivoted away from Tauri).
|
||||
|
||||
**Files:**
|
||||
- Modify: `web/package.json`
|
||||
|
||||
**Steps:**
|
||||
```bash
|
||||
cd web
|
||||
npm uninstall @tauri-apps/api @tauri-apps/cli
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Summary
|
||||
|
||||
| Phase | What | Time estimate |
|
||||
|-------|------|---------------|
|
||||
| 1 | Capacitor init + Android platform | 10 min |
|
||||
| 2 | API base URL native branch | 5 min |
|
||||
| 3 | FCM push (plugin + server) | 1-2 hrs (incl. Firebase setup) |
|
||||
| 4 | Gradle config / icons | 15 min |
|
||||
| 5 | Build, test, publish | 30 min |
|
||||
|
||||
**Total:** ~2-3 hours end-to-end. Phase 3 is the only real work.
|
||||
|
||||
**Dependencies between tasks:**
|
||||
- Tasks 1-3 sequential (Capacitor init)
|
||||
- Task 4 independent of 5-7
|
||||
- Tasks 5-7 sequential (FCM chain)
|
||||
- Task 8 depends on 1-3
|
||||
- Task 9 depends on all above
|
||||
- Task 10 depends on 9
|
||||
- Task 11 independent, do anytime
|
||||
|
||||
**After this plan:** Update the Makefile `build` target to include `npm run build && npx cap sync android` so deploys sync native assets too.
|
||||
Reference in New Issue
Block a user