Compare commits

..

26 Commits

Author SHA1 Message Date
hobokenchicken 3d0bc76ef2 fix(linux): guard WebSocket.prototype assignment — read-only on WebKitGTK
Release Desktop Apps / build-linux (push) Successful in 4m22s
Release Desktop Apps / build-windows (push) Successful in 17m10s
Release Desktop Apps / release (push) Successful in 11s
2026-07-20 12:26:14 -04:00
hobokenchicken 088b27f9c8 fix: remove env() from CSS, set safe-area via JS only
Release Desktop Apps / build-linux (push) Successful in 4m23s
Release Desktop Apps / build-windows (push) Successful in 17m0s
Release Desktop Apps / release (push) Successful in 11s
2026-07-20 11:50:07 -04:00
hobokenchicken 7894d1f796 chore: bump to v0.2.9 (version code 2009) 2026-07-20 11:37:26 -04:00
hobokenchicken 2332e5a21c fix(android): replace env() with JS-set CSS vars for safe areas 2026-07-20 11:30:28 -04:00
hobokenchicken 478e90d305 fix: append messages locally on send, don't rely solely on WS
Release Desktop Apps / build-linux (push) Successful in 2m49s
Release Desktop Apps / build-windows (push) Successful in 17m47s
Release Desktop Apps / release (push) Successful in 9s
2026-07-20 11:07:57 -04:00
hobokenchicken 062bdfddc5 fix: add Tauri origins to CSRF allowlist 2026-07-20 11:02:34 -04:00
hobokenchicken c5be9cea52 fix: correct debug symbols structure for Play Store 2026-07-20 10:58:21 -04:00
hobokenchicken afd1bec53e chore: bump to v0.2.8 (version code 2008) 2026-07-20 10:25:47 -04:00
hobokenchicken f4590f28ea fix(android): replace default icons with dumpster fire logo 2026-07-20 10:12:38 -04:00
hobokenchicken 40ce05b8ce chore: bump to v0.2.7 (version code 2007) 2026-07-20 10:01:32 -04:00
hobokenchicken fb9f06468f chore: add native debug symbols for Play Store 2026-07-20 09:52:24 -04:00
hobokenchicken ec5cb80844 fix(android): edge-to-edge alignment, keyboard handling, toolbar spacing 2026-07-20 09:49:38 -04:00
hobokenchicken 27342e727d chore: remove leftover hashgen temp file 2026-07-17 15:48:46 -04:00
hobokenchicken ddead767ae feat: account deletion in settings + privacy policy with deletion link 2026-07-17 15:47:29 -04:00
hobokenchicken 09a59c6124 fix(tauri): disable WebKitGTK sandbox inside AppImage on Linux and bump version to 0.2.6
Release Desktop Apps / build-linux (push) Successful in 2m40s
Release Desktop Apps / build-windows (push) Successful in 3h16m19s
Release Desktop Apps / release (push) Successful in 8s
2026-07-16 15:28:36 -04:00
hobokenchicken f20f4aa6fa fix(tauri): set WEBKIT_DISABLE_DMABUF_RENDERER=1 on Linux to prevent white screen
Release Desktop Apps / build-linux (push) Successful in 2m41s
Release Desktop Apps / build-windows (push) Successful in 3h16m41s
Release Desktop Apps / release (push) Successful in 8s
2026-07-16 15:03:10 -04:00
hobokenchicken 9491f3a831 fix: use SameSite=Lax for session cookie
Release Desktop Apps / build-linux (push) Successful in 2m59s
Release Desktop Apps / release (push) Has been cancelled
Release Desktop Apps / build-windows (push) Has been cancelled
SameSite=None requires Secure=true or modern browsers silently reject
the Set-Cookie header. Since the site is served over HTTPS via Caddy,
this was causing login to succeed (200) but the session cookie to be
dropped, making the subsequent /auth/me call fail with 401.

