Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
5ce4e48
chore: remove old tauri plan, start fresh desktop effort
itsmeakhil Jul 9, 2026
69f32a1
feat(desktop): static-export gate for Tauri build
itsmeakhil Jul 9, 2026
cc84787
feat(desktop): Tauri v2 shell, SQLCipher local store, offline auth
itsmeakhil Jul 9, 2026
bc312ab
feat(desktop): all vault tools served offline by local router
itsmeakhil Jul 9, 2026
8655f9e
feat(desktop): remote bridge — cloud auth, shared workspaces, online …
itsmeakhil Jul 9, 2026
153e20c
chore: ignore apps/desktop/node_modules
itsmeakhil Jul 9, 2026
fd4985a
feat(desktop): personal-workspace sync engine with settings toggle
itsmeakhil Jul 9, 2026
4b2425b
feat(desktop): live-DB tools on native Rust drivers
itsmeakhil Jul 9, 2026
1486573
feat(desktop): api-client HTTP engine, SSE relay, offline gitignore
itsmeakhil Jul 9, 2026
a7065d5
chore(desktop): packaging — window-state, dmg via hdiutil, README
itsmeakhil Jul 9, 2026
d6ceaca
feat(desktop): show sign-in screen on launch, browser-based auth
itsmeakhil Jul 10, 2026
2601e5f
fix(desktop): loopback auth callback + hide back-to-home on login
itsmeakhil Jul 10, 2026
7566cd4
feat(desktop): drop 'Continue without signing in' from login
itsmeakhil Jul 10, 2026
68314de
fix(desktop): run sign-in handoff when browser already authed
itsmeakhil Jul 10, 2026
8430816
fix(desktop): refresh backend session before minting desktop token
itsmeakhil Jul 10, 2026
1152408
fix(desktop): keep sign-in button clickable while waiting
itsmeakhil Jul 10, 2026
422d7ce
feat(desktop): 60-day long-lived session for the desktop app
itsmeakhil Jul 10, 2026
70f8625
UI
itsmeakhil Jul 10, 2026
0aa20a5
feat(desktop): offline-first entry + strict-local leaks + preferences…
itsmeakhil Jul 10, 2026
1ca3885
UI
itsmeakhil Jul 10, 2026
8d28914
Desktop app
itsmeakhil Jul 10, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -74,4 +74,6 @@ apps/desktop/src-tauri/target/release/
.claude
/docs

.superpowers
.superpowers/apps/desktop/node_modules
/apps/desktop/src-tauri/target
/apps/desktop/node_modules
11 changes: 3 additions & 8 deletions apps/api-runner/node_modules/.bin/tsc

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

11 changes: 3 additions & 8 deletions apps/api-runner/node_modules/.bin/tsserver

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

47 changes: 43 additions & 4 deletions apps/backend/app/api/routes/auth/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

from app.core.limiter import limiter
from app.core import audit
from app.core.config import get_settings

