Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9491f3a831 | |||
| 08e5d92059 | |||
| 92be2a30d1 | |||
| f4f6e8560b | |||
| 1900dd9cb1 | |||
| 1226bd28aa | |||
| 7cdee73542 | |||
| e8ba8ffdba | |||
| 52298d1d46 | |||
| 7fc5d66b8c |
@@ -10,14 +10,21 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
|
with: { node-version: 20 }
|
||||||
|
- name: Cache npm
|
||||||
|
uses: actions/cache@v3
|
||||||
with:
|
with:
|
||||||
node-version: 20
|
path: ~/.npm
|
||||||
cache: 'npm'
|
key: ${{ runner.os }}-npm-${{ hashFiles('web/package-lock.json') }}
|
||||||
cache-dependency-path: 'web/package-lock.json'
|
|
||||||
- uses: dtolnay/rust-toolchain@stable
|
- uses: dtolnay/rust-toolchain@stable
|
||||||
- uses: Swatinem/rust-cache@v2
|
- name: Cache Rust target and registry
|
||||||
|
uses: actions/cache@v3
|
||||||
with:
|
with:
|
||||||
workspaces: 'web/src-tauri'
|
path: |
|
||||||
|
~/.cargo/registry
|
||||||
|
~/.cargo/git
|
||||||
|
web/src-tauri/target
|
||||||
|
key: ${{ runner.os }}-cargo-${{ hashFiles('web/src-tauri/Cargo.lock') }}
|
||||||
- name: Install system deps
|
- name: Install system deps
|
||||||
run: sudo apt-get update && sudo apt-get install -y libwebkit2gtk-4.1-dev libgtk-3-dev libappindicator3-dev librsvg2-dev patchelf rpm
|
run: sudo apt-get update && sudo apt-get install -y libwebkit2gtk-4.1-dev libgtk-3-dev libappindicator3-dev librsvg2-dev patchelf rpm
|
||||||
- name: Build frontend
|
- name: Build frontend
|
||||||
@@ -40,10 +47,12 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
|
with: { node-version: 20 }
|
||||||
|
- name: Cache npm
|
||||||
|
uses: actions/cache@v3
|
||||||
with:
|
with:
|
||||||
node-version: 20
|
path: ~/.npm
|
||||||
cache: 'npm'
|
key: ${{ runner.os }}-npm-${{ hashFiles('web/package-lock.json') }}
|
||||||
cache-dependency-path: 'web/package-lock.json'
|
|
||||||
- name: Install Rust
|
- name: Install Rust
|
||||||
run: |
|
run: |
|
||||||
curl.exe -sLo rustup-init.exe https://win.rustup.rs/x86_64
|
curl.exe -sLo rustup-init.exe https://win.rustup.rs/x86_64
|
||||||
@@ -51,9 +60,11 @@ jobs:
|
|||||||
set PATH=%USERPROFILE%\.cargo\bin;%PATH%
|
set PATH=%USERPROFILE%\.cargo\bin;%PATH%
|
||||||
rustc --version
|
rustc --version
|
||||||
shell: cmd
|
shell: cmd
|
||||||
- uses: Swatinem/rust-cache@v2
|
- name: Cache Rust target and registry
|
||||||
|
uses: Swatinem/rust-cache@v2
|
||||||
with:
|
with:
|
||||||
workspaces: 'web/src-tauri'
|
workspaces: |
|
||||||
|
web/src-tauri
|
||||||
- name: Build frontend
|
- name: Build frontend
|
||||||
run: cd web && npm ci && npm run build
|
run: cd web && npm ci && npm run build
|
||||||
shell: cmd
|
shell: cmd
|
||||||
@@ -82,5 +93,8 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
tag_name: ${{ github.ref_name }}
|
tag_name: ${{ github.ref_name }}
|
||||||
files: |
|
files: |
|
||||||
linux-bundles/*
|
linux-bundles/**/*.AppImage
|
||||||
windows-bundles/*
|
linux-bundles/**/*.deb
|
||||||
|
linux-bundles/**/*.rpm
|
||||||
|
windows-bundles/**/*.msi
|
||||||
|
windows-bundles/**/*.exe
|
||||||
|
|||||||
+12
-1
@@ -37,6 +37,7 @@ import (
|
|||||||
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/webhook"
|
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/webhook"
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
chimw "github.com/go-chi/chi/v5/middleware"
|
chimw "github.com/go-chi/chi/v5/middleware"
|
||||||
|
"github.com/go-chi/cors"
|
||||||
httpSwagger "github.com/swaggo/http-swagger"
|
httpSwagger "github.com/swaggo/http-swagger"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -70,7 +71,7 @@ func main() {
|
|||||||
sessionStore := auth.NewSessionStore(database.DB, cfg)
|
sessionStore := auth.NewSessionStore(database.DB, cfg)
|
||||||
|
|
||||||
// WebSocket origin allowlist
|
// WebSocket origin allowlist
|
||||||
wsOrigins := []string{"https://" + cfg.Host}
|
wsOrigins := []string{"https://" + cfg.Host, "http://tauri.localhost", "https://tauri.localhost", "tauri://localhost"}
|
||||||
if cfg.Host == "localhost" {
|
if cfg.Host == "localhost" {
|
||||||
wsOrigins = append(wsOrigins, "http://localhost:"+cfg.Port)
|
wsOrigins = append(wsOrigins, "http://localhost:"+cfg.Port)
|
||||||
}
|
}
|
||||||
@@ -119,6 +120,16 @@ func main() {
|
|||||||
memberHandler := server.NewMemberHandler(database.DB)
|
memberHandler := server.NewMemberHandler(database.DB)
|
||||||
|
|
||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
|
|
||||||
|
r.Use(cors.Handler(cors.Options{
|
||||||
|
AllowedOrigins: []string{"https://" + cfg.Host, "http://localhost:" + cfg.Port, "http://tauri.localhost", "https://tauri.localhost", "tauri://localhost"},
|
||||||
|
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"},
|
||||||
|
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token"},
|
||||||
|
ExposedHeaders: []string{"Link", "X-Session-Token"},
|
||||||
|
AllowCredentials: true,
|
||||||
|
MaxAge: 300,
|
||||||
|
}))
|
||||||
|
|
||||||
r.Use(chimw.Logger)
|
r.Use(chimw.Logger)
|
||||||
r.Use(chimw.Recoverer)
|
r.Use(chimw.Recoverer)
|
||||||
r.Use(chimw.RequestID)
|
r.Use(chimw.RequestID)
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@@ -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.
|
||||||
@@ -49,6 +49,7 @@ require (
|
|||||||
github.com/fsnotify/fsnotify v1.10.1 // indirect
|
github.com/fsnotify/fsnotify v1.10.1 // indirect
|
||||||
github.com/fxamacker/cbor/v2 v2.9.2 // indirect
|
github.com/fxamacker/cbor/v2 v2.9.2 // indirect
|
||||||
github.com/gammazero/deque v1.2.1 // indirect
|
github.com/gammazero/deque v1.2.1 // indirect
|
||||||
|
github.com/go-chi/cors v1.2.2 // indirect
|
||||||
github.com/go-logr/logr v1.4.3 // indirect
|
github.com/go-logr/logr v1.4.3 // indirect
|
||||||
github.com/go-logr/stdr v1.2.2 // indirect
|
github.com/go-logr/stdr v1.2.2 // indirect
|
||||||
github.com/go-openapi/jsonpointer v0.19.5 // indirect
|
github.com/go-openapi/jsonpointer v0.19.5 // indirect
|
||||||
|
|||||||
@@ -87,6 +87,8 @@ github.com/gammazero/deque v1.2.1 h1:9fnQVFCCZ9/NOc7ccTNqzoKd1tCWOqeI05/lPqFPMGQ
|
|||||||
github.com/gammazero/deque v1.2.1/go.mod h1:5nSFkzVm+afG9+gy0VIowlqVAW4N8zNcMne+CMQVD2g=
|
github.com/gammazero/deque v1.2.1/go.mod h1:5nSFkzVm+afG9+gy0VIowlqVAW4N8zNcMne+CMQVD2g=
|
||||||
github.com/go-chi/chi/v5 v5.3.0 h1:halUjDxhshgXHMrao5bB8eNBXo/rnzwr8m5m36glehM=
|
github.com/go-chi/chi/v5 v5.3.0 h1:halUjDxhshgXHMrao5bB8eNBXo/rnzwr8m5m36glehM=
|
||||||
github.com/go-chi/chi/v5 v5.3.0/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
|
github.com/go-chi/chi/v5 v5.3.0/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
|
||||||
|
github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE=
|
||||||
|
github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
|
||||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||||
|
|||||||
@@ -21,3 +21,4 @@ func SetSessionCookie(w http.ResponseWriter, cookieName, token string, duration
|
|||||||
MaxAge: int(duration.Seconds()),
|
MaxAge: int(duration.Seconds()),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -331,6 +331,7 @@ func (h *Handler) Register(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
h.setSessionCookie(w, token)
|
h.setSessionCookie(w, token)
|
||||||
|
w.Header().Set("X-Session-Token", token)
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
json.NewEncoder(w).Encode(map[string]string{"id": userID})
|
json.NewEncoder(w).Encode(map[string]string{"id": userID})
|
||||||
}
|
}
|
||||||
@@ -383,6 +384,7 @@ func (h *Handler) Login(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
h.setSessionCookie(w, token)
|
h.setSessionCookie(w, token)
|
||||||
|
w.Header().Set("X-Session-Token", token)
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
json.NewEncoder(w).Encode(map[string]string{"id": userID})
|
json.NewEncoder(w).Encode(map[string]string{"id": userID})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -234,7 +234,8 @@ func (h *WebAuthnHandler) LoginBegin(w http.ResponseWriter, r *http.Request) {
|
|||||||
Path: "/",
|
Path: "/",
|
||||||
HttpOnly: true,
|
HttpOnly: true,
|
||||||
MaxAge: 300, // 5 minutes
|
MaxAge: 300, // 5 minutes
|
||||||
SameSite: http.SameSiteStrictMode,
|
SameSite: http.SameSiteNoneMode,
|
||||||
|
Secure: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
@@ -341,9 +342,11 @@ func (h *WebAuthnHandler) LoginFinish(w http.ResponseWriter, r *http.Request) {
|
|||||||
Path: "/",
|
Path: "/",
|
||||||
HttpOnly: true,
|
HttpOnly: true,
|
||||||
MaxAge: -1,
|
MaxAge: -1,
|
||||||
SameSite: http.SameSiteStrictMode,
|
SameSite: http.SameSiteNoneMode,
|
||||||
|
Secure: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
w.Header().Set("X-Session-Token", token)
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
json.NewEncoder(w).Encode(map[string]string{"status": "authenticated"})
|
json.NewEncoder(w).Encode(map[string]string{"status": "authenticated"})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,13 +18,23 @@ type SessionStore interface {
|
|||||||
func Session(store SessionStore, cfg *config.Config) func(http.Handler) http.Handler {
|
func Session(store SessionStore, cfg *config.Config) func(http.Handler) http.Handler {
|
||||||
return func(next http.Handler) http.Handler {
|
return func(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var token string
|
||||||
cookie, err := r.Cookie(cfg.Session.CookieName)
|
cookie, err := r.Cookie(cfg.Session.CookieName)
|
||||||
if err != nil {
|
if err == nil {
|
||||||
|
token = cookie.Value
|
||||||
|
} else {
|
||||||
|
authHeader := r.Header.Get("Authorization")
|
||||||
|
if len(authHeader) > 7 && authHeader[:7] == "Bearer " {
|
||||||
|
token = authHeader[7:]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if token == "" {
|
||||||
next.ServeHTTP(w, r)
|
next.ServeHTTP(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
userID, err := store.GetUserIDByToken(r.Context(), cookie.Value)
|
userID, err := store.GetUserIDByToken(r.Context(), token)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
next.ServeHTTP(w, r)
|
next.ServeHTTP(w, r)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"project_info": {
|
||||||
|
"project_number": "629643353973",
|
||||||
|
"project_id": "dumpster-chat",
|
||||||
|
"storage_bucket": "dumpster-chat.firebasestorage.app"
|
||||||
|
},
|
||||||
|
"client": [
|
||||||
|
{
|
||||||
|
"client_info": {
|
||||||
|
"mobilesdk_app_id": "1:629643353973:android:29dcc703959dd306c0fd3c",
|
||||||
|
"android_client_info": {
|
||||||
|
"package_name": "coffee.dustin.dumpster"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"oauth_client": [],
|
||||||
|
"api_key": [
|
||||||
|
{
|
||||||
|
"current_key": "AIzaSyDt3h4G-imzD7IedRqYOUBIv8CtZQT2YyA"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"services": {
|
||||||
|
"appinvite_service": {
|
||||||
|
"other_platform_oauth_client": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"configuration_version": "1"
|
||||||
|
}
|
||||||
+10
-1
@@ -60,11 +60,20 @@
|
|||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
<script type="module" src="/src/main.tsx"></script>
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
<script>
|
<script>
|
||||||
// Register service worker
|
// Register service worker (only for web, avoid in Tauri to prevent 404 cache bugs)
|
||||||
if ('serviceWorker' in navigator) {
|
if ('serviceWorker' in navigator) {
|
||||||
|
if (!('__TAURI_INTERNALS__' in window) && !('__TAURI__' in window)) {
|
||||||
window.addEventListener('load', () => {
|
window.addEventListener('load', () => {
|
||||||
navigator.serviceWorker.register('/sw.js').catch(() => {});
|
navigator.serviceWorker.register('/sw.js').catch(() => {});
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
|
// In Tauri, aggressively unregister any old service workers that might be causing 404s
|
||||||
|
navigator.serviceWorker.getRegistrations().then((registrations) => {
|
||||||
|
for (let registration of registrations) {
|
||||||
|
registration.unregister();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -40,6 +40,19 @@ async function request<T>(method: string, path: string, body?: unknown): Promise
|
|||||||
const ra = response.headers.get('Retry-After');
|
const ra = response.headers.get('Retry-After');
|
||||||
err.retryAfter = retryAfter ?? (ra ? parseInt(ra, 10) : undefined);
|
err.retryAfter = retryAfter ?? (ra ? parseInt(ra, 10) : undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Auto-logout on 401 for any non-login endpoint so expired sessions
|
||||||
|
// redirect to the login page instead of showing cryptic errors.
|
||||||
|
if (response.status === 401 && !path.startsWith('/auth/login') && !path.startsWith('/auth/register')) {
|
||||||
|
// Dynamically import to avoid circular deps
|
||||||
|
import('../stores/auth.ts').then(({ useAuthStore }) => {
|
||||||
|
const state = useAuthStore.getState();
|
||||||
|
if (state.isAuthenticated) {
|
||||||
|
useAuthStore.setState({ user: null, isAuthenticated: false, error: null });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,71 @@ import { createRoot } from 'react-dom/client';
|
|||||||
import './styles/index.css';
|
import './styles/index.css';
|
||||||
import App from './App.tsx';
|
import App from './App.tsx';
|
||||||
|
|
||||||
|
const isTauri = 'window' in globalThis && '__TAURI_INTERNALS__' in window;
|
||||||
|
if (isTauri) {
|
||||||
|
const TARGET = 'https://dumpster.dustin.coffee';
|
||||||
|
const WS_TARGET = 'wss://dumpster.dustin.coffee';
|
||||||
|
|
||||||
|
let sessionToken = localStorage.getItem('dumpster_session_token') || '';
|
||||||
|
|
||||||
|
const originalFetch = window.fetch;
|
||||||
|
window.fetch = async (input, init) => {
|
||||||
|
let url = typeof input === 'string' ? input : input.toString();
|
||||||
|
if (url.startsWith('/')) {
|
||||||
|
url = TARGET + url;
|
||||||
|
}
|
||||||
|
|
||||||
|
let newInit = init ? { ...init } : {};
|
||||||
|
if (sessionToken) {
|
||||||
|
newInit.headers = {
|
||||||
|
...newInit.headers,
|
||||||
|
'Authorization': 'Bearer ' + sessionToken
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await originalFetch(url, newInit);
|
||||||
|
|
||||||
|
const token = response.headers.get('X-Session-Token');
|
||||||
|
if (token) {
|
||||||
|
sessionToken = token;
|
||||||
|
localStorage.setItem('dumpster_session_token', token);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.endsWith('/auth/logout') && response.ok) {
|
||||||
|
sessionToken = '';
|
||||||
|
localStorage.removeItem('dumpster_session_token');
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
};
|
||||||
|
|
||||||
|
const OriginalWebSocket = window.WebSocket;
|
||||||
|
window.WebSocket = function(url: string | URL, protocols?: string | string[]) {
|
||||||
|
let urlStr = typeof url === 'string' ? url : url.toString();
|
||||||
|
if (urlStr.includes('tauri.localhost') || urlStr.includes('tauri://localhost')) {
|
||||||
|
urlStr = urlStr.replace(/^(wss?|https?|tauri):\/\/tauri\.localhost/, WS_TARGET);
|
||||||
|
urlStr = urlStr.replace(/^tauri:\/\/localhost/, WS_TARGET);
|
||||||
|
} else if (urlStr.startsWith('ws://localhost') && isTauri) {
|
||||||
|
urlStr = urlStr.replace('ws://localhost', WS_TARGET);
|
||||||
|
} else if (urlStr.startsWith('/')) {
|
||||||
|
urlStr = WS_TARGET + urlStr;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ws = new OriginalWebSocket(urlStr, protocols);
|
||||||
|
|
||||||
|
if (sessionToken && urlStr.includes('/ws') && !urlStr.includes('/ws/bot')) {
|
||||||
|
const originalOnOpen = ws.onopen;
|
||||||
|
ws.onopen = function(ev) {
|
||||||
|
ws.send(JSON.stringify({ token: sessionToken }));
|
||||||
|
if (originalOnOpen) originalOnOpen.call(ws, ev);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return ws;
|
||||||
|
} as unknown as typeof WebSocket;
|
||||||
|
window.WebSocket.prototype = OriginalWebSocket.prototype;
|
||||||
|
}
|
||||||
|
|
||||||
createRoot(document.getElementById('root')!).render(
|
createRoot(document.getElementById('root')!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<App />
|
<App />
|
||||||
|
|||||||
@@ -151,12 +151,19 @@ export const useAuthStore = create<AuthState>((set) => ({
|
|||||||
set({ user, isAuthenticated: true, isLoading: false });
|
set({ user, isAuthenticated: true, isLoading: false });
|
||||||
autoSubscribePush();
|
autoSubscribePush();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
// 401 from /auth/me simply means "no active session" — not a
|
||||||
|
// user-facing error. Only surface non-auth failures.
|
||||||
|
const isAuthError =
|
||||||
|
error instanceof Error && (error as import('../lib/api.ts').ApiError).status === 401;
|
||||||
set({
|
set({
|
||||||
user: null,
|
user: null,
|
||||||
isAuthenticated: false,
|
isAuthenticated: false,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
error:
|
error: isAuthError
|
||||||
error instanceof Error ? error.message : "Failed to fetch user",
|
? null
|
||||||
|
: error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: "Failed to fetch user",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user