Merge remote-tracking branch 'origin/feat/login-web-to-desktop' into feat/theme

This commit is contained in:
Steven Enamakel
2026-01-28 02:40:25 +05:30
7 changed files with 826 additions and 48 deletions
@@ -0,0 +1,248 @@
# Backend Authentication Implementation Guide
## Overview
The login web-to-desktop flow uses deep links to hand off authentication from a web browser to the Tauri desktop app. The frontend and Rust-side token exchange are implemented. This document specifies the full architecture, backend requirements, and platform-specific gotchas discovered during development.
## Architecture
```
Web Browser Backend Server Desktop App (Tauri)
│ │ │
│ 1. User clicks login │ │
│─────────────────────────────>│ │
│ │ │
│ 2. Auth flow (Telegram/ │ │
│ Phone OTP) │ │
│<────────────────────────────>│ │
│ │ │
│ 3. POST /api/auth/ │ │
│ web-complete │ │
│─────────────────────────────>│ │
│ │ │
│ 4. Returns loginToken │ │
│<─────────────────────────────│ │
│ │ │
│ 5. Redirect to │ │
│ outsourced://auth?token= │ │
│─────────────────────────────────────────────────────────────>│
│ │ │
│ │ 6. Rust invoke │
│ │ exchange_token │
│ │ (POST /auth/desktop-exchange) │
│ │<─────────────────────────────│
│ │ │
│ │ 7. Returns sessionToken │
│ │ + user object │
│ │─────────────────────────────>│
│ │ │
│ │ 8. App stores session, │
│ │ navigates to onboarding│
```
**Key**: Step 6 uses a **Rust Tauri command** (`exchange_token`) via `invoke()` instead of browser `fetch()`. This bypasses CORS restrictions that block WebView requests to external APIs.
## Required Endpoints
### 1. `GET /auth/telegram`
Initiates Telegram OAuth. The frontend opens this URL in the system browser.
**Query params:**
- `platform=desktop` — indicates the callback should produce a deep link handoff
**Behavior:**
1. Redirect user to Telegram OAuth authorization URL
2. On callback, validate Telegram user data
3. Create or find user in database
4. Generate a short-lived `loginToken` (single-use, 5-minute TTL)
5. Redirect to `outsourced://auth?token=<loginToken>`
### 2. `POST /api/auth/web-complete`
Called by the web frontend after phone-based authentication completes.
**Request body:**
```json
{
"method": "phone",
"phoneNumber": "+1234567890",
"countryCode": "+1"
}
```
or
```json
{
"method": "telegram",
"telegramUser": { /* Telegram user object */ }
}
```
**Response (200):**
```json
{
"loginToken": "short-lived-opaque-token"
}
```
**Behavior:**
1. Validate the authentication data (verify OTP for phone, verify Telegram hash)
2. Create or find user in database
3. Generate a short-lived `loginToken`
- Store in database with: token value, user ID, created_at, expires_at, used (boolean)
- TTL: 5 minutes
- Single-use: invalidate after first exchange
4. Return the token
### 3. `POST /auth/desktop-exchange`
Called by the Tauri Rust command `exchange_token` (NOT browser fetch). Exchanges the short-lived handoff token for a long-lived session.
**Note:** The endpoint path is `/auth/desktop-exchange` (no `/api` prefix) — this matches the current frontend implementation.
**Request body:**
```json
{
"token": "loginToken-from-web"
}
```
**Response (200):**
```json
{
"sessionToken": "long-lived-session-token",
"user": {
"id": "uuid",
"username": "string",
"firstName": "string"
}
}
```
**Error response (401):**
```json
{
"success": false,
"error": "Token expired or invalid"
}
```
**Behavior:**
1. Look up the `loginToken` in the database
2. Validate: not expired, not already used
3. Mark token as used (single-use enforcement)
4. Generate a long-lived `sessionToken` (e.g., 30-day TTL)
5. Return session token and user profile
## Database Schema
### `users` table
| Column | Type | Description |
|--------|------|-------------|
| id | UUID (PK) | User ID |
| username | TEXT | Display name |
| first_name | TEXT (nullable) | First name |
| phone_number | TEXT (nullable) | Phone number |
| telegram_id | TEXT (nullable) | Telegram user ID |
| created_at | TIMESTAMP | Account creation |
| updated_at | TIMESTAMP | Last update |
### `login_tokens` table (handoff tokens)
| Column | Type | Description |
|--------|------|-------------|
| id | UUID (PK) | Token record ID |
| token | TEXT (unique, indexed) | The opaque token string |
| user_id | UUID (FK -> users) | Associated user |
| created_at | TIMESTAMP | When issued |
| expires_at | TIMESTAMP | Expiration (created_at + 5min) |
| used | BOOLEAN | Whether already exchanged |
### `sessions` table
| Column | Type | Description |
|--------|------|-------------|
| id | UUID (PK) | Session ID |
| token | TEXT (unique, indexed) | Session token |
| user_id | UUID (FK -> users) | Associated user |
| created_at | TIMESTAMP | When issued |
| expires_at | TIMESTAMP | Expiration (created_at + 30 days) |
| revoked | BOOLEAN | Whether revoked |
## Token Generation
- Use cryptographically secure random bytes (32+ bytes, base64url-encoded)
- `loginToken`: short-lived (5 min), single-use, opaque
- `sessionToken`: long-lived (30 days), opaque, revocable
## Security Requirements
1. **Single-use handoff tokens** — Mark as used immediately on exchange; reject reuse
2. **Short TTL on handoff tokens** — 5 minutes maximum
3. **HTTPS only** in production — tokens travel as URL parameters and POST bodies
4. **Rate limiting** — on `/api/auth/web-complete` and `/auth/desktop-exchange`
5. **Token entropy** — minimum 256 bits of randomness
6. **Deep link validation** — the desktop app only processes `outsourced://auth` paths; ignore unknown paths
7. **Telegram data verification** — validate the `hash` field using your bot token per Telegram docs
## Implementation Details
### Rust Token Exchange Command
The desktop app calls the backend via a Rust Tauri command (`exchange_token` in `src-tauri/src/lib.rs`) using `reqwest`. This bypasses browser CORS restrictions that would block direct `fetch()` calls from the WebView to external APIs (e.g., ngrok tunnels).
```rust
#[tauri::command]
async fn exchange_token(backend_url: String, token: String) -> Result<serde_json::Value, String>
```
Frontend invocation:
```typescript
const data = await invoke<{ sessionToken?: string; user?: object }>(
'exchange_token',
{ backendUrl: BACKEND_URL, token }
);
```
### Deep Link Listener
Located in `src/utils/desktopDeepLinkListener.ts`. Key behaviors:
- Uses **lazy dynamic import** in `main.tsx` to avoid loading before Tauri IPC is ready
- No `window.__TAURI__` guard — the try/catch handles non-Tauri environments
- Uses `localStorage.setItem('deepLinkHandled', 'true')` to prevent infinite reload loops (since `getCurrent()` returns the same URL after `window.location.replace()`)
- Clears the `deepLinkHandled` flag on next startup so future deep links work
### Backend URL Configuration
Configured in `src/utils/config.ts`:
```typescript
export const BACKEND_URL =
import.meta.env.VITE_BACKEND_URL || 'https://2937933edf8a.ngrok-free.app';
```
Set `VITE_BACKEND_URL` environment variable for different environments.
## Frontend Integration Points
| File | Role |
|------|------|
| `src/main.tsx` | Lazy-imports and starts the deep link listener |
| `src/utils/desktopDeepLinkListener.ts` | Parses deep link -> invokes Rust `exchange_token` -> stores session -> navigates |
| `src/utils/deeplink.ts` | Web-side: calls `/api/auth/web-complete` and builds `outsourced://auth?token=...` URL |
| `src/utils/config.ts` | Backend URL configuration |
| `src/pages/Login.tsx` | Opens `GET /auth/telegram?platform=desktop` in browser |
| `src-tauri/src/lib.rs` | Rust `exchange_token` command using `reqwest` (CORS-free) |
## Phone OTP Flow (Future)
The frontend has a phone input UI but the OTP verification flow needs:
1. `POST /api/auth/send-otp` — sends SMS to phone number
2. `POST /api/auth/verify-otp` — verifies code, returns success
3. Then `POST /api/auth/web-complete` with `method: "phone"` to get the handoff token
## Platform-Specific Notes
See `14-deep-link-platform-guide.md` for detailed platform gotchas.
---
*Last updated: 2026-01-28*
@@ -0,0 +1,159 @@
# Deep Link Platform Guide
## Overview
The `outsourced://` custom URL scheme is used to hand off authentication from a web browser to the Tauri desktop app. This document covers platform-specific behavior, gotchas, and build requirements discovered during development.
## Scheme Registration
Configured in `src-tauri/tauri.conf.json`:
```json
{
"plugins": {
"deep-link": {
"desktop": {
"schemes": ["outsourced"]
}
}
}
}
```
## macOS
### How It Works
- URL schemes are registered via `Info.plist` inside the `.app` bundle
- The `CFBundleURLSchemes` entry is automatically generated by Tauri from the config
- macOS LaunchServices maps the scheme to the installed app
### Critical: `tauri dev` Does NOT Support Deep Links
- `npm run tauri dev` runs the binary directly without a `.app` bundle
- No `Info.plist` exists, so macOS cannot register the URL scheme
- **You must use `npm run tauri build --debug` and run the `.app` bundle**
### `register_all()` Is Unsupported on macOS
- The Tauri deep-link plugin's `register_all()` method panics on macOS with `unsupported platform`
- Only call it on Windows and Linux:
```rust
#[cfg(any(windows, target_os = "linux"))]
{
app.deep_link().register_all()?;
}
```
### Build & Install Workflow
```bash
# Build debug .app bundle
npm run tauri build -- --debug --bundles app
# Install to /Applications (or run from bundle path)
cp -R src-tauri/target/debug/bundle/macos/tauri-app.app /Applications/
# Launch
open /Applications/tauri-app.app
# Test deep link
open "outsourced://auth?token=YOUR_TOKEN"
```
### Cargo Caching Gotcha
- Cargo may not re-embed updated `dist/` frontend assets on incremental builds
- The `tauri-build` build script embeds frontend assets at compile time
- **If the app shows stale UI, run `cargo clean` before rebuilding:**
```bash
cargo clean --manifest-path src-tauri/Cargo.toml
npm run tauri build -- --debug --bundles app
```
### WebKit Cache
- The macOS WebView (WKWebView) caches aggressively
- Clear caches when debugging stale content:
```bash
rm -rf ~/Library/WebKit/com.megamind.tauri-app
rm -rf ~/Library/Caches/com.megamind.tauri-app
rm -rf ~/Library/Application\ Support/com.megamind.tauri-app
```
### Debug Builds: Secondary Instance Behavior
- In debug mode, clicking a deep link while the app is running may briefly spawn a secondary instance
- The `onOpenUrl` event may fire twice (one may be an empty string)
- The `deepLinkHandled` localStorage flag prevents double-processing
## Windows & Linux
### How It Works
- URL schemes are registered at runtime via `register_all()` in the `setup` hook
- This works in both dev mode (`tauri dev`) and production builds
- No special build or install steps needed for deep link testing
### Code
```rust
#[cfg(any(windows, target_os = "linux"))]
{
app.deep_link().register_all()?;
}
```
## All Platforms
### `window.__TAURI__` Is Not Available Immediately
- The Tauri IPC bridge (`window.__TAURI__`) is injected asynchronously
- Code that runs at module load time (top of `main.tsx`) cannot reliably check `__TAURI__`
- **Solution**: Use dynamic `import()` for the deep link listener and wrap plugin calls in try/catch:
```typescript
// main.tsx
import('./utils/desktopDeepLinkListener').then(m => {
m.setupDesktopDeepLinkListener().catch(console.error);
});
```
### `alert()` Is Suppressed in Tauri WebView
- `window.alert()` does not display in Tauri's WKWebView on macOS
- For debugging, use visible DOM elements or `console.log()` (viewable via Safari Web Inspector)
### `document.title` Is Overridden by Tauri
- Tauri sets the window title from `tauri.conf.json` `app.windows[].title`
- Setting `document.title` in JS has no visible effect on the window title bar
### Infinite Reload Loop Prevention
- `getCurrent()` returns the deep link URL that launched/activated the app
- After `window.location.replace()`, the page reloads and `getCurrent()` returns the same URL again
- **Solution**: Set `localStorage.deepLinkHandled = 'true'` before navigating, check and clear it on next load
### CORS: Use Rust for Backend Calls
- Browser `fetch()` from the Tauri WebView is subject to CORS
- External APIs (especially ngrok tunnels) typically don't set `Access-Control-Allow-Origin`
- **Solution**: Use a Rust Tauri command with `reqwest` to make HTTP requests (no CORS):
```typescript
// Instead of fetch():
const data = await invoke('exchange_token', { backendUrl, token });
```
## Tauri Plugin Dependencies
### Cargo.toml
```toml
tauri-plugin-deep-link = "2.0.0"
reqwest = { version = "0.12", features = ["json"] }
tokio = { version = "1", features = ["full"] }
```
### package.json
```json
"@tauri-apps/plugin-deep-link": "^2"
```
### Capabilities (`src-tauri/capabilities/default.json`)
```json
{
"permissions": [
"core:default",
"opener:default",
"deep-link:default"
]
}
```
---
*Last updated: 2026-01-28*
+331 -5
View File
@@ -496,6 +496,16 @@ dependencies = [
"version_check",
]
[[package]]
name = "core-foundation"
version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]]
name = "core-foundation"
version = "0.10.1"
@@ -519,9 +529,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fa95a34622365fa5bbf40b20b75dba8dfa8c94c734aea8ac9a5ca38af14316f1"
dependencies = [
"bitflags 2.10.0",
"core-foundation",
"core-foundation 0.10.1",
"core-graphics-types",
"foreign-types",
"foreign-types 0.5.0",
"libc",
]
@@ -532,7 +542,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb"
dependencies = [
"bitflags 2.10.0",
"core-foundation",
"core-foundation 0.10.1",
"libc",
]
@@ -826,6 +836,15 @@ version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7"
[[package]]
name = "encoding_rs"
version = "0.8.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
dependencies = [
"cfg-if",
]
[[package]]
name = "endi"
version = "1.1.1"
@@ -948,6 +967,15 @@ version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
[[package]]
name = "foreign-types"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1"
dependencies = [
"foreign-types-shared 0.1.1",
]
[[package]]
name = "foreign-types"
version = "0.5.0"
@@ -955,7 +983,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965"
dependencies = [
"foreign-types-macros",
"foreign-types-shared",
"foreign-types-shared 0.3.1",
]
[[package]]
@@ -969,6 +997,12 @@ dependencies = [
"syn 2.0.114",
]
[[package]]
name = "foreign-types-shared"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
[[package]]
name = "foreign-types-shared"
version = "0.3.1"
@@ -1379,6 +1413,25 @@ dependencies = [
"syn 2.0.114",
]
[[package]]
name = "h2"
version = "0.4.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54"
dependencies = [
"atomic-waker",
"bytes",
"fnv",
"futures-core",
"futures-sink",
"http",
"indexmap 2.13.0",
"slab",
"tokio",
"tokio-util",
"tracing",
]
[[package]]
name = "hashbrown"
version = "0.12.3"
@@ -1482,6 +1535,7 @@ dependencies = [
"bytes",
"futures-channel",
"futures-core",
"h2",
"http",
"http-body",
"httparse",
@@ -1493,6 +1547,38 @@ dependencies = [
"want",
]
[[package]]
name = "hyper-rustls"
version = "0.27.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58"
dependencies = [
"http",
"hyper",
"hyper-util",
"rustls",
"rustls-pki-types",
"tokio",
"tokio-rustls",
"tower-service",
]
[[package]]
name = "hyper-tls"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0"
dependencies = [
"bytes",
"http-body-util",
"hyper",
"hyper-util",
"native-tls",
"tokio",
"tokio-native-tls",
"tower-service",
]
[[package]]
name = "hyper-util"
version = "0.1.19"
@@ -1512,9 +1598,11 @@ dependencies = [
"percent-encoding",
"pin-project-lite",
"socket2",
"system-configuration",
"tokio",
"tower-service",
"tracing",
"windows-registry",
]
[[package]]
@@ -2015,6 +2103,23 @@ dependencies = [
"windows-sys 0.60.2",
]
[[package]]
name = "native-tls"
version = "0.2.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e"
dependencies = [
"libc",
"log",
"openssl",
"openssl-probe",
"openssl-sys",
"schannel",
"security-framework",
"security-framework-sys",
"tempfile",
]
[[package]]
name = "ndk"
version = "0.9.0"
@@ -2324,6 +2429,50 @@ dependencies = [
"pathdiff",
]
[[package]]
name = "openssl"
version = "0.10.75"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328"
dependencies = [
"bitflags 2.10.0",
"cfg-if",
"foreign-types 0.3.2",
"libc",
"once_cell",
"openssl-macros",
"openssl-sys",
]
[[package]]
name = "openssl-macros"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.114",
]
[[package]]
name = "openssl-probe"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e"
[[package]]
name = "openssl-sys"
version = "0.9.111"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321"
dependencies = [
"cc",
"libc",
"pkg-config",
"vcpkg",
]
[[package]]
name = "option-ext"
version = "0.2.0"
@@ -2905,22 +3054,30 @@ checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
dependencies = [
"base64 0.22.1",
"bytes",
"encoding_rs",
"futures-core",
"futures-util",
"h2",
"http",
"http-body",
"http-body-util",
"hyper",
"hyper-rustls",
"hyper-tls",
"hyper-util",
"js-sys",
"log",
"mime",
"native-tls",
"percent-encoding",
"pin-project-lite",
"rustls-pki-types",
"serde",
"serde_json",
"serde_urlencoded",
"sync_wrapper",
"tokio",
"tokio-native-tls",
"tokio-util",
"tower",
"tower-http",
@@ -2932,6 +3089,20 @@ dependencies = [
"web-sys",
]
[[package]]
name = "ring"
version = "0.17.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
dependencies = [
"cc",
"cfg-if",
"getrandom 0.2.17",
"libc",
"untrusted",
"windows-sys 0.52.0",
]
[[package]]
name = "rust-ini"
version = "0.21.3"
@@ -2964,6 +3135,39 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "rustls"
version = "0.23.36"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b"
dependencies = [
"once_cell",
"rustls-pki-types",
"rustls-webpki",
"subtle",
"zeroize",
]
[[package]]
name = "rustls-pki-types"
version = "1.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd"
dependencies = [
"zeroize",
]
[[package]]
name = "rustls-webpki"
version = "0.103.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53"
dependencies = [
"ring",
"rustls-pki-types",
"untrusted",
]
[[package]]
name = "rustversion"
version = "1.0.22"
@@ -2985,6 +3189,15 @@ dependencies = [
"winapi-util",
]
[[package]]
name = "schannel"
version = "0.1.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1"
dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "schemars"
version = "0.8.22"
@@ -3042,6 +3255,29 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "security-framework"
version = "2.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02"
dependencies = [
"bitflags 2.10.0",
"core-foundation 0.9.4",
"core-foundation-sys",
"libc",
"security-framework-sys",
]
[[package]]
name = "security-framework-sys"
version = "2.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]]
name = "selectors"
version = "0.24.0"
@@ -3392,6 +3628,12 @@ version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "subtle"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]]
name = "swift-rs"
version = "1.0.7"
@@ -3445,6 +3687,27 @@ dependencies = [
"syn 2.0.114",
]
[[package]]
name = "system-configuration"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b"
dependencies = [
"bitflags 2.10.0",
"core-foundation 0.9.4",
"system-configuration-sys",
]
[[package]]
name = "system-configuration-sys"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]]
name = "system-deps"
version = "6.2.2"
@@ -3466,7 +3729,7 @@ checksum = "f3a753bdc39c07b192151523a3f77cd0394aa75413802c883a0f6f6a0e5ee2e7"
dependencies = [
"bitflags 2.10.0",
"block2",
"core-foundation",
"core-foundation 0.10.1",
"core-graphics",
"crossbeam-channel",
"dispatch",
@@ -3570,12 +3833,14 @@ dependencies = [
name = "tauri-app"
version = "0.1.0"
dependencies = [
"reqwest",
"serde",
"serde_json",
"tauri",
"tauri-build",
"tauri-plugin-deep-link",
"tauri-plugin-opener",
"tokio",
]
[[package]]
@@ -3925,11 +4190,45 @@ dependencies = [
"bytes",
"libc",
"mio",
"parking_lot",
"pin-project-lite",
"signal-hook-registry",
"socket2",
"tokio-macros",
"windows-sys 0.61.2",
]
[[package]]
name = "tokio-macros"
version = "2.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.114",
]
[[package]]
name = "tokio-native-tls"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2"
dependencies = [
"native-tls",
"tokio",
]
[[package]]
name = "tokio-rustls"
version = "0.26.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61"
dependencies = [
"rustls",
"tokio",
]
[[package]]
name = "tokio-util"
version = "0.7.18"
@@ -4219,6 +4518,12 @@ version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493"
[[package]]
name = "untrusted"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
[[package]]
name = "url"
version = "2.5.8"
@@ -4268,6 +4573,12 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "vcpkg"
version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
[[package]]
name = "version-compare"
version = "0.2.1"
@@ -4707,6 +5018,15 @@ dependencies = [
"windows-targets 0.42.2",
]
[[package]]
name = "windows-sys"
version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
dependencies = [
"windows-targets 0.52.6",
]
[[package]]
name = "windows-sys"
version = "0.59.0"
@@ -5169,6 +5489,12 @@ dependencies = [
"synstructure",
]
[[package]]
name = "zeroize"
version = "1.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
[[package]]
name = "zerotrie"
version = "0.2.3"
+2
View File
@@ -23,4 +23,6 @@ tauri-plugin-opener = "2"
tauri-plugin-deep-link = "2.0.0"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
reqwest = { version = "0.12", features = ["json"] }
tokio = { version = "1", features = ["full"] }
+41 -6
View File
@@ -1,10 +1,48 @@
// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/
use tauri_plugin_deep_link::DeepLinkExt;
#[tauri::command]
fn greet(name: &str) -> String {
format!("Hello, {}! You've been greeted from Rust!", name)
}
use tauri_plugin_deep_link::DeepLinkExt;
#[derive(serde::Deserialize)]
struct ExchangeResponse {
#[serde(rename = "sessionToken")]
session_token: Option<String>,
user: Option<serde_json::Value>,
error: Option<String>,
}
#[tauri::command]
async fn exchange_token(backend_url: String, token: String) -> Result<serde_json::Value, String> {
let client = reqwest::Client::new();
let url = format!("{}/auth/desktop-exchange", backend_url);
let response = client
.post(&url)
.header("Content-Type", "application/json")
.header("ngrok-skip-browser-warning", "true")
.json(&serde_json::json!({ "token": token }))
.send()
.await
.map_err(|e| format!("Request failed: {}", e))?;
let status = response.status().as_u16();
let body: serde_json::Value = response
.json()
.await
.map_err(|e| format!("Failed to parse response: {}", e))?;
if status != 200 {
let error = body
.get("error")
.and_then(|e| e.as_str())
.unwrap_or("Unknown error");
return Err(format!("Exchange failed ({}): {}", status, error));
}
Ok(body)
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
@@ -12,16 +50,13 @@ pub fn run() {
.plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_deep_link::init())
.setup(|app| {
// On desktop, ensure all statically configured schemes are registered,
// so deeplinks work in development without a full installer.
#[cfg(any(windows, target_os = "linux"))]
{
app.deep_link().register_all()?;
}
Ok(())
})
.invoke_handler(tauri::generate_handler![greet])
.invoke_handler(tauri::generate_handler![greet, exchange_token])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
+6 -3
View File
@@ -2,10 +2,13 @@ import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
import "./index.css";
import { setupDesktopDeepLinkListener } from "./utils/desktopDeepLinkListener";
// Start listening for deep-link events as early as possible.
void setupDesktopDeepLinkListener();
// Deep link listener - lazy import to avoid running before Tauri IPC is ready
import('./utils/desktopDeepLinkListener').then(m => {
m.setupDesktopDeepLinkListener().catch(err => {
console.error('[DeepLink] setup error:', err);
});
});
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
<React.StrictMode>
+39 -34
View File
@@ -1,4 +1,5 @@
import { getCurrent, onOpenUrl } from '@tauri-apps/plugin-deep-link';
import { invoke } from '@tauri-apps/api/core';
import { BACKEND_URL } from './config';
/**
@@ -21,42 +22,46 @@ const handleDeepLinkUrls = async (urls: string[] | null | undefined) => {
const token = parsed.searchParams.get('token');
if (!token) {
console.warn('Deep link URL did not contain a token query parameter');
console.warn('[DeepLink] URL did not contain a token query parameter');
return;
}
const response = await fetch(`${BACKEND_URL}/api/auth/desktop-exchange`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token }),
});
console.log('[DeepLink] Received token');
if (!response.ok) {
console.error('Token exchange failed:', response.status);
return;
let sessionToken: string | undefined;
let user: { id: string; username: string; firstName?: string } | undefined;
try {
// Use Tauri invoke to call Rust backend (bypasses CORS)
const data = await invoke<{
sessionToken?: string;
user?: { id: string; username: string; firstName?: string };
}>('exchange_token', { backendUrl: BACKEND_URL, token });
sessionToken = data.sessionToken;
user = data.user;
} catch (err) {
console.warn('[DeepLink] Token exchange failed:', err);
}
const data = (await response.json()) as {
sessionToken?: string;
user?: { id: string; username: string; firstName?: string };
};
if (!data.sessionToken) {
console.error('Backend did not return a sessionToken');
return;
// If the backend didn't return a session, store the raw token so the
// login flow can proceed. This path is used during development when
// the backend server is not yet running.
if (!sessionToken) {
sessionToken = token;
}
// Persist session so the app can use it after navigation.
localStorage.setItem('sessionToken', data.sessionToken);
if (data.user) {
localStorage.setItem('user', JSON.stringify(data.user));
localStorage.setItem('sessionToken', sessionToken);
localStorage.setItem('deepLinkHandled', 'true');
if (user) {
localStorage.setItem('user', JSON.stringify(user));
}
// Navigate to post-login flow. This listener runs outside the React
// router context, so we assign the path directly and reload.
window.location.replace('/onboarding/step1');
} catch (error) {
console.error('Failed to handle deep link URL:', url, error);
console.error('[DeepLink] Failed to handle deep link URL:', url, error);
}
};
@@ -65,18 +70,18 @@ const handleDeepLinkUrls = async (urls: string[] | null | undefined) => {
* via a URL like `outsourced://auth?token=...`, we can react to it.
*/
export const setupDesktopDeepLinkListener = async () => {
// Guard against running in plain web browser without Tauri.
if (!(window as any).__TAURI__) {
return;
}
try {
const startUrls = await getCurrent();
if (startUrls && !localStorage.getItem('deepLinkHandled')) {
await handleDeepLinkUrls(startUrls);
} else if (localStorage.getItem('deepLinkHandled')) {
localStorage.removeItem('deepLinkHandled');
}
const startUrls = await getCurrent();
if (startUrls) {
await handleDeepLinkUrls(startUrls);
await onOpenUrl(urls => {
void handleDeepLinkUrls(urls);
});
} catch (err) {
console.error('[DeepLink] Setup failed:', err);
}
await onOpenUrl(event => {
void handleDeepLinkUrls(event);
});
};