from app.api.routes.auth.cookie_attach import attach_auth_cookies, clear_auth_cookies
from app.api.routes.auth.schema import (
Expand Down Expand Up @@ -80,8 +81,16 @@ async def create_session(

access = create_access_token(uid)
raw_refresh = new_refresh_token()
await set_refresh_token_hash(uid, hash_refresh_token(raw_refresh))
attach_auth_cookies(response, access, raw_refresh)
settings = get_settings()
refresh_days = (
settings.LONG_LIVED_REFRESH_TOKEN_EXPIRE_DAYS
if payload.long_lived
else settings.REFRESH_TOKEN_EXPIRE_DAYS
)
await set_refresh_token_hash(
uid, hash_refresh_token(raw_refresh), long_lived=payload.long_lived
)
attach_auth_cookies(response, access, raw_refresh, refresh_days=refresh_days)

doc = await get_user_doc(uid)
if not doc:
Expand Down Expand Up @@ -132,10 +141,20 @@ async def refresh_session(
detail="Invalid refresh token.",
)

# Preserve the session's long-lived flag across rotation so desktop
# sessions keep their 60-day cookie TTL on every refresh.
user_doc = await get_user_doc(uid)
long_lived = bool(user_doc.get("refresh_long_lived", False)) if user_doc else False
settings = get_settings()
refresh_days = (
settings.LONG_LIVED_REFRESH_TOKEN_EXPIRE_DAYS
if long_lived
else settings.REFRESH_TOKEN_EXPIRE_DAYS
)
new_raw = new_refresh_token()
await set_refresh_token_hash(uid, hash_refresh_token(new_raw))
await set_refresh_token_hash(uid, hash_refresh_token(new_raw), long_lived=long_lived)
access = create_access_token(uid)
attach_auth_cookies(response, access, new_raw)
attach_auth_cookies(response, access, new_raw, refresh_days=refresh_days)
audit.set_action("auth.token_refresh")
audit.set_entity("user", uid)
audit.set_summary("Refreshed session")
Expand Down Expand Up @@ -300,6 +319,26 @@ async def session_check(_uid: Annotated[str, Depends(get_current_uid)]) -> OkRes
return OkResponse(ok=True)


@router.post(
"/desktop-token",
summary="Mint a Firebase custom token for the desktop-app sign-in handoff",
)
async def desktop_token(uid: Annotated[str, Depends(get_current_uid)]) -> dict:
# The web session (browser) mints a short-lived Firebase custom token which
# is handed to the desktop app via the mydevtools:// deep link; the app
# signs in with it and runs the normal session exchange.
from app.core.firebase import get_firebase_app

try:
from firebase_admin import auth as firebase_auth
except Exception: # pragma: no cover - firebase_admin always present in prod
raise HTTPException(status_code=503, detail="Firebase unavailable")
get_firebase_app()
token_bytes = firebase_auth.create_custom_token(uid)
token = token_bytes.decode("utf-8") if isinstance(token_bytes, bytes) else token_bytes
return {"token": token}


# ── Master-password vault ─────────────────────────────────────────────────────


Expand Down
10 changes: 8 additions & 2 deletions apps/backend/app/api/routes/auth/cookie_attach.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,16 @@
from app.core.config import get_settings


def attach_auth_cookies(response: Response, access: str, refresh_plain: str) -> None:
def attach_auth_cookies(
response: Response,
access: str,
refresh_plain: str,
refresh_days: int | None = None,
) -> None:
settings = get_settings()
access_max = settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60
refresh_max = settings.REFRESH_TOKEN_EXPIRE_DAYS * 24 * 3600
days = refresh_days if refresh_days is not None else settings.REFRESH_TOKEN_EXPIRE_DAYS
refresh_max = days * 24 * 3600
common: dict = {
"httponly": True,
"secure": settings.AUTH_COOKIE_SECURE,
Expand Down
2 changes: 2 additions & 0 deletions apps/backend/app/api/routes/auth/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ class SessionRequest(BaseModel):

id_token: str = Field(min_length=1)
check_revoked: bool = True
# Desktop app requests a long-lived (60-day) refresh cookie.
long_lived: bool = False


class SocialLinks(BaseModel):
Expand Down
12 changes: 10 additions & 2 deletions apps/backend/app/api/routes/auth/users_repo.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,12 +83,20 @@ async def update_user_profile(uid: str, updates: dict[str, Any]) -> None:
await bump_version(ns="auth_user", uid=uid)


async def set_refresh_token_hash(uid: str, token_hash: str) -> None:
async def set_refresh_token_hash(
uid: str, token_hash: str, long_lived: bool = False
) -> None:
now = create_timestamp()
await db_manager.update_one(
USERS,
{"_id": uid},
{"$set": {"refresh_token_hash": token_hash, "updated_at": now}},
{
"$set": {
"refresh_token_hash": token_hash,
"refresh_long_lived": long_lived,
"updated_at": now,
}
},
)
await bump_version(ns="auth_user", uid=uid)

Expand Down
1 change: 1 addition & 0 deletions apps/backend/app/api/routes/nosql/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,3 +37,4 @@ class ConnectionOut(BaseModel):
name: str
createdAt: int
lastUsedAt: int
updatedAt: int
9 changes: 8 additions & 1 deletion apps/backend/app/api/routes/nosql/services.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@
def _doc_to_out(doc: dict[str, Any], *, connection_id: str) -> ConnectionOut:
created_at = int(doc.get("createdAt", 0)) or create_timestamp()
last_used_at = int(doc.get("lastUsedAt", 0)) or created_at
# Content-edit clock for sync LWW. Falls back to lastUsedAt for legacy docs
# written before updatedAt existed.
updated_at = int(doc.get("updatedAt", 0)) or last_used_at
return ConnectionOut(
id=connection_id,
userId=str(doc.get("created_by", "")),
Expand All @@ -25,6 +28,7 @@ def _doc_to_out(doc: dict[str, Any], *, connection_id: str) -> ConnectionOut:
name=str(doc.get("name", "")),
createdAt=created_at,
lastUsedAt=last_used_at,
updatedAt=updated_at,
)


Expand Down Expand Up @@ -52,6 +56,7 @@ async def create_connection(ctx: WorkspaceContext, body: ConnectionCreate) -> Co
"name": body.name or "My Connection",
"createdAt": ts,
"lastUsedAt": ts,
"updatedAt": ts,
}
try:
await db_manager.insert_one(NOSQL_CONNECTIONS, doc)
Expand All @@ -69,7 +74,9 @@ async def update_connection(ctx: WorkspaceContext, connection_id: str, body: Con
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Connection not found.")

ts = create_timestamp()
patch: dict[str, Any] = {"lastUsedAt": ts}
# updatedAt is the sync LWW clock — it advances on every content edit (unlike
# lastUsedAt, which also moves on a bare connect/touch).
patch: dict[str, Any] = {"lastUsedAt": ts, "updatedAt": ts}
if body.encryptedData is not None:
patch["encryptedData"] = body.encryptedData
if body.iv is not None:
Expand Down
1 change: 1 addition & 0 deletions apps/backend/app/api/routes/redis_commander/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,3 +39,4 @@ class RedisConnectionOut(BaseModel):
name: str
createdAt: int
lastUsedAt: int
updatedAt: int
9 changes: 8 additions & 1 deletion apps/backend/app/api/routes/redis_commander/services.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@
def _doc_to_out(doc: dict[str, Any], *, connection_id: str) -> RedisConnectionOut:
created_at = int(doc.get("createdAt", 0)) or create_timestamp()
last_used_at = int(doc.get("lastUsedAt", 0)) or created_at
# Content-edit clock for sync LWW. Falls back to lastUsedAt for legacy docs
# written before updatedAt existed.
updated_at = int(doc.get("updatedAt", 0)) or last_used_at
return RedisConnectionOut(
id=connection_id,
userId=str(doc.get("created_by", "")),
Expand All @@ -29,6 +32,7 @@ def _doc_to_out(doc: dict[str, Any], *, connection_id: str) -> RedisConnectionOu
name=str(doc.get("name", "")),
createdAt=created_at,
lastUsedAt=last_used_at,
updatedAt=updated_at,
)


Expand Down Expand Up @@ -56,6 +60,7 @@ async def create_connection(ctx: WorkspaceContext, body: RedisConnectionCreate)
"name": body.name or "My Redis Connection",
"createdAt": ts,
"lastUsedAt": ts,
"updatedAt": ts,
}
try:
await db_manager.insert_one(REDIS_CONNECTIONS, doc)
Expand All @@ -74,7 +79,9 @@ async def update_connection(ctx: WorkspaceContext, connection_id: str, body: Red
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Connection not found.")

ts = create_timestamp()
patch: dict[str, Any] = {"lastUsedAt": ts}
# updatedAt is the sync LWW clock — it advances on every content edit (unlike
# lastUsedAt, which also moves on a bare connect/touch).
patch: dict[str, Any] = {"lastUsedAt": ts, "updatedAt": ts}
if body.encryptedData is not None:
patch["encryptedData"] = body.encryptedData
if body.iv is not None:
Expand Down
1 change: 1 addition & 0 deletions apps/backend/app/api/routes/sql_client/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,3 +45,4 @@ class SqlConnectionOut(BaseModel):
type: DbType
createdAt: int
lastUsedAt: int
updatedAt: int
9 changes: 8 additions & 1 deletion apps/backend/app/api/routes/sql_client/services.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@
def _doc_to_out(doc: dict[str, Any], *, connection_id: str) -> SqlConnectionOut:
created_at = int(doc.get("createdAt", 0)) or create_timestamp()
last_used_at = int(doc.get("lastUsedAt", 0)) or created_at
# Content-edit clock for sync LWW. Falls back to lastUsedAt for legacy docs
# written before updatedAt existed.
updated_at = int(doc.get("updatedAt", 0)) or last_used_at
return SqlConnectionOut(
id=connection_id,
userId=str(doc.get("created_by", "")),
Expand All @@ -30,6 +33,7 @@ def _doc_to_out(doc: dict[str, Any], *, connection_id: str) -> SqlConnectionOut:
type=doc.get("type", "postgresql"),
createdAt=created_at,
lastUsedAt=last_used_at,
updatedAt=updated_at,
)


Expand Down Expand Up @@ -58,6 +62,7 @@ async def create_connection(ctx: WorkspaceContext, body: SqlConnectionCreate) ->
"type": body.type,
"createdAt": ts,
"lastUsedAt": ts,
"updatedAt": ts,
}
try:
await db_manager.insert_one(SQL_CONNECTIONS, doc)
Expand All @@ -75,7 +80,9 @@ async def update_connection(ctx: WorkspaceContext, connection_id: str, body: Sql
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Connection not found.")

ts = create_timestamp()
patch: dict[str, Any] = {"lastUsedAt": ts}
# updatedAt is the sync LWW clock — it advances on every content edit (unlike
# lastUsedAt, which also moves on a bare connect/touch).
patch: dict[str, Any] = {"lastUsedAt": ts, "updatedAt": ts}
if body.encryptedData is not None:
patch["encryptedData"] = body.encryptedData
if body.iv is not None:
Expand Down
5 changes: 4 additions & 1 deletion apps/backend/app/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,10 @@ class Settings(BaseSettings):
)
JWT_ALGORITHM: str = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES: int
REFRESH_TOKEN_EXPIRE_DAYS: int
REFRESH_TOKEN_EXPIRE_DAYS: int
# Long-lived sessions (desktop app): refresh cookie TTL when the client
# requests `long_lived`. Decoupled from the web REFRESH_TOKEN_EXPIRE_DAYS.
LONG_LIVED_REFRESH_TOKEN_EXPIRE_DAYS: int = 60
# HttpOnly cookies: use Secure=true over HTTPS (recommended in production).
AUTH_COOKIE_SECURE: bool = False

Expand Down
66 changes: 66 additions & 0 deletions apps/desktop/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
# MyDevTools Desktop (macOS, Tauri v2)

Offline-first desktop app. All 57 tools work; **all data lives on this Mac**
(SQLCipher database keyed by a macOS Keychain device key) unless you turn on
Cloud Sync for your personal workspace. Shared/team workspaces are always
cloud-backed and require sign-in.

## Architecture

- **Frontend**: the Next.js web app, statically exported by
`apps/web/scripts/build-tauri.mjs` (exclusion build — API routes,
middleware, and dynamic marketing pages are moved aside during the build).
- **Local data**: single `local_api(method, path, body)` Rust command →
`router/` dispatcher mirroring the FastAPI `/api/v1/*` JSON contracts →
SQLCipher (`entries` table stores opaque docs; E2E crypto stays in the
webview — Rust never sees plaintext vault data).
- **Cloud**: `remote_api` command (reqwest + persistent cookie jar in
SQLCipher) calls FastAPI directly — no CORS, no backend changes. Cloud
sign-in happens in the system browser (`/login?desktop=1`) and hands a
Firebase custom token back via the `mydevtools://auth` deep link.
- **Sync**: TS engine (`apps/web/src/lib/desktop/sync-engine.ts`) reconciles
the local personal workspace against the remote one — push dirty rows
first, then pull with last-write-wins on `updatedAt`. Toggle in Settings.
- **Live DB tools**: native Rust drivers (tokio-postgres, mysql_async,
mongodb, redis) behind the same JSON contracts as the web API routes.
- **api-client**: `http_request` / `http_request_stream` commands (reqwest)
replace the `/api/proxy` and `/api/proxy-stream` routes.

## Dev workflow

```sh
nvm use 20.19.5 # Node >= 20.19 required (older 20.x breaks the export)
pnpm dev:desktop # tauri dev against next dev :3000
pnpm build:desktop # static export → release build → .app + .dmg
```

Artifacts land in `apps/desktop/src-tauri/target/release/bundle/`.

The `.dmg` is unsigned: on first launch, **right-click the app → Open →
Open** to pass Gatekeeper (or `xattr -dr com.apple.quarantine
/Applications/MyDevTools.app`).

Rust tests: `cargo test` (unit) and `cargo test -- --ignored` (integration —
needs local `mongod` on 27017, `redis-server` on 6379, and network for the
httpbin proxy test).

## Not in v1 (planned v1.1)

- gRPC / NTLM / SPNEGO proxies and the mock server (api-client advanced)
- Redis MONITOR + pub/sub live subscribe (SSE panes show an error)
- CSP hardening in `tauri.conf.json` (currently null)
- Signing/notarization, auto-update, user-preferences sync

## Regression matrix

Offline pass (Wi-Fi off): 41 pure-client tools · master-vault
create/unlock/relaunch · password-manager, api-keys, environment-manager,
snippets, bookmarks, notes (text), to-do, api-client
collections/history/environments, DB connection vaults · gitignore-generator
(bundled templates) · sql-client/database-explorer/redis-commander against
local servers.

Online pass: cloud sign-in via browser handoff · session survives relaunch ·
shared workspace round-trip with web · sync toggle on → offline edits appear
on web, web edits propagate back, deletes propagate both ways · online-only
gates (s3-drive, url-shortener, dns-lookup, email-validator) unlock.
Loading