Skip to content

Commit a7de9ad

Browse files
committed
fix(desktop): fix startup reliability and data migration for upgrading users
- Migrate database from old data directories (DevTools, DevTools Studio to DevTools-Studio) - Catch protocol handler errors during server startup (return 503 instead of throwing) - Cap health check retry backoff at 2 seconds with 60 retries (~2 min window) - Add branded loading screen and startup error recovery UI with Reset database and restart - Fix migration FK reference (files_new self-reference) to prevent folder hierarchy flattening - Make telemetry (Umami, OpenReplay) non-blocking so they don't delay app startup - Add retry loops to collection sync streams for resilience against transient failures - Fix ApiCollections readiness probe (pipe + Effect.runPromise instead of incorrect call) - Add version plan for desktop 0.3.2 patch release
1 parent bc738f6 commit a7de9ad

File tree

10 files changed

+206
-46
lines changed

10 files changed

+206
-46
lines changed
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
desktop: patch
3+
---
4+
5+
Fix startup reliability and data migration for upgrading users. Migrate database from old data directories (DevTools, DevTools Studio). Catch protocol handler errors during server startup. Cap health check retry backoff. Add branded loading screen and error recovery UI. Fix migration FK reference to prevent folder hierarchy flattening. Make telemetry non-blocking.

apps/desktop/src/main/index.ts

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@ import * as NodeRuntime from '@effect/platform-node/NodeRuntime';
44
import { Config, Console, Effect, pipe, Runtime, String } from 'effect';
55
import { app, BrowserWindow, dialog, Dialog, globalShortcut, ipcMain, nativeTheme, protocol, shell } from 'electron';
66
import { autoUpdater } from 'electron-updater';
7+
import { copyFileSync, existsSync, mkdirSync, unlinkSync } from 'node:fs';
78
import os from 'node:os';
9+
import nodePath from 'node:path';
810
import { Agent } from 'undici';
911
import icon from '../../build/icon.ico?asset';
1012
import { CustomUpdateProvider, UpdateOptions } from './update';
@@ -98,10 +100,44 @@ const createWindow = Effect.gen(function* () {
98100
return mainWindow;
99101
});
100102

