Compare commits

..

4 Commits

Author SHA1 Message Date
byGalax 0dde1dd1a3 chore(desktop): release v0.17.2 2026-05-12 22:24:32 +02:00
byGalax 81d3587a91 feat(chat): per-conversation scroll memory + version badge on changelog page
Two small UX polishes:

1. Switching between chats no longer slams you to the bottom. Each
   conversation's scroll position (pixel offset + stickToBottom flag)
   is remembered in a module-scoped Map for the lifetime of the
   renderer process. Discord-style: leave Chat A scrolled up, peek at
   another conversation, come back — same spot you were reading.
   Chats left at the bottom keep auto-following new messages on return.
   Reload resets everything (session-only, no localStorage).

   The restore runs once messages.length > 0 to avoid the browser
   clamping scrollTop to a near-zero scrollHeight before the message
   list has rendered. A small isRestoringRef guard prevents the
   programmatic scroll event from immediately overwriting the saved
   position with a clamped value.

2. Changelog page now shows a version badge in the header that compares
   the installed app version against entries[0].version from the
   server-side changelog feed. Three states:
   * `vX.Y.Z · aktuell` (emerald) — installed matches latest
   * `vX.Y.Z · Update verfügbar` + `neueste: vA.B.C` (amber) — outdated
   * `vX.Y.Z` neutral — installed is ahead of the published feed
     (dev/test builds)
   Semver compare is integer-major.minor.patch with a graceful
   garbage-fallback so a malformed version string doesn't false-flag
   a current install as outdated.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 22:22:49 +02:00
byGalax 8be1105333 chore(desktop): release v0.17.1 2026-05-12 22:00:27 +02:00
byGalax f9e1d2f073 fix(secure-store): preserve original ciphertext on decrypt failure + startup path log
Critical hotfix for the 0.17.0 regression: users upgrading from 0.16.x
were logged out, and their next login wrote a fresh empty secure-store
on top of the original ciphertext — destroying device keys irrecoverably.

Why it happened: loadState used a blanket `catch {}` that conflated
"file doesn't exist (genuine new user)" with "file exists but can't be
decrypted (DPAPI / OSCrypt quirk after the install rename)". Both paths
returned an empty Map; the next scheduledSave then overwrote the
original .bin file with a fresh blob.

Fix:
* Separate ENOENT from decrypt/parse failures. ENOENT → empty Map. Any
  other read error → log, empty Map (no quarantine, matches old
  behaviour for transient lock issues).
* When decrypt/parse fails the original file is renamed to
  <file>.broken-<iso-ts> BEFORE returning empty Map. The next save
  writes to a fresh file; the original ciphertext is preserved on disk
  so a future build (or manual recovery) can still get at the bytes.
* Loud console.error around the failure so future regressions surface
  in main-process logs.

main.ts: move setPath('userData', appData/ChatApp) BEFORE setName so
any productName-derived path caching inside setName can't beat us to
it. Add a startup log of the resolved paths so future debugging has
hard evidence instead of guessing.