SameSite=Lax is the correct setting for same-origin session cookies.
2026-07-16 14:52:13 -04:00
hobokenchicken 08e5d92059 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
2026-07-16 14:46:17 -04:00
hobokenchicken 92be2a30d1 fix(desktop): implement bearer token auth for windows webview2
Release Desktop Apps / build-linux (push) Successful in 2m49s
Release Desktop Apps / release (push) Has been cancelled
Release Desktop Apps / build-windows (push) Has been cancelled
2026-07-16 14:30:28 -04:00
hobokenchicken f4f6e8560b fix(auth): set SameSite=None for session cookies to fix cross-origin session loss in Tauri apps 2026-07-16 14:26:18 -04:00
hobokenchicken 1900dd9cb1 fix(api): add CORS headers to allow cross-origin requests from Tauri desktop app 2026-07-16 14:21:15 -04:00
hobokenchicken 1226bd28aa added firebase json 2026-07-16 14:07:29 -04:00
hobokenchicken 7cdee73542 fix(tauri): unregister and prevent service worker to fix 404 cache routing issues
Release Desktop Apps / build-linux (push) Successful in 3m17s
Release Desktop Apps / build-windows (push) Successful in 3h17m8s
Release Desktop Apps / release (push) Successful in 13s
2026-07-16 13:58:06 -04:00
hobokenchicken e8ba8ffdba fix(tauri): redirect relative fetch and websocket urls to production backend for desktop app
Release Desktop Apps / build-linux (push) Successful in 3m2s
Release Desktop Apps / build-windows (push) Successful in 3h24m0s
Release Desktop Apps / release (push) Successful in 10s
2026-07-16 13:27:33 -04:00
hobokenchicken 52298d1d46 fix(ci): manually configure caching using actions/cache@v3 for gitea compatibility
Release Desktop Apps / build-linux (push) Successful in 10m35s
Release Desktop Apps / build-windows (push) Successful in 3h16m21s
Release Desktop Apps / release (push) Successful in 11s
2026-07-16 13:03:56 -04:00
hobokenchicken 7fc5d66b8c fix(ci): recursively glob artifact files to prevent empty releases
Release Desktop Apps / build-linux (push) Failing after 11m16s
Release Desktop Apps / release (push) Has been cancelled
Release Desktop Apps / build-windows (push) Has been cancelled
2026-07-16 12:47:45 -04:00
82 changed files with 1743 additions and 35 deletions
+26 -12
View File
@@ -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
+4
View File
@@ -42,3 +42,7 @@ minio_data/
/dumpster-server /dumpster-server
keygen keygen
steamfree steamfree
# Tauri Android keystore (contains signing passwords)
web/src-tauri/gen/android/keystore.properties
+10
View File
@@ -18,6 +18,16 @@ build-tui:
build-tauri: build-tauri:
cd web && NODE_ENV=development NO_STRIP=true npx tauri build --bundles appimage,deb,rpm cd web && NODE_ENV=development NO_STRIP=true npx tauri build --bundles appimage,deb,rpm
# Build signed Android APK
build-android:
cd web && ANDROID_HOME=$(HOME)/Android/Sdk NDK_HOME=$(HOME)/Android/Sdk/ndk/27.2.12479018 JAVA_HOME=/usr/lib/jvm/java-17-openjdk npx tauri android build --apk
@echo "APK: web/src-tauri/gen/android/app/build/outputs/apk/universal/release/app-universal-release.apk"
# Build signed Android AAB (Google Play bundle)
build-android-aab:
cd web && ANDROID_HOME=$(HOME)/Android/Sdk NDK_HOME=$(HOME)/Android/Sdk/ndk/27.2.12479018 JAVA_HOME=/usr/lib/jvm/java-17-openjdk npx tauri android build --aab
@echo "AAB: web/src-tauri/gen/android/app/build/outputs/bundle/universalRelease/app-universal-release.aab"
# Generate Swagger docs # Generate Swagger docs
docs: docs:
~/go/bin/swag init -g cmd/server/main.go -o docs ~/go/bin/swag init -g cmd/server/main.go -o docs
+124 -2
View File
@@ -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)
@@ -152,7 +163,10 @@ func main() {
r.Group(func(r chi.Router) { r.Group(func(r chi.Router) {
r.Use(middleware.Session(sessionStore, cfg)) r.Use(middleware.Session(sessionStore, cfg))
r.Use(middleware.RequireAuth) r.Use(middleware.RequireAuth)
r.Use(middleware.CSRFProtect(cfg.Host, cfg.Port, strings.Split(os.Getenv("DUMPSTER_CSRF_ORIGINS"), ","))) r.Use(middleware.CSRFProtect(cfg.Host, cfg.Port, append(
strings.Split(os.Getenv("DUMPSTER_CSRF_ORIGINS"), ","),
"tauri://localhost", "http://tauri.localhost", "https://tauri.localhost",
)))
// Auth (protected: me, update profile) // Auth (protected: me, update profile)
authHandler.RegisterProtectedRoutes(r) authHandler.RegisterProtectedRoutes(r)
@@ -417,6 +431,13 @@ func main() {
)) ))
}) })
// Privacy policy page (served before SPA catch-all)
r.HandleFunc("/privacy", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "public, max-age=3600")
w.Write([]byte(privacyPage))
})
// Static file serving for production (SPA) // Static file serving for production (SPA)
staticDir := "web/dist" staticDir := "web/dist"
if _, err := os.Stat(staticDir); err == nil { if _, err := os.Stat(staticDir); err == nil {
@@ -439,3 +460,104 @@ func main() {
os.Exit(1) os.Exit(1)
} }
} }
const privacyPage = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Privacy Policy — dumpsterChat</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
background: #1d2021; color: #ebdbb2; line-height: 1.7; padding: 2rem 1rem;
}
main { max-width: 720px; margin: 0 auto; }
h1 { font-size: 1.8rem; margin-bottom: 0.25rem; color: #fabd2f; }
.subtitle { color: #a89984; font-size: 0.85rem; margin-bottom: 2rem; }
h2 { font-size: 1.15rem; margin: 1.5rem 0 0.5rem; color: #83a598; }
p, li { margin-bottom: 0.6rem; }
ul { padding-left: 1.25rem; }
li { margin-bottom: 0.3rem; }
a { color: #8ec07c; }
.footer { margin-top: 2.5rem; padding-top: 1rem; border-top: 1px solid #3c3836; font-size: 0.8rem; color: #928374; }
.update { color: #928374; font-size: 0.8rem; margin-top: 1.5rem; }
</style>
</head>
<body>
<main>
<h1>Privacy Policy</h1>
<p class="subtitle"><strong>dumpsterChat</strong> — Last updated: July 17, 2026</p>
<h2>Overview</h2>
<p>dumpsterChat is a self-hosted messaging platform. This privacy policy describes how your data is handled when you use the app. Because dumpsterChat is <strong>self-hosted</strong>, your data is stored on the server instance you connect to, which is operated by the server owner — not by us.</p>
<h2>Data We Collect</h2>
<p>When you use dumpsterChat, the following data is stored on the server:</p>
<ul>
<li><strong>Account information:</strong> username, email address, avatar, and password hash (Argon2id, not reversible).</li>
<li><strong>Messages and content:</strong> text messages, reactions, uploaded files, voice activity metadata, and poll votes.</li>
<li><strong>Session data:</strong> login sessions stored in encrypted cookies.</li>
</ul>
<h2>How We Use Your Data</h2>
<p>Your data is used solely to operate the chat platform:</p>
<ul>
<li>Deliver messages and notifications to the intended recipients.</li>
<li>Sync read states and presence (online/offline) across your devices.</li>
<li>Provide moderation tools (kicks, bans, mutes) per server rules.</li>
</ul>
<h2>No Third-Party Analytics</h2>
<p>dumpsterChat does <strong>not</strong> include any analytics SDKs, tracking pixels, or telemetry. No usage data is sent to us or to any third party for advertising, profiling, or analytics purposes.</p>
<h2>Third-Party Integrations</h2>
<p>If enabled by the server owner, optional integrations may be used:</p>
<ul>
<li><strong>Giphy:</strong> GIF search queries are proxied through the server to Giphy's API. No user data is shared with Giphy.</li>
<li><strong>LiveKit:</strong> Voice and video calls use a self-hosted LiveKit server. Media streams are processed in real time and are not recorded or stored by default.</li>
</ul>
<p>These integrations are optional and controlled entirely by the server owner.</p>
<h2>Data Retention</h2>
<p>Data is retained for as long as the server owner maintains the database. You can delete your messages or account at any time through the app. Server owners may also set message retention limits. Uploaded files persist until explicitly removed.</p>
<h2>Your Rights</h2>
<p>Depending on your jurisdiction, you may have the right to:</p>
<ul>
<li>Request a copy of your stored data.</li>
<li>Delete your account and associated data.</li>
<li>Correct inaccurate personal information.</li>
</ul>
<p>To exercise these rights, contact the operator of the dumpsterChat instance you use, or use the account management tools available within the app.</p>
<h2>Account Deletion Requests</h2>
<p>To request deletion of your account and all associated data:</p>
<ul>
<li>Use the <strong>Delete Account</strong> option in your account settings within the app.</li>
<li>Or email <a href="mailto:account-deletion@dustin.coffee">account-deletion@dustin.coffee</a> from the email address associated with your account.</li>
</ul>
<p>We will process your request within 30 days. Deletion removes your account, messages, uploaded files, and all personal data from the server.</p>
<h2>Security</h2>
<p>We take reasonable measures to protect your data:</p>
<ul>
<li>Passwords are hashed with Argon2id.</li>
<li>Session tokens use httpOnly cookies.</li>
<li>All communications are encrypted over HTTPS and WSS where available.</li>
</ul>
<h2>Children's Privacy</h2>
<p>dumpsterChat is not directed at children under 13. We do not knowingly collect personal information from children.</p>
<h2>Changes to This Policy</h2>
<p>We may update this privacy policy from time to time. Changes will be posted at this URL. Continued use of the app after changes constitutes acceptance of the updated policy.</p>
<h2>Contact</h2>
<p>If you have questions about this privacy policy, contact the operator of the dumpsterChat instance you use, or open an issue on our project repository.</p>
<div class="update">This privacy policy applies to the dumpsterChat mobile app and web app. The specific data practices of each instance may vary based on the server operator's configuration.</div>
</main>
</body>
</html>`
Binary file not shown.
Binary file not shown.
+369
View File
@@ -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.
+1
View File
@@ -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
+2
View File
@@ -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=
+1
View File
@@ -21,3 +21,4 @@ func SetSessionCookie(w http.ResponseWriter, cookieName, token string, duration
MaxAge: int(duration.Seconds()), MaxAge: int(duration.Seconds()),
}) })
} }
+32
View File
@@ -50,6 +50,7 @@ func (h *Handler) RegisterProtectedRoutes(r chi.Router) {
r.Get("/auth/me", h.Me) r.Get("/auth/me", h.Me)
r.Patch("/auth/me", h.UpdateProfile) r.Patch("/auth/me", h.UpdateProfile)
r.Put("/auth/me/password", h.ChangePassword) r.Put("/auth/me/password", h.ChangePassword)
r.Delete("/auth/me", h.DeleteAccount)
r.Get("/users/{userID}/profile", h.GetPublicProfile) r.Get("/users/{userID}/profile", h.GetPublicProfile)
r.Get("/users/me/blocks", h.ListBlocks) r.Get("/users/me/blocks", h.ListBlocks)
r.Post("/users/me/blocks", h.BlockUser) r.Post("/users/me/blocks", h.BlockUser)
@@ -331,6 +332,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 +385,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})
} }
@@ -660,3 +663,32 @@ func (h *Handler) ChangePassword(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"message": "password updated"}) json.NewEncoder(w).Encode(map[string]string{"message": "password updated"})
} }
// DeleteAccount deletes the authenticated user and all associated data.
// ponytail: cascading FK handles all DB cleanup — push subscriptions cleaned manually.
func (h *Handler) DeleteAccount(w http.ResponseWriter, r *http.Request) {
userID, ok := middleware.UserIDFromContext(r.Context())
if !ok {
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
return
}
// Clean up push subscriptions
h.db.ExecContext(r.Context(), `DELETE FROM push_subscriptions WHERE user_id = $1`, userID)
// ON DELETE CASCADE handles everything else: sessions, tokens, messages,
// reactions, memberships, invites, bots, etc.
result, err := h.db.ExecContext(r.Context(), `DELETE FROM users WHERE id = $1`, userID)
if err != nil {
http.Error(w, `{"error":"failed to delete account"}`, http.StatusInternalServerError)
return
}
rows, _ := result.RowsAffected()
if rows == 0 {
http.Error(w, `{"error":"user not found"}`, http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"message": "account deleted"})
}
+5 -2
View File
@@ -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"})
} }
+12 -2
View File
@@ -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
Binary file not shown.
Binary file not shown.
+29
View File
@@ -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
View File
@@ -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>
Binary file not shown.

After

Width:  |  Height:  |  Size: 502 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

+12
View File
@@ -0,0 +1,12 @@
# EditorConfig is awesome: https://EditorConfig.org
# top-most EditorConfig file
root = true
[*]
indent_style = space
indent_size = 2
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = false
insert_final_newline = false
+20
View File
@@ -0,0 +1,20 @@
*.iml
.gradle
/local.properties
/.idea/caches
/.idea/libraries
/.idea/modules.xml
/.idea/workspace.xml
/.idea/navEditor.xml
/.idea/assetWizardSettings.xml
.DS_Store
build
/captures
.externalNativeBuild
.cxx
local.properties
key.properties
keystore.properties
/.tauri
/tauri.settings.gradle
+6
View File
@@ -0,0 +1,6 @@
/src/main/**/generated
/src/main/jniLibs/**/*.so
/src/main/assets/tauri.conf.json
/tauri.build.gradle.kts
/proguard-tauri.pro
/tauri.properties
@@ -0,0 +1,90 @@
import java.util.Properties
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
id("rust")
}
val tauriProperties = Properties().apply {
val propFile = file("tauri.properties")
if (propFile.exists()) {
propFile.inputStream().use { load(it) }
}
}
val keystorePropertiesFile = rootProject.file("keystore.properties")
val keystoreProperties = Properties()
if (keystorePropertiesFile.exists()) {
keystoreProperties.load(keystorePropertiesFile.inputStream())
}
android {
compileSdk = 36
namespace = "coffee.dustin.dumpster"
defaultConfig {
manifestPlaceholders["usesCleartextTraffic"] = "false"
applicationId = "coffee.dustin.dumpster"
minSdk = 24
targetSdk = 36
versionCode = tauriProperties.getProperty("tauri.android.versionCode", "1").toInt()
versionName = tauriProperties.getProperty("tauri.android.versionName", "1.0")
}
signingConfigs {
if (keystorePropertiesFile.exists()) {
create("release") {
storeFile = file(keystoreProperties["storeFile"] as String)
storePassword = keystoreProperties["storePassword"] as String
keyAlias = keystoreProperties["keyAlias"] as String
keyPassword = keystoreProperties["keyPassword"] as String
}
}
}
buildTypes {
getByName("debug") {
manifestPlaceholders["usesCleartextTraffic"] = "true"
isDebuggable = true
isJniDebuggable = true
isMinifyEnabled = false
packaging { jniLibs.keepDebugSymbols.add("*/arm64-v8a/*.so")
jniLibs.keepDebugSymbols.add("*/armeabi-v7a/*.so")
jniLibs.keepDebugSymbols.add("*/x86/*.so")
jniLibs.keepDebugSymbols.add("*/x86_64/*.so")
}
}
getByName("release") {
if (keystorePropertiesFile.exists()) {
signingConfig = signingConfigs.getByName("release")
}
isMinifyEnabled = true
proguardFiles(
*fileTree(".") { include("**/*.pro") }
.plus(getDefaultProguardFile("proguard-android-optimize.txt"))
.toList().toTypedArray()
)
}
}
kotlinOptions {
jvmTarget = "1.8"
}
buildFeatures {
buildConfig = true
}
}
rust {
rootDirRel = "../../../"
}
dependencies {
implementation("androidx.webkit:webkit:1.14.0")
implementation("androidx.appcompat:appcompat:1.7.1")
implementation("androidx.activity:activity-ktx:1.10.1")
implementation("com.google.android.material:material:1.12.0")
implementation("androidx.lifecycle:lifecycle-process:2.10.0")
testImplementation("junit:junit:4.13.2")
androidTestImplementation("androidx.test.ext:junit:1.1.4")
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.0")
}
apply(from = "tauri.build.gradle.kts")
+21
View File
@@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile
@@ -0,0 +1,38 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<!-- AndroidTV support -->
<uses-feature android:name="android.software.leanback" android:required="false" />
<application
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:theme="@style/Theme.app"
android:usesCleartextTraffic="${usesCleartextTraffic}">
<activity
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode"
android:launchMode="singleTask"
android:label="@string/main_activity_title"
android:name=".MainActivity"
android:exported="true"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
<!-- AndroidTV support -->
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
</intent-filter>
</activity>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application>
</manifest>
@@ -0,0 +1,11 @@
package coffee.dustin.dumpster
import android.os.Bundle
import androidx.activity.enableEdgeToEdge
class MainActivity : TauriActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
super.onCreate(savedInstanceState)
}
}
@@ -0,0 +1,30 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
<aapt:attr name="android:fillColor">
<gradient
android:endX="85.84757"
android:endY="92.4963"
android:startX="42.9492"
android:startY="49.59793"
android:type="linear">
<item
android:color="#44000000"
android:offset="0.0" />
<item
android:color="#00000000"
android:offset="1.0" />
</gradient>
</aapt:attr>
</path>
<path
android:fillColor="#FFFFFF"
android:fillType="nonZero"
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
android:strokeWidth="1"
android:strokeColor="#00000000" />
</vector>
@@ -0,0 +1,170 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#3DDC84"
android:pathData="M0,0h108v108h-108z" />
<path
android:fillColor="#00000000"
android:pathData="M9,0L9,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,0L19,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,0L29,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,0L39,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,0L49,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,0L59,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,0L69,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,0L79,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M89,0L89,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M99,0L99,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,9L108,9"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,19L108,19"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,29L108,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,39L108,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,49L108,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,59L108,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,69L108,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,79L108,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,89L108,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,99L108,99"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,29L89,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,39L89,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,49L89,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,59L89,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,69L89,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,79L89,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,19L29,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,19L39,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,19L49,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,19L59,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,19L69,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,19L79,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
</vector>
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Hello World!"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

@@ -0,0 +1,10 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<!-- Base application theme. Forces dark mode since our CSS themes are always dark. -->
<style name="Theme.app" parent="Theme.MaterialComponents.NoActionBar">
<item name="android:statusBarColor">@android:color/transparent</item>
<item name="android:navigationBarColor">@android:color/transparent</item>
<!-- Force light status bar icons (white) against our dark background -->
<item name="android:windowLightStatusBar">false</item>
<item name="android:windowLightNavigationBar">false</item>
</style>
</resources>
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="purple_200">#FFBB86FC</color>
<color name="purple_500">#FF6200EE</color>
<color name="purple_700">#FF3700B3</color>
<color name="teal_200">#FF03DAC5</color>
<color name="teal_700">#FF018786</color>
<color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</color>
</resources>
@@ -0,0 +1,4 @@
<resources>
<string name="app_name">"dumpsterChat"</string>
<string name="main_activity_title">"dumpsterChat"</string>
</resources>
@@ -0,0 +1,10 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<!-- Base application theme. Forces dark mode since our CSS themes are always dark. -->
<style name="Theme.app" parent="Theme.MaterialComponents.NoActionBar">
<item name="android:statusBarColor">@android:color/transparent</item>
<item name="android:navigationBarColor">@android:color/transparent</item>
<!-- Force light status bar icons (white) against our dark background -->
<item name="android:windowLightStatusBar">false</item>
<item name="android:windowLightNavigationBar">false</item>
</style>
</resources>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<external-path name="my_images" path="." />
<cache-path name="my_cache_images" path="." />
</paths>
@@ -0,0 +1,22 @@
buildscript {
repositories {
google()
mavenCentral()
}
dependencies {
classpath("com.android.tools.build:gradle:8.11.0")
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:1.9.25")
}
}
allprojects {
repositories {
google()
mavenCentral()
}
}
tasks.register("clean").configure {
delete("build")
}
@@ -0,0 +1,23 @@
plugins {
`kotlin-dsl`
}
gradlePlugin {
plugins {
create("pluginsForCoolKids") {
id = "rust"
implementationClass = "RustPlugin"
}
}
}
repositories {
google()
mavenCentral()
}
dependencies {
compileOnly(gradleApi())
implementation("com.android.tools.build:gradle:8.11.0")
}
@@ -0,0 +1,68 @@
import java.io.File
import org.apache.tools.ant.taskdefs.condition.Os
import org.gradle.api.DefaultTask
import org.gradle.api.GradleException
import org.gradle.api.logging.LogLevel
import org.gradle.api.tasks.Input
import org.gradle.api.tasks.TaskAction
open class BuildTask : DefaultTask() {
@Input
var rootDirRel: String? = null
@Input
var target: String? = null
@Input
var release: Boolean? = null
@TaskAction
fun assemble() {
val executable = """npm""";
try {
runTauriCli(executable)
} catch (e: Exception) {
if (Os.isFamily(Os.FAMILY_WINDOWS)) {
// Try different Windows-specific extensions
val fallbacks = listOf(
"$executable.exe",
"$executable.cmd",
"$executable.bat",
)
var lastException: Exception = e
for (fallback in fallbacks) {
try {
runTauriCli(fallback)
return
} catch (fallbackException: Exception) {
lastException = fallbackException
}
}
throw lastException
} else {
throw e;
}
}
}
fun runTauriCli(executable: String) {
val rootDirRel = rootDirRel ?: throw GradleException("rootDirRel cannot be null")
val target = target ?: throw GradleException("target cannot be null")
val release = release ?: throw GradleException("release cannot be null")
val args = listOf("run", "--", "tauri", "android", "android-studio-script");
project.exec {
workingDir(File(project.projectDir, rootDirRel))
executable(executable)
args(args)
if (project.logger.isEnabled(LogLevel.DEBUG)) {
args("-vv")
} else if (project.logger.isEnabled(LogLevel.INFO)) {
args("-v")
}
if (release) {
args("--release")
}
args(listOf("--target", target))
}.assertNormalExitValue()
}
}
@@ -0,0 +1,85 @@
import com.android.build.api.dsl.ApplicationExtension
import org.gradle.api.DefaultTask
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.kotlin.dsl.configure
import org.gradle.kotlin.dsl.get
const val TASK_GROUP = "rust"
open class Config {
lateinit var rootDirRel: String
}
open class RustPlugin : Plugin<Project> {
private lateinit var config: Config
override fun apply(project: Project) = with(project) {
config = extensions.create("rust", Config::class.java)
val defaultAbiList = listOf("arm64-v8a", "armeabi-v7a", "x86", "x86_64");
val abiList = (findProperty("abiList") as? String)?.split(',') ?: defaultAbiList
val defaultArchList = listOf("arm64", "arm", "x86", "x86_64");
val archList = (findProperty("archList") as? String)?.split(',') ?: defaultArchList
val targetsList = (findProperty("targetList") as? String)?.split(',') ?: listOf("aarch64", "armv7", "i686", "x86_64")
extensions.configure<ApplicationExtension> {
@Suppress("UnstableApiUsage")
flavorDimensions.add("abi")
productFlavors {
create("universal") {
dimension = "abi"
ndk {
abiFilters += abiList
}
}
defaultArchList.forEachIndexed { index, arch ->
create(arch) {
dimension = "abi"
ndk {
abiFilters.add(defaultAbiList[index])
}
}
}
}
}
afterEvaluate {
for (profile in listOf("debug", "release")) {
val profileCapitalized = profile.replaceFirstChar { it.uppercase() }
val buildTask = tasks.maybeCreate(
"rustBuildUniversal$profileCapitalized",
DefaultTask::class.java
).apply {
group = TASK_GROUP
description = "Build dynamic library in $profile mode for all targets"
}
tasks["mergeUniversal${profileCapitalized}JniLibFolders"].dependsOn(buildTask)
for (targetPair in targetsList.withIndex()) {
val targetName = targetPair.value
val targetArch = archList[targetPair.index]
val targetArchCapitalized = targetArch.replaceFirstChar { it.uppercase() }
val targetBuildTask = project.tasks.maybeCreate(
"rustBuild$targetArchCapitalized$profileCapitalized",
BuildTask::class.java
).apply {
group = TASK_GROUP
description = "Build dynamic library in $profile mode for $targetArch"
rootDirRel = config.rootDirRel
target = targetName
release = profile == "release"
}
buildTask.dependsOn(targetBuildTask)
tasks["merge$targetArchCapitalized${profileCapitalized}JniLibFolders"].dependsOn(
targetBuildTask
)
}
}
}
}
}
@@ -0,0 +1,24 @@
# Project-wide Gradle settings.
# IDE (e.g. Android Studio) users:
# Gradle settings configured through the IDE *will override*
# any settings specified in this file.
# For more details on how to configure your build environment visit
# http://www.gradle.org/docs/current/userguide/build_environment.html
# Specifies the JVM arguments used for the daemon process.
# The setting is particularly useful for tweaking memory settings.
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
# When configured, Gradle will run in incubating parallel mode.
# This option should only be used with decoupled projects. More details, visit
# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
# org.gradle.parallel=true
# AndroidX package structure to make it clearer which packages are bundled with the
# Android operating system, and which are packaged with your app"s APK
# https://developer.android.com/topic/libraries/support-library/androidx-rn
android.useAndroidX=true
# Kotlin code style for this project: "official" or "obsolete":
kotlin.code.style=official
# Enables namespacing of each library's R class so that its R class includes only the
# resources declared in the library itself and none from the library's dependencies,
# thereby reducing the size of the R class for that library
android.nonTransitiveRClass=true
android.nonFinalResIds=false
Binary file not shown.
@@ -0,0 +1,6 @@
#Tue May 10 19:22:52 CST 2022
distributionBase=GRADLE_USER_HOME
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip
distributionPath=wrapper/dists
zipStorePath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
+185
View File
@@ -0,0 +1,185 @@
#!/usr/bin/env sh
#
# Copyright 2015 the original author or authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
##############################################################################
##
## Gradle start up script for UN*X
##
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
PRG="$0"
# Need this for relative symlinks.
while [ -h "$PRG" ] ; do
ls=`ls -ld "$PRG"`
link=`expr "$ls" : '.*-> \(.*\)$'`
if expr "$link" : '/.*' > /dev/null; then
PRG="$link"
else
PRG=`dirname "$PRG"`"/$link"
fi
done
SAVED="`pwd`"
cd "`dirname \"$PRG\"`/" >/dev/null
APP_HOME="`pwd -P`"
cd "$SAVED" >/dev/null
APP_NAME="Gradle"
APP_BASE_NAME=`basename "$0"`
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD="maximum"
warn () {
echo "$*"
}
die () {
echo
echo "$*"
echo
exit 1
}
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "`uname`" in
CYGWIN* )
cygwin=true
;;
Darwin* )
darwin=true
;;
MINGW* )
msys=true
;;
NONSTOP* )
nonstop=true
;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD="$JAVA_HOME/jre/sh/java"
else
JAVACMD="$JAVA_HOME/bin/java"
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD="java"
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
# Increase the maximum file descriptors if we can.
if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
MAX_FD_LIMIT=`ulimit -H -n`
if [ $? -eq 0 ] ; then
if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
MAX_FD="$MAX_FD_LIMIT"
fi
ulimit -n $MAX_FD
if [ $? -ne 0 ] ; then
warn "Could not set maximum file descriptor limit: $MAX_FD"
fi
else
warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
fi
fi
# For Darwin, add options to specify how the application appears in the dock
if $darwin; then
GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
fi
# For Cygwin or MSYS, switch paths to Windows format before running java
if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then
APP_HOME=`cygpath --path --mixed "$APP_HOME"`
CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
JAVACMD=`cygpath --unix "$JAVACMD"`
# We build the pattern for arguments to be converted via cygpath
ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
SEP=""
for dir in $ROOTDIRSRAW ; do
ROOTDIRS="$ROOTDIRS$SEP$dir"
SEP="|"
done
OURCYGPATTERN="(^($ROOTDIRS))"
# Add a user-defined pattern to the cygpath arguments
if [ "$GRADLE_CYGPATTERN" != "" ] ; then
OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
fi
# Now convert the arguments - kludge to limit ourselves to /bin/sh
i=0
for arg in "$@" ; do
CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
else
eval `echo args$i`="\"$arg\""
fi
i=`expr $i + 1`
done
case $i in
0) set -- ;;
1) set -- "$args0" ;;
2) set -- "$args0" "$args1" ;;
3) set -- "$args0" "$args1" "$args2" ;;
4) set -- "$args0" "$args1" "$args2" "$args3" ;;
5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
esac
fi
# Escape application args
save () {
for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
echo " "
}
APP_ARGS=`save "$@"`
# Collect all arguments for the java command, following the shell quoting and substitution rules
eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
exec "$JAVACMD" "$@"
+89
View File
@@ -0,0 +1,89 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@if "%DEBUG%" == "" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%" == "" set DIRNAME=.
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if "%ERRORLEVEL%" == "0" goto execute
echo.
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo.
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
:end
@rem End local scope for the variables with windows NT shell
if "%ERRORLEVEL%"=="0" goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
exit /b 1
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega
Binary file not shown.
@@ -0,0 +1,3 @@
include ':app'
apply from: 'tauri.settings.gradle'
+8
View File
@@ -2,5 +2,13 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() { fn main() {
#[cfg(target_os = "linux")]
{
std::env::set_var("WEBKIT_DISABLE_DMABUF_RENDERER", "1");
std::env::set_var("WEBKIT_DISABLE_SANDBOX_THIS_IS_DANGEROUS", "1");
}
app_lib::run(); app_lib::run();
} }
+2 -2
View File
@@ -1,8 +1,8 @@
{ {
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json", "$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
"productName": "dumpsterChat", "productName": "dumpsterChat",
"version": "0.2.0", "version": "0.2.9",
"identifier": "coffee.dustin.dumpsterchat", "identifier": "coffee.dustin.dumpster",
"build": { "build": {
"frontendDist": "../dist", "frontendDist": "../dist",
"devUrl": "http://localhost:5173", "devUrl": "http://localhost:5173",
+2 -2
View File
@@ -104,7 +104,7 @@ export function Layout() {
<div className="h-full w-full bg-gb-bg text-gb-fg font-mono flex flex-col"> <div className="h-full w-full bg-gb-bg text-gb-fg font-mono flex flex-col">
{/* Outer terminal frame with safe area */} {/* Outer terminal frame with safe area */}
<div className="flex-1 terminal-border bg-gb-bg-h flex flex-col min-h-0" <div className="flex-1 terminal-border bg-gb-bg-h flex flex-col min-h-0"
style={{ padding: 'max(env(safe-area-inset-top), 0.25rem) max(env(safe-area-inset-right), 0) max(env(safe-area-inset-bottom), 0) max(env(safe-area-inset-left), 0)' }}> style={{ padding: 'var(--safe-top, 0.25rem) var(--safe-right, 0) var(--safe-bottom, 0) var(--safe-left, 0)' }}>
{/* Top bar — minimal on mobile */} {/* Top bar — minimal on mobile */}
<div className="flex items-center justify-between px-2 md:px-3 py-1 border-b border-gb-bg-t bg-gb-bg-s gap-1"> <div className="flex items-center justify-between px-2 md:px-3 py-1 border-b border-gb-bg-t bg-gb-bg-s gap-1">
@@ -237,7 +237,7 @@ function MobileBottomNav({
return ( return (
<div className="md:hidden flex items-center bg-gb-bg-h border-t border-gb-bg-t shrink-0" <div className="md:hidden flex items-center bg-gb-bg-h border-t border-gb-bg-t shrink-0"
style={{ paddingBottom: 'env(safe-area-inset-bottom)' }}> style={{ paddingBottom: 'var(--safe-bottom)' }}>
<NavTab active={mobileView === 'sidebar'} onClick={() => onViewChange('sidebar')}> <NavTab active={mobileView === 'sidebar'} onClick={() => onViewChange('sidebar')}>
[SERVERS] [SERVERS]
</NavTab> </NavTab>
+1 -1
View File
@@ -598,7 +598,7 @@ export const MessageInput = forwardRef<HTMLTextAreaElement, MessageInputProps>(f
)} )}
{/* toolbar row */} {/* toolbar row */}
<div className="flex items-center gap-0.5 pt-0.5 border-t border-gb-bg-t"> <div className="flex items-center gap-x-1 md:gap-x-0.5 pt-0.5 border-t border-gb-bg-t">
{/* + file upload */} {/* + file upload */}
<input <input
ref={fileInputRef} ref={fileInputRef}
+1 -1
View File
@@ -47,7 +47,7 @@ export function MobileNav({ activeTab, onTabChange, onToggleDrawer }: MobileNavP
<ThemeToggle /> <ThemeToggle />
</div> </div>
{/* Safe area for phones with bottom notch */} {/* Safe area for phones with bottom notch */}
<div className="h-[env(safe-area-inset-bottom)]" /> <div className="h-[var(--safe-bottom)]" />
</div> </div>
); );
} }
+26 -1
View File
@@ -5,7 +5,7 @@ import { usePushStore } from '../stores/push.ts';
import { ThemeToggle } from './ThemeToggle.tsx'; import { ThemeToggle } from './ThemeToggle.tsx';
export function UserSettings() { export function UserSettings() {
const { user, updateProfile, changePassword, isLoading, error, clearError } = useAuthStore(); const { user, updateProfile, changePassword, deleteAccount, isLoading, error, clearError } = useAuthStore();
const { isSupported, isSubscribed, subscribe, unsubscribe, checkSubscription } = usePushStore(); const { isSupported, isSubscribed, subscribe, unsubscribe, checkSubscription } = usePushStore();
const navigate = useNavigate(); const navigate = useNavigate();
@@ -440,6 +440,31 @@ export function UserSettings() {
</div> </div>
</form> </form>
{/* Account Deletion */}
<div className="mt-6 pt-4 border-t border-gb-bg-t">
<details className="group">
<summary className="text-gb-red font-mono text-xs cursor-pointer hover:text-gb-bright-red select-none">
[DELETE ACCOUNT]
</summary>
<div className="mt-3 p-3 border border-gb-red/40 bg-gb-bg-t/30">
<p className="text-gb-fg-f text-xs font-mono mb-3">
This action is permanent and cannot be undone. All your messages, servers you own, and uploaded files will be deleted.
</p>
<button
type="button"
onClick={async () => {
if (!confirm('Are you sure you want to permanently delete your account? This cannot be undone.')) return;
await deleteAccount();
navigate('/login');
}}
className="terminal-button text-xs border-gb-red text-gb-red hover:bg-gb-red/20"
>
[CONFIRM DELETE ACCOUNT]
</button>
</div>
</details>
</div>
{/* Footer info */} {/* Footer info */}
<div className="mt-6 pt-4 border-t border-gb-bg-t"> <div className="mt-6 pt-4 border-t border-gb-bg-t">
<p className="text-gb-fg-f text-xs font-mono"> <p className="text-gb-fg-f text-xs font-mono">
+13
View File
@@ -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;
} }
+80
View File
@@ -3,6 +3,86 @@ 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) {
// WebView safe-area fallbacks: Android doesn't support CSS env(), and
// Linux WebKitGTK chokes on env() inside var(). Set explicit values via JS.
if (navigator.userAgent.includes('Android')) {
document.documentElement.style.setProperty('--safe-top', '1.5rem');
document.documentElement.style.setProperty('--safe-bottom', '0.75rem');
document.documentElement.style.setProperty('--safe-left', '0px');
document.documentElement.style.setProperty('--safe-right', '0px');
} else {
// Linux / macOS Tauri — no notch, but body has default padding already
document.documentElement.style.setProperty('--safe-top', '0px');
document.documentElement.style.setProperty('--safe-bottom', '0px');
document.documentElement.style.setProperty('--safe-left', '0px');
document.documentElement.style.setProperty('--safe-right', '0px');
}
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;
// ponytail: WebKitGTK may have read-only prototype — guard against throw
try { window.WebSocket.prototype = OriginalWebSocket.prototype; } catch { /* readonly, fine */ }
}
createRoot(document.getElementById('root')!).render( createRoot(document.getElementById('root')!).render(
<StrictMode> <StrictMode>
<App /> <App />
+14 -2
View File
@@ -69,6 +69,7 @@ interface AuthState {
blockUser: (userId: string) => Promise<void>; blockUser: (userId: string) => Promise<void>;
unblockUser: (userId: string) => Promise<void>; unblockUser: (userId: string) => Promise<void>;
clearError: () => void; clearError: () => void;
deleteAccount: () => Promise<void>;
} }
// ponytail: auto-subscribe to push notifications on auth // ponytail: auto-subscribe to push notifications on auth
@@ -151,12 +152,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",
}); });
} }
}, },
@@ -242,4 +250,8 @@ export const useAuthStore = create<AuthState>((set) => ({
unblockUser: async (userId) => { unblockUser: async (userId) => {
await api.delete(`/users/me/blocks/${userId}`); await api.delete(`/users/me/blocks/${userId}`);
}, },
deleteAccount: async () => {
await api.delete("/auth/me");
},
})); }));
+4 -2
View File
@@ -135,12 +135,14 @@ export const useConversationStore = create<ConversationState>((set, get) => ({
}, },
sendMessage: async (conversationId, content) => { sendMessage: async (conversationId, content) => {
// don't append here — WS MESSAGE_CREATE broadcast is the single source of truth. // Add locally so the message appears immediately even if WS lags.
// Appending in both places caused double messages when the WS event arrived before the HTTP response. // addMessage dedupes, so a late WS event won't double it.
const message = await api.post<ConversationMessage>( const message = await api.post<ConversationMessage>(
`/conversations/${conversationId}/messages`, `/conversations/${conversationId}/messages`,
{ content }, { content },
); );
// ponytail: local append as fallback for WS MESSAGE_CREATE; remove if WS reliability improves
get().addMessage(message);
return message; return message;
}, },
+4 -2
View File
@@ -169,12 +169,14 @@ export const useMessageStore = create<MessageState>((set, get) => ({
sendMessage: async (channelId, content, replyTo) => { sendMessage: async (channelId, content, replyTo) => {
const body: { content: string; reply_to?: string } = { content }; const body: { content: string; reply_to?: string } = { content };
if (replyTo) body.reply_to = replyTo; if (replyTo) body.reply_to = replyTo;
// ponytail: don't append here — WS MESSAGE_CREATE broadcast is the single source of truth. // Add locally so the message appears immediately even if WS lags.
// Appending in both places caused double messages when the WS event arrived before the HTTP response. // addMessage dedupes, so a late WS event won't double it.
const message = await api.post<Message>( const message = await api.post<Message>(
`/channels/${channelId}/messages`, `/channels/${channelId}/messages`,
body, body,
); );
// ponytail: local append as fallback for WS MESSAGE_CREATE; remove if WS reliability improves
get().addMessage(message);
return message; return message;
}, },