103+
/** Migrate database from old data directories if needed. */
104+
const migrateDataDir = () => {
105+
const newDir = app.getPath('userData');
106+
const newDb = nodePath.join(newDir, 'state.db');
107+
108+
// If the current data directory already has a database, nothing to do.
109+
if (existsSync(newDb)) return;
110+
111+
const appData = app.getPath('appData');
112+
// Check old directories in reverse-chronological order (prefer most recent data).
113+
// 0.2.0 used "DevTools Studio" (space), 0.1.x used "DevTools".
114+
const oldDirs = [nodePath.join(appData, 'DevTools Studio'), nodePath.join(appData, 'DevTools')];
115+
116+
const sourceDir = oldDirs.find((dir) => existsSync(nodePath.join(dir, 'state.db')));
117+
if (!sourceDir) return;
118+
119+
console.log(`Migrating database from ${sourceDir} to ${newDir}`);
120+
mkdirSync(newDir, { recursive: true });
121+
122+
for (const suffix of ['', '-wal', '-shm']) {
123+
const src = nodePath.join(sourceDir, `state.db${suffix}`);
124+
const dst = nodePath.join(newDir, `state.db${suffix}`);
125+
try {
126+
copyFileSync(src, dst);
127+
console.log(`Copied state.db${suffix}`);
128+
} catch {
129+
// WAL/SHM may not exist, safe to ignore
130+
}
131+
}
132+
console.log('Data directory migration complete');
133+
};
134+
101135
const server = pipe(
102136
Effect.gen(function* () {
103137
const path = yield* Path.Path;
104138

139+
yield* Effect.sync(migrateDataDir);
140+
105141
const dist = yield* pipe(
106142
import.meta.resolve('@the-dev-tools/server'),
107143
Url.fromString,
@@ -185,7 +221,7 @@ const onReady = Effect.gen(function* () {
185221
const url = rawRequest.url.replace('server://', 'http://the-dev-tools:0/');
186222
let request = new Request(url, rawRequest);
187223
request = new Request(request, { dispatcher } as never);
188-
return fetch(request);
224+
return fetch(request).catch(() => new Response(null, { status: 503 }));
189225
});
190226

191227
const mainWindow = yield* createWindow;
@@ -201,6 +237,20 @@ const onReady = Effect.gen(function* () {
201237
ipcMain.on('update:start', () => void autoUpdater.downloadUpdate());
202238
autoUpdater.on('download-progress', (_) => void mainWindow.webContents.send('update:progress', _));
203239
autoUpdater.on('update-downloaded', () => void autoUpdater.quitAndInstall());
240+
241+
ipcMain.handle('server:wipe-and-restart', () => {
242+
const dbDir = app.getPath('userData');
243+
for (const suffix of ['', '-wal', '-shm']) {
244+
const file = nodePath.join(dbDir, `state.db${suffix}`);
245+
try {
246+
if (existsSync(file)) unlinkSync(file);
247+
} catch (e) {
248+
console.error(`Failed to delete ${file}:`, e);
249+
}
250+
}
251+
app.relaunch();
252+
app.exit(0);
253+
});
204254
});
205255

206256
const onActivate = Effect.gen(function* () {

apps/desktop/src/preload/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,10 @@ contextBridge.exposeInMainWorld('electron', {
99
onClose: (callback: () => void) => ipcRenderer.on('on-close', callback),
1010
onCloseDone: () => void ipcRenderer.send('on-close-done'),
1111

12+
server: {
13+
wipeAndRestart: () => ipcRenderer.invoke('server:wipe-and-restart') as Promise<void>,
14+
},
15+
1216
update: {
1317
check: () => ipcRenderer.invoke('update:check'),
1418
finish: () => void ipcRenderer.send('update:finish'),

apps/desktop/src/renderer/env.d.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@ declare global {
88
onClose: (callback: () => void) => void;
99
onCloseDone: () => void;
1010

11+
server: {
12+
wipeAndRestart: () => Promise<void>;
13+
};
14+
1115
update: {
1216
check: () => Promise<string | undefined>;
1317
finish: () => void;

apps/desktop/src/renderer/main.tsx

Lines changed: 42 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ interface UpdateAvailableProps {
5252
const UpdateAvailable = ({ children }: UpdateAvailableProps) => {
5353
const [state, setState] = useState<'init' | 'skip' | 'update'>('init');
5454

55-
if (state === 'skip') return <Client />;
55+
if (state === 'skip') return <Client renderError={renderError} />;
5656

5757
return (
5858
<div className={tw`flex h-full flex-col items-center gap-8 p-16`}>
@@ -103,6 +103,45 @@ const UpdateProgress = () => {
103103
return <ProgressBar label='Updating...' value={percent} />;
104104
};
105105

106+
const LoadingScreen = () => (
107+
<div className={tw`flex h-full flex-col items-center justify-center gap-4`}>
108+
<Logo className={tw`size-10 animate-pulse`} />
109+
<div className={tw`text-on-neutral-low`}>Starting DevTools Studio...</div>
110+
</div>
111+
);
112+
113+
const StartupError = () => {
114+
const [isWiping, setIsWiping] = useState(false);
115+
116+
return (
117+
<div className={tw`flex h-full flex-col items-center justify-center gap-6 p-16`}>
118+
<Logo className={tw`size-10`} />
119+
120+
<div className={tw`text-center`}>
121+
<div className={tw`text-xl font-medium text-on-neutral`}>Failed to connect to the server</div>
122+
<div className={tw`mt-2 max-w-md text-on-neutral-low`}>
123+
The server took too long to start. This can happen on first launch or if the database is corrupted.
124+
</div>
125+
</div>
126+
127+
<Button
128+
isDisabled={isWiping}
129+
onPress={() => {
130+
setIsWiping(true);
131+
void window.electron.server.wipeAndRestart();
132+
}}
133+
variant='primary'
134+
>
135+
{isWiping ? 'Restarting...' : 'Reset database & restart'}
136+
</Button>
137+
138+
<div className={tw`text-sm text-on-neutral-lower`}>This will delete all local data and start fresh.</div>
139+
</div>
140+
);
141+
};
142+
143+
const renderError = () => <StartupError />;
144+
106145
const finalizerAtom = Atom.make((_) => void _.addFinalizer(() => void window.electron.onCloseDone()));
107146

108147
const App = () => {
@@ -111,8 +150,8 @@ const App = () => {
111150
const updateCheck = useAtomValue(updateCheckAtom);
112151

113152
return Result.match(updateCheck, {
114-
onFailure: () => <Client />,
115-
onInitial: () => 'Loading...',
153+
onFailure: () => <Client renderError={renderError} />,
154+
onInitial: () => <LoadingScreen />,
116155
onSuccess: (_) => <UpdateAvailable>{_.value}</UpdateAvailable>,
117156
});
118157
};

packages/client/src/app/index.tsx

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { Atom, Result, useAtomValue } from '@effect-atom/atom-react';
55
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
66
import { RouterProvider } from '@tanstack/react-router';
77
import { ConfigProvider, Effect, pipe, Record, Runtime } from 'effect';
8-
import { StrictMode } from 'react';
8+
import { type ReactNode, StrictMode } from 'react';
99
import { UiProvider } from '@the-dev-tools/ui/provider';
1010
import { makeToastQueue } from '@the-dev-tools/ui/toast';
1111
import { ApiCollections, ApiTransport } from '~/shared/api';
@@ -19,11 +19,13 @@ scan({ enabled: !import.meta.env.PROD, showToolbar: false });
1919

2020
const appAtom = runtimeAtom.atom(
2121
Effect.gen(function* () {
22-
yield* initUmami;
23-
yield* startOpenReplay;
24-
yield* ApiCollections;
25-
2622
const runtime = yield* Effect.runtime<RouterContext['runtime'] extends Runtime.Runtime<infer R> ? R : never>();
23+
24+
// Telemetry startup should never block app rendering.
25+
void Runtime.runPromise(runtime)(initUmami).catch(() => undefined);
26+
void Runtime.runPromise(runtime)(startOpenReplay).catch(() => undefined);
27+
28+
yield* ApiCollections;
2729
const transport = yield* ApiTransport;
2830
const queryClient = new QueryClient();
2931
const toastQueue = makeToastQueue();
@@ -32,11 +34,15 @@ const appAtom = runtimeAtom.atom(
3234
}),
3335
);
3436

35-
export const App = () => {
37+
interface AppProps {
38+
renderError?: () => ReactNode;
39+
}
40+
41+
export const App = ({ renderError }: AppProps = {}) => {
3642
const context = useAtomValue(appAtom);
3743

3844
return Result.match(context, {
39-
onFailure: () => <div>App startup error</div>,
45+
onFailure: renderError ?? (() => <div>App startup error</div>),
4046
onInitial: () => <div>Loading...</div>,
4147
onSuccess: ({ value }) => {
4248
let _ = <RouterProvider context={value} router={router} />;

packages/client/src/app/umami.tsx

Lines changed: 28 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -22,18 +22,34 @@ export const initUmami = Effect.gen(function* () {
2222
const host = yield* pipe(Config.string('HOST'), configNamespace);
2323
const websiteId = yield* pipe(Config.string('ID'), configNamespace);
2424

25-
const umami = yield* Effect.async<Umami>((resume) => {
26-
const script = document.createElement('script');
27-
script.src = `${host}/script.js`;
28-
script.setAttribute('data-website-id', websiteId);
29-
script.setAttribute('data-auto-track', 'false');
30-
document.head.appendChild(script);
31-
32-
script.addEventListener('load', () => {
33-
const { umami } = window as unknown as { umami: Umami };
34-
resume(Effect.succeed(umami));
35-
});
36-
});
25+
const umami = yield* Effect.tryPromise(
26+
() =>
27+
new Promise<Umami>((resolve, reject) => {
28+
const script = document.createElement('script');
29+
script.src = `${host}/script.js`;
30+
script.setAttribute('data-website-id', websiteId);
31+
script.setAttribute('data-auto-track', 'false');
32+
33+
script.addEventListener(
34+
'load',
35+
() => {
36+
const { umami } = window as unknown as { umami?: Umami };
37+
if (umami) resolve(umami);
38+
else reject(new Error('Umami script loaded but window.umami is missing'));
39+
},
40+
{ once: true },
41+
);
42+
script.addEventListener(
43+
'error',
44+
() => {
45+
reject(new Error(`Failed to load Umami script from ${script.src}`));
46+
},
47+
{ once: true },
48+
);
49+
50+
document.head.appendChild(script);
51+
}),
52+
);
3753

3854
const sessionIdKey = 'UMAMI_SESSION_ID';
3955
let sessionId = localStorage.getItem(sessionIdKey);

packages/client/src/shared/api/collection.internal.tsx

Lines changed: 56 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,28 @@ const createApiCollection = <TSchema extends ApiCollectionSchema>(schema: TSchem
7373
return value;
7474
}) as ItemKeyObject;
7575

76+
const waitForRetry = (signal: AbortSignal, delayMs = 250) =>
77+
new Promise<void>((resolve) => {
78+
if (signal.aborted) {
79+
resolve();
80+
return;
81+
}
82+
83+
const timeout = setTimeout(done, delayMs);
84+
85+
const onAbort = () => {
86+
done();
87+
};
88+
89+
function done() {
90+
clearTimeout(timeout);
91+
signal.removeEventListener('abort', onAbort);
92+
resolve();
93+
}
94+
95+
signal.addEventListener('abort', onAbort, { once: true });
96+
});
97+
7698
const sync: SpecCollectionOptions['sync']['sync'] = (_) => {
7799
params = _;
78100
const { begin, collection, commit, markReady, write } = params;
@@ -120,8 +142,8 @@ const createApiCollection = <TSchema extends ApiCollectionSchema>(schema: TSchem
120142
const syncController = new AbortController();
121143

122144
const sync = async () => {
123-
try {
124-
while (true) {
145+
while (!syncController.signal.aborted) {
146+
try {
125147
const syncStream = stream({
126148
method: schema.sync.method,
127149
signal: syncController.signal,
@@ -146,10 +168,11 @@ const createApiCollection = <TSchema extends ApiCollectionSchema>(schema: TSchem
146168

147169
processSync(items);
148170
}
171+
} catch (error) {
172+
if (error instanceof ConnectError && error.code === Code.Canceled) return;
173+
console.error('Collection sync stream failed, retrying...', schema.item.typeName, error);
174+
await waitForRetry(syncController.signal);
149175
}
150-
} catch (error) {
151-
if (error instanceof ConnectError && error.code === Code.Canceled) return;
152-
throw error;
153176
}
154177
};
155178

@@ -159,23 +182,33 @@ const createApiCollection = <TSchema extends ApiCollectionSchema>(schema: TSchem
159182
};
160183

161184
const initialSync = async () => {
162-
const { message } = await request({ method: schema.collection, transport });
163-
const valid = validate(schema.collection.output, message);
164-
165-
if (valid.kind !== 'valid') {
166-
console.error('Invalid initial collection data', valid);
167-
return;
168-
}
185+
while (!syncController.signal.aborted) {
186+
try {
187+
const { message } = await request({ method: schema.collection, transport });
188+
const valid = validate(schema.collection.output, message);
189+
190+
if (valid.kind !== 'valid') {
191+
console.error('Invalid initial collection data', valid);
192+
await waitForRetry(syncController.signal);
193+
continue;
194+
}
169195

170-
begin();
171-
(valid.message as Message & { items: Item[] }).items.forEach((_) => void write({ type: 'insert', value: _ }));
172-
commit();
196+
begin();
197+
(valid.message as Message & { items: Item[] }).items.forEach((_) => void write({ type: 'insert', value: _ }));
198+
commit();
173199

174-
initialSyncState.isComplete = true;
200+
initialSyncState.isComplete = true;
175201

176-
if (initialSyncState.buffer.length > 0) processSync(initialSyncState.buffer);
202+
if (initialSyncState.buffer.length > 0) processSync(initialSyncState.buffer);
177203

178-
markReady();
204+
markReady();
205+
return;
206+
} catch (error) {
207+
if (error instanceof ConnectError && error.code === Code.Canceled) return;
208+
console.error('Initial collection sync failed, retrying...', schema.item.typeName, error);
209+
await waitForRetry(syncController.signal);
210+
}
211+
}
179212
};
180213

181214
void sync();
@@ -339,11 +372,14 @@ export class ApiCollections extends Effect.Service<ApiCollections>()('ApiCollect
339372
HashMap.fromIterable,
340373
);
341374

342-
yield* pipe(
375+
void pipe(
343376
HashMap.toValues(collections),
344377
Array.map((_) => Effect.tryPromise(() => _.waitFor('status:ready'))),
345378
(_) => Effect.all(_, { concurrency: 'unbounded' }),
346-
);
379+
Effect.runPromise,
380+
).catch((error: unknown) => {
381+
console.error('ApiCollections readiness probe failed', error);
382+
});
347383

348384
return collections;
349385
}),

0 commit comments

Comments
 (0)