Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 68bc1f76f6 | |||
| f1c7501807 | |||
| e16b248366 | |||
| 9b764053c4 | |||
| 950ef5b706 |
@@ -37,6 +37,16 @@ const __dirnameSafe = path.dirname(__filenameSafe);
|
|||||||
const DEV_URL = 'http://localhost:1420';
|
const DEV_URL = 'http://localhost:1420';
|
||||||
const WINDOW_STATE_FILE = 'window-state.json';
|
const WINDOW_STATE_FILE = 'window-state.json';
|
||||||
|
|
||||||
|
// Run dev side-by-side with the installed packaged build by isolating the
|
||||||
|
// renderer profile / secret-store / SQLite / IndexedDB / localStorage in
|
||||||
|
// a separate userData dir. Without this both share `%APPDATA%\ChatApp`,
|
||||||
|
// the single-instance lock fires, and `pnpm dev` exits immediately while
|
||||||
|
// the installed prod app holds the lock. Must run BEFORE the lock check
|
||||||
|
// below + before any other module reads `app.getPath('userData')`.
|
||||||
|
if (!app.isPackaged) {
|
||||||
|
app.setPath('userData', app.getPath('userData') + '-Dev');
|
||||||
|
}
|
||||||
|
|
||||||
let mainWindow: BrowserWindow | null = null;
|
let mainWindow: BrowserWindow | null = null;
|
||||||
|
|
||||||
function resolvePreloadPath(): string {
|
function resolvePreloadPath(): string {
|
||||||
@@ -64,7 +74,7 @@ async function createWindow(): Promise<BrowserWindow> {
|
|||||||
const state = await loadState(WINDOW_STATE_FILE);
|
const state = await loadState(WINDOW_STATE_FILE);
|
||||||
|
|
||||||
const win = new BrowserWindow({
|
const win = new BrowserWindow({
|
||||||
title: 'ChatApp',
|
title: app.isPackaged ? 'ChatApp' : 'ChatApp (Dev)',
|
||||||
width: state.width,
|
width: state.width,
|
||||||
height: state.height,
|
height: state.height,
|
||||||
...(state.x !== undefined ? { x: state.x } : {}),
|
...(state.x !== undefined ? { x: state.x } : {}),
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@chat-app/desktop",
|
"name": "@chat-app/desktop",
|
||||||
"version": "0.16.1",
|
"version": "0.16.3",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Electron desktop client (Windows / macOS / Linux)",
|
"description": "Electron desktop client (Windows / macOS / Linux)",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
|
import { loadDevicePrivateKey } from '@chat-app/shared/auth';
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { Outlet } from 'react-router-dom';
|
import { Outlet } from 'react-router-dom';
|
||||||
|
|
||||||
import { useAuth } from '../context/AuthContext';
|
import { useAuth } from '../context/AuthContext';
|
||||||
import { startConversationKeySync } from '../lib/conversationKeySync';
|
import { startConversationKeySync } from '../lib/conversationKeySync';
|
||||||
|
import { startDeviceApprovalListener } from '../lib/deviceApproval';
|
||||||
import { ensureNotificationPermission } from '../lib/osNotify';
|
import { ensureNotificationPermission } from '../lib/osNotify';
|
||||||
|
import { devLocalSecretStore } from '../lib/secretStore';
|
||||||
import { BackupPromptBanner } from './BackupPromptBanner';
|
import { BackupPromptBanner } from './BackupPromptBanner';
|
||||||
import { CallUI } from './CallUI';
|
import { CallUI } from './CallUI';
|
||||||
|
import { DeviceApprovalBanner } from './DeviceApprovalBanner';
|
||||||
import { Sidebar } from './Sidebar';
|
import { Sidebar } from './Sidebar';
|
||||||
|
|
||||||
export function AppShell() {
|
export function AppShell() {
|
||||||
@@ -18,7 +22,18 @@ export function AppShell() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!session?.user.id || !device?.id) return;
|
if (!session?.user.id || !device?.id) return;
|
||||||
return startConversationKeySync(session.user.id, device.id);
|
const userId = session.user.id;
|
||||||
|
const deviceId = device.id;
|
||||||
|
const stopKeySync = startConversationKeySync(userId, deviceId);
|
||||||
|
const stopApproval = startDeviceApprovalListener({
|
||||||
|
ownUserId: userId,
|
||||||
|
ownDeviceId: deviceId,
|
||||||
|
getPriv: () => loadDevicePrivateKey(devLocalSecretStore, userId, deviceId),
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
stopKeySync();
|
||||||
|
stopApproval();
|
||||||
|
};
|
||||||
}, [session?.user.id, device?.id]);
|
}, [session?.user.id, device?.id]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -34,6 +49,7 @@ export function AppShell() {
|
|||||||
</div>
|
</div>
|
||||||
<CallUI />
|
<CallUI />
|
||||||
<BackupPromptBanner />
|
<BackupPromptBanner />
|
||||||
|
<DeviceApprovalBanner />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,196 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import {
|
||||||
|
approveDevice,
|
||||||
|
denyAllPending,
|
||||||
|
denyDevice,
|
||||||
|
type PendingApproval,
|
||||||
|
subscribePendingApprovals,
|
||||||
|
} from '../lib/deviceApproval';
|
||||||
|
import { LockIcon, SpinnerIcon, XIcon } from './icons';
|
||||||
|
|
||||||
|
// Sticky bottom-right banner stack (Discord-style). One tile per pending
|
||||||
|
// device-approval request. Visual language deliberately mirrors
|
||||||
|
// `BackupPromptBanner` — fixed positioning, rounded panel, subtle border
|
||||||
|
// accent — but uses the brand/accent palette to distinguish "security
|
||||||
|
// decision" from the amber "you should make a backup" nudge.
|
||||||
|
//
|
||||||
|
// Data flow:
|
||||||
|
// 1. `startDeviceApprovalListener` (mounted from AppShell) seeds the
|
||||||
|
// pending list on connect + on realtime INSERTs.
|
||||||
|
// 2. This component subscribes to that module and re-renders.
|
||||||
|
// 3. On Genehmigen: calls `approveDevice` which re-uses the
|
||||||
|
// `wrapForOneDevice` helper from conversationKeySync to write conv-key
|
||||||
|
// bundles for the new device across every shared conversation.
|
||||||
|
// 4. On Ablehnen: persists the deviceId in localStorage so it doesn't
|
||||||
|
// re-surface on app reload.
|
||||||
|
export function DeviceApprovalBanner() {
|
||||||
|
const { t, i18n } = useTranslation(['app']);
|
||||||
|
const [pending, setPending] = useState<PendingApproval[]>([]);
|
||||||
|
const [busyId, setBusyId] = useState<string | null>(null);
|
||||||
|
const [errorId, setErrorId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => subscribePendingApprovals(setPending), []);
|
||||||
|
|
||||||
|
const onApprove = useCallback(async (req: PendingApproval) => {
|
||||||
|
setErrorId(null);
|
||||||
|
setBusyId(req.deviceId);
|
||||||
|
try {
|
||||||
|
await approveDevice(req);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('deviceApproval: approve failed', err);
|
||||||
|
setErrorId(req.deviceId);
|
||||||
|
} finally {
|
||||||
|
setBusyId((curr) => (curr === req.deviceId ? null : curr));
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const onDeny = useCallback((deviceId: string) => {
|
||||||
|
denyDevice(deviceId);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (pending.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="pointer-events-none fixed bottom-6 right-6 z-40 flex w-[min(92vw,420px)] flex-col gap-3">
|
||||||
|
{pending.length > 1 && (
|
||||||
|
<div className="pointer-events-auto flex items-center justify-between gap-3 rounded-xl border border-line bg-surface-3/80 px-4 py-2 text-xs text-fg-muted backdrop-blur-md">
|
||||||
|
<span>
|
||||||
|
{t('app:device_approval.bulk_count', {
|
||||||
|
count: pending.length,
|
||||||
|
defaultValue: '{{count}} Geräte warten auf Bestätigung',
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => denyAllPending()}
|
||||||
|
disabled={busyId !== null}
|
||||||
|
className="cursor-pointer rounded-md border border-line bg-transparent px-3 py-1 text-xs font-medium text-fg-muted transition hover:bg-surface-2 hover:text-fg disabled:opacity-50 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
|
||||||
|
>
|
||||||
|
{t('app:device_approval.deny_all', { defaultValue: 'Alle ablehnen' })}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{pending.map((req) => {
|
||||||
|
const busy = busyId === req.deviceId;
|
||||||
|
const errored = errorId === req.deviceId;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={req.deviceId}
|
||||||
|
role="alertdialog"
|
||||||
|
aria-labelledby={`device-approval-${req.deviceId}-title`}
|
||||||
|
className="pointer-events-auto flex items-start gap-3 rounded-2xl border border-line bg-surface-3 p-4 text-sm text-fg shadow-xl backdrop-blur-md"
|
||||||
|
>
|
||||||
|
<LockIcon className="mt-0.5 h-5 w-5 shrink-0 text-accent" />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p
|
||||||
|
id={`device-approval-${req.deviceId}-title`}
|
||||||
|
className="font-semibold"
|
||||||
|
>
|
||||||
|
{t('app:device_approval.title', {
|
||||||
|
defaultValue: 'Neues Gerät registriert',
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
<p className="mt-0.5 text-xs text-fg-muted">
|
||||||
|
{formatDeviceLabel(req)} ·{' '}
|
||||||
|
{formatRelativeTime(req.createdAt, i18n.language)}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-xs text-fg-muted">
|
||||||
|
{t('app:device_approval.question', {
|
||||||
|
defaultValue: 'War das du?',
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
{errored && (
|
||||||
|
<p className="mt-1 text-xs text-red-500">
|
||||||
|
{t('app:device_approval.error', {
|
||||||
|
defaultValue:
|
||||||
|
'Genehmigung fehlgeschlagen. Versuch es nochmal.',
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => void onApprove(req)}
|
||||||
|
className="inline-flex cursor-pointer items-center gap-1.5 rounded-md bg-accent px-3 py-1.5 text-xs font-semibold text-accent-fg transition hover:opacity-90 disabled:cursor-wait disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{busy && <SpinnerIcon className="h-3.5 w-3.5 animate-spin" />}
|
||||||
|
{t('app:device_approval.approve', {
|
||||||
|
defaultValue: 'Genehmigen',
|
||||||
|
})}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => onDeny(req.deviceId)}
|
||||||
|
className="cursor-pointer rounded-md border border-line bg-transparent px-3 py-1.5 text-xs font-semibold text-fg-muted transition hover:bg-surface-2 disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{t('app:device_approval.deny', {
|
||||||
|
defaultValue: 'Ablehnen',
|
||||||
|
})}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => onDeny(req.deviceId)}
|
||||||
|
aria-label={t('app:device_approval.dismiss', {
|
||||||
|
defaultValue: 'Schließen',
|
||||||
|
})}
|
||||||
|
className="cursor-pointer text-fg-muted transition hover:text-fg disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
|
>
|
||||||
|
<XIcon className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDeviceLabel(req: PendingApproval): string {
|
||||||
|
const platform = humanPlatform(req.platform);
|
||||||
|
const name = (req.name ?? '').trim();
|
||||||
|
if (name && platform) return `${platform} · ${name}`;
|
||||||
|
if (name) return name;
|
||||||
|
if (platform) return platform;
|
||||||
|
return 'Unbekanntes Gerät';
|
||||||
|
}
|
||||||
|
|
||||||
|
function humanPlatform(p: string): string {
|
||||||
|
switch (p) {
|
||||||
|
case 'windows':
|
||||||
|
return 'Windows';
|
||||||
|
case 'macos':
|
||||||
|
return 'macOS';
|
||||||
|
case 'linux':
|
||||||
|
return 'Linux';
|
||||||
|
case 'ios':
|
||||||
|
return 'iOS';
|
||||||
|
case 'android':
|
||||||
|
return 'Android';
|
||||||
|
default:
|
||||||
|
return p.length > 0 ? p.charAt(0).toUpperCase() + p.slice(1) : '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Best-effort relative-time formatter using Intl.RelativeTimeFormat.
|
||||||
|
// Falls back to absolute timestamp if anything goes sideways.
|
||||||
|
function formatRelativeTime(iso: string, locale: string): string {
|
||||||
|
try {
|
||||||
|
const ts = Date.parse(iso);
|
||||||
|
if (Number.isNaN(ts)) return iso;
|
||||||
|
const diffSec = Math.round((ts - Date.now()) / 1000);
|
||||||
|
const abs = Math.abs(diffSec);
|
||||||
|
const rtf = new Intl.RelativeTimeFormat(locale || 'de', { numeric: 'auto' });
|
||||||
|
if (abs < 60) return rtf.format(diffSec, 'second');
|
||||||
|
if (abs < 3600) return rtf.format(Math.round(diffSec / 60), 'minute');
|
||||||
|
if (abs < 86400) return rtf.format(Math.round(diffSec / 3600), 'hour');
|
||||||
|
return rtf.format(Math.round(diffSec / 86400), 'day');
|
||||||
|
} catch {
|
||||||
|
return iso;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,7 +12,7 @@ import { supabase } from './supabase';
|
|||||||
// This fixes the "cannot decrypt" cliff for devices that registered while
|
// This fixes the "cannot decrypt" cliff for devices that registered while
|
||||||
// no other participant device was online to share the key with them.
|
// no other participant device was online to share the key with them.
|
||||||
|
|
||||||
interface SyncCtx {
|
export interface SyncCtx {
|
||||||
myUserId: string;
|
myUserId: string;
|
||||||
myDeviceId: string;
|
myDeviceId: string;
|
||||||
priv: Uint8Array;
|
priv: Uint8Array;
|
||||||
@@ -32,46 +32,55 @@ export function startConversationKeySync(
|
|||||||
ownUserId: string,
|
ownUserId: string,
|
||||||
ownDeviceId: string,
|
ownDeviceId: string,
|
||||||
): () => void {
|
): () => void {
|
||||||
let cancelled = false;
|
// DISABLED: auto-share of conversation keys to newly-registered devices
|
||||||
let priv: Uint8Array | null = null;
|
// is gone. Without it, account takeover (stolen password / new device
|
||||||
const dedupeKey = ownUserId + ':' + ownDeviceId;
|
// registered by attacker) no longer automatically grants history access
|
||||||
|
// — an attacker would have a working device-key but no conv-key wraps.
|
||||||
void loadDevicePrivateKey(devLocalSecretStore, ownUserId, ownDeviceId).then(async (pk) => {
|
//
|
||||||
if (cancelled) return;
|
// History access paths still supported:
|
||||||
priv = pk;
|
// 1. Backup-Restore — restores the OLD device-id + privkey, so the
|
||||||
if (!priv) return;
|
// server-side wraps for that device-id are accessible as before.
|
||||||
if (backfilledKey.has(dedupeKey)) return;
|
// 2. (Planned) Approval flow — existing device or conversation peer
|
||||||
backfilledKey.add(dedupeKey);
|
// explicitly approves a new device, then conv-keys are wrapped
|
||||||
await syncAllExistingGaps({ myUserId: ownUserId, myDeviceId: ownDeviceId, priv });
|
// for it. Until that ships, fresh-login-without-backup means old
|
||||||
});
|
// conversations stay encrypted.
|
||||||
|
//
|
||||||
const channel = supabase
|
// For NEW conversations: the key is generated at conv-creation time
|
||||||
.channel('device-key-sync:' + ownDeviceId)
|
// and includes all current devices of all members, so a freshly-logged-
|
||||||
.on(
|
// in device CAN still participate in newly-created conversations. It
|
||||||
'postgres_changes',
|
// just can't read the back-history of conversations it wasn't a member
|
||||||
{ event: 'INSERT', schema: 'public', table: 'devices' },
|
// of when those messages were sealed.
|
||||||
(payload: { new: { id?: string; user_id?: string; public_key?: string } }) => {
|
//
|
||||||
if (cancelled) return;
|
// We deliberately keep the helper functions below (syncAllExistingGaps,
|
||||||
const row = payload.new;
|
// wrapForOneDevice, …) intact so the upcoming approval flow can wire
|
||||||
if (!row?.id || !row.user_id || !row.public_key) return;
|
// them to user-driven triggers without rebuilding from scratch.
|
||||||
if (row.user_id === ownUserId && row.id === ownDeviceId) return;
|
void ownUserId;
|
||||||
if (!priv) return; // backfill on mount will catch it later
|
void ownDeviceId;
|
||||||
void wrapForOneDevice(
|
void backfilledKey;
|
||||||
{ myUserId: ownUserId, myDeviceId: ownDeviceId, priv },
|
void loadDevicePrivateKey;
|
||||||
row.id,
|
void devLocalSecretStore;
|
||||||
row.user_id,
|
void supabase;
|
||||||
row.public_key,
|
return () => {};
|
||||||
);
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.subscribe();
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
void supabase.removeChannel(channel);
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Keep helpers alive across the auto-sync hibernation window so the
|
||||||
|
// upcoming approval flow can re-wire them. Without this no-op reference
|
||||||
|
// `tsc --noEmit` flags them as unused (TS6133).
|
||||||
|
//
|
||||||
|
// `wrapForOneDevice` and `syncOneConversationGaps` are exported below for
|
||||||
|
// the device-approval module — once the user explicitly approves a new
|
||||||
|
// device the approval flow re-uses these helpers to wrap conv-keys for
|
||||||
|
// that specific deviceId.
|
||||||
|
void (() => {
|
||||||
|
void listMyConversationIds;
|
||||||
|
void listConversationDevices;
|
||||||
|
void listExistingKeyRecipients;
|
||||||
|
void getActiveKeyVersion;
|
||||||
|
void syncAllExistingGaps;
|
||||||
|
void isExpectedShareFailure;
|
||||||
|
void rawFrom;
|
||||||
|
});
|
||||||
|
|
||||||
async function listMyConversationIds(myUserId: string): Promise<string[]> {
|
async function listMyConversationIds(myUserId: string): Promise<string[]> {
|
||||||
const { data, error } = await supabase
|
const { data, error } = await supabase
|
||||||
.from('conversation_members')
|
.from('conversation_members')
|
||||||
@@ -149,7 +158,7 @@ async function syncAllExistingGaps(ctx: SyncCtx): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function syncOneConversationGaps(ctx: SyncCtx, convId: string): Promise<void> {
|
export async function syncOneConversationGaps(ctx: SyncCtx, convId: string): Promise<void> {
|
||||||
const version = await getActiveKeyVersion(convId);
|
const version = await getActiveKeyVersion(convId);
|
||||||
const devices = await listConversationDevices(convId);
|
const devices = await listConversationDevices(convId);
|
||||||
if (devices.length === 0) return;
|
if (devices.length === 0) return;
|
||||||
@@ -200,7 +209,7 @@ function isExpectedShareFailure(err: unknown): boolean {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function wrapForOneDevice(
|
export async function wrapForOneDevice(
|
||||||
ctx: SyncCtx,
|
ctx: SyncCtx,
|
||||||
newDeviceId: string,
|
newDeviceId: string,
|
||||||
newDeviceUserId: string,
|
newDeviceUserId: string,
|
||||||
|
|||||||
@@ -0,0 +1,322 @@
|
|||||||
|
import type { DevicePlatform } from '@chat-app/shared/supabase';
|
||||||
|
|
||||||
|
import { type SyncCtx, wrapForOneDevice } from './conversationKeySync';
|
||||||
|
import { supabase } from './supabase';
|
||||||
|
|
||||||
|
// Device-approval flow (Phase 1).
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// When the user registers a brand-new device on top of an existing one, the
|
||||||
|
// existing device must explicitly approve it before any conv-key wraps are
|
||||||
|
// created. This module:
|
||||||
|
//
|
||||||
|
// 1. On startup, fetches every device row owned by the user and surfaces
|
||||||
|
// the ones that aren't this device, aren't already approved, and aren't
|
||||||
|
// dismissed. Covers the "I was offline when the new device registered"
|
||||||
|
// case.
|
||||||
|
// 2. Subscribes to realtime INSERTs on `devices` for the user's id, so a
|
||||||
|
// device that registers WHILE this client is online raises a banner
|
||||||
|
// immediately.
|
||||||
|
// 3. Persists approve/deny decisions in localStorage so a reload doesn't
|
||||||
|
// ask again for a device the user already answered for.
|
||||||
|
//
|
||||||
|
// Approval call: re-uses `wrapForOneDevice` from conversationKeySync — that
|
||||||
|
// helper already walks every shared conversation and writes the key bundle
|
||||||
|
// for the target device.
|
||||||
|
|
||||||
|
export interface PendingApproval {
|
||||||
|
deviceId: string;
|
||||||
|
userId: string;
|
||||||
|
name: string;
|
||||||
|
platform: DevicePlatform | string;
|
||||||
|
createdAt: string;
|
||||||
|
publicKey: string; // pg-hex-encoded bytea
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeviceApprovalListenerCtx {
|
||||||
|
ownUserId: string;
|
||||||
|
ownDeviceId: string;
|
||||||
|
// Lazy getter so we never hold the privkey in memory for longer than the
|
||||||
|
// approve action that needs it. Returns null if the key isn't loadable
|
||||||
|
// (e.g. fresh-restored device that hasn't unsealed yet).
|
||||||
|
getPriv: () => Promise<Uint8Array | null>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const APPROVED_KEY = 'chatapp.approvedDeviceIds';
|
||||||
|
const DISMISSED_KEY = 'chatapp.dismissedDeviceIds';
|
||||||
|
|
||||||
|
// In-process state. Module-level so the banner component and the listener
|
||||||
|
// share one source of truth without prop-drilling through context.
|
||||||
|
let pending: PendingApproval[] = [];
|
||||||
|
const subscribers = new Set<(list: PendingApproval[]) => void>();
|
||||||
|
let listenerCtx: DeviceApprovalListenerCtx | null = null;
|
||||||
|
|
||||||
|
function readIdSet(key: string): Set<string> {
|
||||||
|
try {
|
||||||
|
const raw = window.localStorage.getItem(key);
|
||||||
|
if (!raw) return new Set();
|
||||||
|
const parsed: unknown = JSON.parse(raw);
|
||||||
|
if (!Array.isArray(parsed)) return new Set();
|
||||||
|
return new Set(parsed.filter((v): v is string => typeof v === 'string'));
|
||||||
|
} catch {
|
||||||
|
return new Set();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeIdSet(key: string, set: Set<string>): void {
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(key, JSON.stringify([...set]));
|
||||||
|
} catch {
|
||||||
|
/* storage unavailable — non-fatal */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function persistApproved(deviceId: string): void {
|
||||||
|
const s = readIdSet(APPROVED_KEY);
|
||||||
|
s.add(deviceId);
|
||||||
|
writeIdSet(APPROVED_KEY, s);
|
||||||
|
}
|
||||||
|
|
||||||
|
function persistDismissed(deviceId: string): void {
|
||||||
|
const s = readIdSet(DISMISSED_KEY);
|
||||||
|
s.add(deviceId);
|
||||||
|
writeIdSet(DISMISSED_KEY, s);
|
||||||
|
}
|
||||||
|
|
||||||
|
function notify(): void {
|
||||||
|
const snapshot = [...pending];
|
||||||
|
for (const cb of subscribers) {
|
||||||
|
try {
|
||||||
|
cb(snapshot);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('deviceApproval: subscriber threw', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPendingApprovals(): PendingApproval[] {
|
||||||
|
return [...pending];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function subscribePendingApprovals(
|
||||||
|
cb: (list: PendingApproval[]) => void,
|
||||||
|
): () => void {
|
||||||
|
subscribers.add(cb);
|
||||||
|
// Fire once with current state so the consumer can initialise without
|
||||||
|
// waiting for the next change.
|
||||||
|
try {
|
||||||
|
cb([...pending]);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('deviceApproval: initial subscriber call threw', err);
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
subscribers.delete(cb);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldSurface(deviceId: string, ownDeviceId: string): boolean {
|
||||||
|
if (deviceId === ownDeviceId) return false;
|
||||||
|
const approved = readIdSet(APPROVED_KEY);
|
||||||
|
if (approved.has(deviceId)) return false;
|
||||||
|
const dismissed = readIdSet(DISMISSED_KEY);
|
||||||
|
if (dismissed.has(deviceId)) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DeviceRowLite {
|
||||||
|
id: string;
|
||||||
|
user_id: string;
|
||||||
|
name: string;
|
||||||
|
platform: DevicePlatform | string;
|
||||||
|
created_at: string;
|
||||||
|
public_key: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rowToPending(row: DeviceRowLite): PendingApproval {
|
||||||
|
return {
|
||||||
|
deviceId: row.id,
|
||||||
|
userId: row.user_id,
|
||||||
|
name: row.name,
|
||||||
|
platform: row.platform,
|
||||||
|
createdAt: row.created_at,
|
||||||
|
publicKey: row.public_key,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function upsertPending(req: PendingApproval): void {
|
||||||
|
if (pending.some((p) => p.deviceId === req.deviceId)) return;
|
||||||
|
pending = [...pending, req];
|
||||||
|
notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
function removePending(deviceId: string): void {
|
||||||
|
const next = pending.filter((p) => p.deviceId !== deviceId);
|
||||||
|
if (next.length === pending.length) return;
|
||||||
|
pending = next;
|
||||||
|
notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadInitialPending(ctx: DeviceApprovalListenerCtx): Promise<void> {
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('devices')
|
||||||
|
.select('id, user_id, name, platform, created_at, public_key')
|
||||||
|
.eq('user_id', ctx.ownUserId);
|
||||||
|
if (error) {
|
||||||
|
console.warn('deviceApproval: initial devices lookup failed', error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const rows = (data ?? []) as DeviceRowLite[];
|
||||||
|
|
||||||
|
// Pre-filter: anything already in approved/dismissed sets, plus own
|
||||||
|
// device, never surfaces.
|
||||||
|
const candidates = rows.filter((r) => shouldSurface(r.id, ctx.ownDeviceId));
|
||||||
|
if (candidates.length === 0) return;
|
||||||
|
|
||||||
|
// Semantic skip: any candidate that already has a `conversation_keys`
|
||||||
|
// wrap somewhere is by definition legit — it was either auto-shared
|
||||||
|
// back when that path was enabled (pre-0.16.2) or explicitly approved
|
||||||
|
// earlier. Surfacing it now would just nag the user about something
|
||||||
|
// already taken care of. Bulk query against `conversation_keys` for
|
||||||
|
// all candidate device IDs at once; cheap, avoids N+1.
|
||||||
|
const candidateIds = candidates.map((c) => c.id);
|
||||||
|
const wrappedIds = new Set<string>();
|
||||||
|
try {
|
||||||
|
const { data: keyRows, error: kErr } = await (
|
||||||
|
supabase as unknown as {
|
||||||
|
from: (t: string) => {
|
||||||
|
select: (cols: string) => {
|
||||||
|
in: (
|
||||||
|
col: string,
|
||||||
|
vals: string[],
|
||||||
|
) => Promise<{ data: { recipient_device_id: string }[] | null; error: unknown }>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.from('conversation_keys')
|
||||||
|
.select('recipient_device_id')
|
||||||
|
.in('recipient_device_id', candidateIds);
|
||||||
|
if (!kErr && keyRows) {
|
||||||
|
for (const r of keyRows) wrappedIds.add(r.recipient_device_id);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('deviceApproval: conv-key existence probe failed', err);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Defense in depth: also keep the created_at guard. Devices older than
|
||||||
|
// own can't reasonably need approval — when own came online a fanout
|
||||||
|
// pass already covered them. Helps when the conv_keys probe returns
|
||||||
|
// partial data due to RLS.
|
||||||
|
const own = rows.find((r) => r.id === ctx.ownDeviceId);
|
||||||
|
const ownCreatedAt = own ? Date.parse(own.created_at) : Number.NEGATIVE_INFINITY;
|
||||||
|
|
||||||
|
for (const row of candidates) {
|
||||||
|
if (wrappedIds.has(row.id)) {
|
||||||
|
persistApproved(row.id);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const rowCreatedAt = Date.parse(row.created_at);
|
||||||
|
if (Number.isFinite(rowCreatedAt) && rowCreatedAt <= ownCreatedAt) {
|
||||||
|
persistApproved(row.id);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
upsertPending(rowToPending(row));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Starts the approval listener for the given user/device. Returns an
|
||||||
|
// unsubscribe function — call it on shell unmount to tear down the realtime
|
||||||
|
// channel and clear in-memory state.
|
||||||
|
export function startDeviceApprovalListener(
|
||||||
|
ctx: DeviceApprovalListenerCtx,
|
||||||
|
): () => void {
|
||||||
|
listenerCtx = ctx;
|
||||||
|
|
||||||
|
void loadInitialPending(ctx);
|
||||||
|
|
||||||
|
const channel = supabase
|
||||||
|
.channel(`device-approval:${ctx.ownUserId}`)
|
||||||
|
.on(
|
||||||
|
'postgres_changes',
|
||||||
|
{
|
||||||
|
event: 'INSERT',
|
||||||
|
schema: 'public',
|
||||||
|
table: 'devices',
|
||||||
|
filter: `user_id=eq.${ctx.ownUserId}`,
|
||||||
|
},
|
||||||
|
(payload: { new: Record<string, unknown> }) => {
|
||||||
|
const row = payload.new as unknown as DeviceRowLite;
|
||||||
|
if (!row?.id) return;
|
||||||
|
if (!shouldSurface(row.id, ctx.ownDeviceId)) return;
|
||||||
|
upsertPending(rowToPending(row));
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.subscribe();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
void supabase.removeChannel(channel).catch(() => {
|
||||||
|
/* ignore — channel might already be gone */
|
||||||
|
});
|
||||||
|
pending = [];
|
||||||
|
listenerCtx = null;
|
||||||
|
notify();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Approves a pending device: walks every conversation the current user is
|
||||||
|
// in and writes a conv-key bundle for the new device. On success the request
|
||||||
|
// is removed from the pending list and the deviceId is persisted in
|
||||||
|
// localStorage so a reload doesn't re-prompt.
|
||||||
|
export async function approveDevice(req: PendingApproval): Promise<void> {
|
||||||
|
const ctx = listenerCtx;
|
||||||
|
if (!ctx) {
|
||||||
|
throw new Error('deviceApproval: listener not started');
|
||||||
|
}
|
||||||
|
if (req.userId !== ctx.ownUserId) {
|
||||||
|
// Phase 1 only handles same-user approvals (own new device). Friend-side
|
||||||
|
// approval is a later phase.
|
||||||
|
throw new Error('deviceApproval: cross-user approval not supported yet');
|
||||||
|
}
|
||||||
|
|
||||||
|
const priv = await ctx.getPriv();
|
||||||
|
if (!priv) {
|
||||||
|
throw new Error('deviceApproval: own private key unavailable');
|
||||||
|
}
|
||||||
|
|
||||||
|
const sync: SyncCtx = {
|
||||||
|
myUserId: ctx.ownUserId,
|
||||||
|
myDeviceId: ctx.ownDeviceId,
|
||||||
|
priv,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
await wrapForOneDevice(sync, req.deviceId, req.userId, req.publicKey);
|
||||||
|
} finally {
|
||||||
|
// Wipe the priv copy we asked for. The original lives in the secret
|
||||||
|
// store; this is the transient working copy.
|
||||||
|
for (let i = 0; i < priv.length; i++) priv[i] = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
persistApproved(req.deviceId);
|
||||||
|
removePending(req.deviceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Denies a pending device: just remembers the deviceId in the dismissed-set
|
||||||
|
// and removes the request. No server-side change — the new device simply
|
||||||
|
// stays without any conv-key wraps until the user changes their mind (e.g.
|
||||||
|
// from a settings screen later).
|
||||||
|
export function denyDevice(deviceId: string): void {
|
||||||
|
persistDismissed(deviceId);
|
||||||
|
removePending(deviceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bulk-deny: persists every currently-pending deviceId into the dismissed
|
||||||
|
// set and clears the in-memory list in one notify. Useful when a user has
|
||||||
|
// accumulated stale entries from old test devices / migrations.
|
||||||
|
export function denyAllPending(): void {
|
||||||
|
if (pending.length === 0) return;
|
||||||
|
const dismissed = readIdSet(DISMISSED_KEY);
|
||||||
|
for (const p of pending) dismissed.add(p.deviceId);
|
||||||
|
writeIdSet(DISMISSED_KEY, dismissed);
|
||||||
|
pending = [];
|
||||||
|
notify();
|
||||||
|
}
|
||||||
@@ -104,6 +104,29 @@ for (const p of [exePath, blockmapPath, latestYmlPath]) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Inject releaseNotes into latest.yml ----------------------------------
|
||||||
|
//
|
||||||
|
// electron-builder doesn't write the CLI-supplied notes into the
|
||||||
|
// manifest by default — clients then see an empty body in the
|
||||||
|
// UpdateToast. Patch the YAML in place: append a block scalar
|
||||||
|
// (`releaseNotes: |-`) so multi-line content survives intact.
|
||||||
|
// Idempotent — skip if a `releaseNotes:` entry is already present
|
||||||
|
// (covers reruns / hand-edited manifests).
|
||||||
|
{
|
||||||
|
let yml = readFileSync(latestYmlPath, 'utf8');
|
||||||
|
if (!/^releaseNotes:/m.test(yml)) {
|
||||||
|
const indented = notes
|
||||||
|
.split('\n')
|
||||||
|
.map((l) => ' ' + l)
|
||||||
|
.join('\n');
|
||||||
|
yml = yml.replace(/\s*$/, '') + `\nreleaseNotes: |-\n${indented}\n`;
|
||||||
|
writeFileSync(latestYmlPath, yml, 'utf8');
|
||||||
|
console.log('Injected releaseNotes into latest.yml');
|
||||||
|
} else {
|
||||||
|
console.log('latest.yml already has releaseNotes — skipping injection');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// --- scp helpers ----------------------------------------------------------
|
// --- scp helpers ----------------------------------------------------------
|
||||||
|
|
||||||
const sshTarget = `${env.UPDATE_SSH_USER}@${env.UPDATE_HOST}`;
|
const sshTarget = `${env.UPDATE_SSH_USER}@${env.UPDATE_HOST}`;
|
||||||
|
|||||||
Reference in New Issue
Block a user