Affected users on 0.17.0 should still recover via Settings → Backup
Wiederherstellen (account-level keys are unchanged); this fix prevents
the data destruction for anyone who hasn't upgraded yet.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 21:54:14 +02:00
5 changed files with 232 additions and 25 deletions
+28 -10
View File
@@ -37,23 +37,26 @@ const __dirnameSafe = path.dirname(__filenameSafe);
const DEV_URL = 'http://localhost:1420';
const WINDOW_STATE_FILE = 'window-state.json';
// Pin userData FIRST — before any other Electron call that might cache a
// productName-derived path. The 0.17.0 release saw users get logged out
// after upgrading from 0.16.x: the most likely culprit was an internal
// path resolution kicking off the moment `setName('Netralax')` ran, so
// 0.17.1 swaps the order so the explicit override wins regardless of
// what setName triggers internally. The literal 'ChatApp' here is the
// pre-rename product folder — installed users' SQLite, secrets, sounds,
// IndexedDB all live there and we never want to leave them stranded by
// a future rebrand.
app.setPath('userData', path.join(app.getPath('appData'), 'ChatApp'));
// App branding. productName in package.json drives the packaged exe name
// (Netralax.exe) and electron-builder installer title. setName + the
// AppUserModelId below cover the live process: window title fallback,
// Windows taskbar grouping, notification source attribution.
// AppUserModelId cover the live process: window title fallback, Windows
// taskbar grouping, notification source attribution.
app.setName('Netralax');
if (process.platform === 'win32') {
app.setAppUserModelId('cloud.netralax.desktop');
}
// Pin userData to %APPDATA%\ChatApp regardless of productName so existing
// installs keep their profile, sounds, secrets, SQLite. Electron's default
// is %APPDATA%\<productName>, which after the Netralax rename would point
// at an empty fresh dir — same painful migration as the Tauri → Electron
// cut. Anchored to `appData` (the platform-AppData root) so productName
// changes can't drag it.
app.setPath('userData', path.join(app.getPath('appData'), 'ChatApp'));
// 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`,
@@ -64,6 +67,21 @@ if (!app.isPackaged) {
app.setPath('userData', app.getPath('userData') + '-Dev');
}
// Startup diagnostics — the 0.17.0 logout regression was hard to debug
// because we had no record of the actual resolved paths. With this log
// any future user can paste their main-process output and we can tell
// at a glance whether userData ended up where we intended.
console.log(
'[main] resolved paths',
JSON.stringify({
appName: app.getName(),
appData: app.getPath('appData'),
userData: app.getPath('userData'),
isPackaged: app.isPackaged,
platform: process.platform,
}),
);
let mainWindow: BrowserWindow | null = null;
function resolvePreloadPath(): string {
+56 -8
View File
@@ -40,20 +40,68 @@ function filePathFor(userId: string, encrypted: boolean): string {
}
async function loadState(filePath: string, encrypted: boolean): Promise<Map<string, string>> {
// Read step. Distinguish "no file yet" (genuinely new user — empty Map
// is correct) from "file exists but unreadable" (corruption / DPAPI
// breakage — we MUST NOT let the next write overwrite those bytes,
// because the original ciphertext is the only path back to the user's
// device keys if a future build can fix the read path).
let rawBuf: Buffer | null = null;
let rawStr: string | null = null;
try {
if (encrypted) {
const buf = await fs.readFile(filePath);
const json = safeStorage.decryptString(buf);
rawBuf = await fs.readFile(filePath);
} else {
rawStr = await fs.readFile(filePath, 'utf8');
}
} catch (err: unknown) {
const code = (err as NodeJS.ErrnoException | null)?.code;
if (code === 'ENOENT') return new Map();
console.warn('[secure-store] read failed (non-ENOENT)', filePath, err);
// For non-ENOENT read failures (EACCES, EBUSY, …) don't quarantine —
// the file might be transiently locked. Empty map + future writes
// will attempt to overwrite, matching the pre-0.17.1 behaviour for
// these rarer cases.
return new Map();
}
// Parse / decrypt step.
try {
if (encrypted && rawBuf) {
const json = safeStorage.decryptString(rawBuf);
const parsed = JSON.parse(json) as { version?: number; entries?: Record<string, string> };
return new Map(Object.entries(parsed.entries ?? {}));
} else {
const raw = await fs.readFile(filePath, 'utf8');
const parsed = JSON.parse(raw) as { version?: number; entries?: Record<string, string> };
}
if (rawStr) {
const parsed = JSON.parse(rawStr) as { version?: number; entries?: Record<string, string> };
return new Map(Object.entries(parsed.entries ?? {}));
}
} catch {
// Missing file or malformed contents — start fresh. The next write
// will overwrite with a fresh blob.
return new Map();
} catch (err: unknown) {
// CRITICAL: file existed but we couldn't decrypt or parse it. In the
// pre-0.17.1 build we silently started fresh — the next set() then
// scheduledSave() over-wrote the original ciphertext, destroying the
// user's device keys forever. Now we rename the original to
// `<file>.broken-<iso-ts>` BEFORE returning the empty map so the next
// write goes to a new file and the original bytes survive for
// forensics or a future decrypt-recovery path.
const ts = new Date().toISOString().replace(/[:.]/g, '-');
const brokenPath = `${filePath}.broken-${ts}`;
try {
await fs.rename(filePath, brokenPath);
console.error(
`[secure-store] DECRYPT/PARSE FAILED for ${filePath} — preserved original at ${brokenPath}. Original error:`,
err,
);
} catch (renameErr: unknown) {
// Even rename failed — fall back to the old behaviour (silent empty
// map) but log loudly so it's visible in the main-process output.
console.error(
'[secure-store] rename of broken file failed; original may be overwritten on next save',
renameErr,
'original decrypt error:',
err,
);
}
return new Map();
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@chat-app/desktop",
"version": "0.17.0",
"version": "0.17.2",
"private": true,
"description": "Electron desktop client (Windows / macOS / Linux)",
"type": "module",
+97 -2
View File
@@ -1,15 +1,33 @@
import { useEffect, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { fetchChangelog, type ChangelogEntry } from '../lib/changelog';
import { SparklesIcon, SpinnerIcon } from '../components/icons';
const PAGE_SIZE = 10;
// Installed app version comes from the preload bridge (process.env.npm_-
// package_version at preload build time). Falls back to '0.0.0' outside
// Electron so the page still renders in a browser preview.
const installedVersion = window.electronAPI?.appVersion ?? '0.0.0';
export function ChangelogPage() {
const [entries, setEntries] = useState<ChangelogEntry[] | null>(null);
const [error, setError] = useState<string | null>(null);
const [visible, setVisible] = useState(PAGE_SIZE);
// Compare the installed version against the top changelog entry. The
// server-side changelog is sorted newest-first by the release script, so
// entries[0] is always the published latest.
const latestVersion = entries?.[0]?.version ?? null;
const versionStatus = useMemo<'loading' | 'current' | 'outdated' | 'ahead'>(() => {
if (entries === null) return 'loading';
if (!latestVersion) return 'current';
const cmp = compareSemver(installedVersion, latestVersion);
if (cmp === 0) return 'current';
if (cmp < 0) return 'outdated';
return 'ahead';
}, [entries, latestVersion]);
useEffect(() => {
let cancelled = false;
void (async () => {
@@ -34,7 +52,7 @@ export function ChangelogPage() {
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-accent/15 text-accent">
<SparklesIcon className="h-5 w-5" />
</div>
<div>
<div className="flex-1">
<h1 className="font-display text-2xl font-semibold tracking-tight text-fg">
Was ist neu
</h1>
@@ -42,6 +60,11 @@ export function ChangelogPage() {
Alle Änderungen in dieser App, neueste zuerst.
</p>
</div>
<VersionBadge
status={versionStatus}
installed={installedVersion}
latest={latestVersion}
/>
</header>
{entries === null && !error && (
@@ -118,3 +141,75 @@ function formatDate(iso: string): string {
return iso;
}
}
// Compact status chip in the header that tells the user whether their
// installed build matches the latest published version. Three visual
// tones: emerald (current), amber (outdated → update available), neutral
// (loading / unknown). The "ahead" case (dev build > released) shares the
// neutral tone since users running it always know what they're doing.
function VersionBadge({
status,
installed,
latest,
}: {
status: 'loading' | 'current' | 'outdated' | 'ahead';
installed: string;
latest: string | null;
}) {
if (status === 'loading') {
return (
<span className="inline-flex shrink-0 items-center gap-1.5 rounded-full border border-line bg-surface-2 px-2.5 py-1 text-[11px] font-medium tabular-nums text-fg-muted">
v{installed}
</span>
);
}
if (status === 'current') {
return (
<span
title="Du läufst auf der neuesten Version."
className="inline-flex shrink-0 items-center gap-1.5 rounded-full border border-emerald-500/40 bg-emerald-500/10 px-2.5 py-1 text-[11px] font-semibold tabular-nums text-emerald-700 dark:text-emerald-300"
>
<span className="h-1.5 w-1.5 rounded-full bg-emerald-500" />
v{installed} · aktuell
</span>
);
}
if (status === 'outdated' && latest) {
return (
<span
title={`Update verfügbar — neueste Version: v${latest}.`}
className="inline-flex shrink-0 flex-col items-end gap-0.5 rounded-md border border-amber-400/50 bg-amber-400/10 px-2.5 py-1 text-[11px] font-semibold tabular-nums text-amber-800 dark:text-amber-200"
>
<span>v{installed} · Update verfügbar</span>
<span className="text-[10px] font-normal opacity-80">neueste: v{latest}</span>
</span>
);
}
return (
<span
title="Du läufst auf einer neueren Version als veröffentlicht (z.B. Dev-Build)."
className="inline-flex shrink-0 items-center gap-1.5 rounded-full border border-line bg-surface-2 px-2.5 py-1 text-[11px] font-medium tabular-nums text-fg-muted"
>
v{installed}
</span>
);
}
// Lightweight semver comparator: parses major.minor.patch as ints and
// compares numerically. Returns negative if a < b, zero if equal, positive
// if a > b. Handles malformed inputs by treating non-numeric segments as
// 0 so a typo doesn't flag a perfectly current install as outdated.
function compareSemver(a: string, b: string): number {
const parse = (s: string): [number, number, number] => {
const parts = s.split('.').map((p) => {
const n = parseInt(p, 10);
return Number.isFinite(n) ? n : 0;
});
return [parts[0] ?? 0, parts[1] ?? 0, parts[2] ?? 0];
};
const [aMaj, aMin, aPat] = parse(a);
const [bMaj, bMin, bPat] = parse(b);
if (aMaj !== bMaj) return aMaj - bMaj;
if (aMin !== bMin) return aMin - bMin;
return aPat - bPat;
}
+50 -4
View File
@@ -49,6 +49,15 @@ import { useTypingChannel } from '../lib/useTypingChannel';
const STICK_THRESHOLD = 80;
// Per-conversation scroll memory. Module-scoped so it survives re-mounts
// of ConversationPage when the route param (`id`) changes — switching
// chats unmounts/remounts the page in our router setup. Session-only
// (lost on reload, like Discord). The `stickToBottom` flag is preserved
// alongside the pixel offset so a chat the user left at the bottom keeps
// auto-following new messages when they return; a chat scrolled up
// returns to the exact spot the user was reading.
const scrollPositions = new Map<string, { scrollTop: number; stickToBottom: boolean }>();
export function ConversationPage() {
const { t } = useTranslation(['app', 'errors']);
const { id } = useParams<{ id: string }>();
@@ -403,11 +412,41 @@ export function ConversationPage() {
el.scrollTop = el.scrollHeight;
}, [messages.length, stickToBottom]);
// Restore saved scroll position once the conversation's messages have
// actually rendered. The earlier version fired on `[id]` alone and ran
// before the message list populated — scrollHeight was still tiny, so
// `el.scrollTop = saved.scrollTop` got clamped to 0 by the browser
// and the user landed at the top instead of the saved position. By
// waiting for `messages.length > 0` we know the rendered scrollHeight
// is meaningful. `restoredForRef` ensures the restore runs at most
// once per chat switch (subsequent message arrivals don't re-trigger).
const restoredForRef = useRef<string | null>(null);
const isRestoringRef = useRef(false);
useEffect(() => {
setStickToBottom(true);
const el = scrollRef.current;
if (el) el.scrollTop = el.scrollHeight;
}, [id]);
if (!el || !id) return;
if (restoredForRef.current === id) return;
// Wait for the conversation's messages to populate; for a chat that
// truly has zero messages the bottom and the top are the same anyway.
if (messages.length === 0) return;
restoredForRef.current = id;
const saved = scrollPositions.get(id);
// Suppress handleScroll's persistence during the programmatic scroll
// below — otherwise the browser's clamp/normalisation could write a
// different scrollTop back into the Map and lose the saved position.
isRestoringRef.current = true;
if (saved && !saved.stickToBottom) {
el.scrollTop = saved.scrollTop;
setStickToBottom(false);
} else {
setStickToBottom(true);
el.scrollTop = el.scrollHeight;
}
requestAnimationFrame(() => {
isRestoringRef.current = false;
});
}, [id, messages.length]);
const handleScroll = useCallback(() => {
const el = scrollRef.current;
@@ -416,7 +455,14 @@ export function ConversationPage() {
const nextStick = distanceFromBottom < STICK_THRESHOLD;
setStickToBottom(nextStick);
if (nextStick) setNewMessagesWhileAway(0);
}, []);
// Persist position per chat so re-entering this conversation lands
// where the user left off (see scrollPositions module-level Map).
// Skipped during the in-flight restore so we don't immediately
// overwrite the saved position with a clamped value.
if (id && !isRestoringRef.current) {
scrollPositions.set(id, { scrollTop: el.scrollTop, stickToBottom: nextStick });
}
}, [id]);
const jumpToBottom = useCallback(() => {
const el = scrollRef.current;