Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
69 changes: 69 additions & 0 deletions .github/ISSUE_TEMPLATE/desktop-crash.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
name: Desktop app crash
description: The Executor desktop app crashes, won't start, or shows a blank window.
title: "[desktop] "
labels: ["desktop", "crash"]
body:
- type: markdown
attributes:
value: |
Before filing, please try the quick fixes in the pinned
**[Desktop app crashing? Start here](https://github.com/RhysSullivan/executor/issues?q=is%3Aissue+label%3Acrash-triage)**
guide — most crashes are fixed by updating to the latest version.
If you're still stuck, the details below help us pin it down fast.
- type: input
id: version
attributes:
label: Executor version
description: Shown in the About menu (or Settings page). Please update to the latest first if you can.
placeholder: "1.5.4"
validations:
required: true
- type: dropdown
id: os
attributes:
label: Operating system
options:
- macOS (Apple Silicon)
- macOS (Intel)
- Windows
- Linux
validations:
required: true
- type: dropdown
id: when
attributes:
label: When does it crash?
options:
- On launch — the app never opens
- Shortly after launch
- While doing something specific (describe below)
- Randomly during use
validations:
required: true
- type: textarea
id: what-happened
attributes:
label: What happened
description: What were you doing when it crashed? Does it happen every time?
validations:
required: true
- type: textarea
id: logs
attributes:
label: Diagnostics / logs
description: |
If the app opens far enough to show a menu, use **Help → Export Diagnostics…**
(or **Report a Problem…**) and attach the zip — it has everything we need and no secrets.

Otherwise, attach the log file directly:
- macOS: `~/Library/Logs/Executor/main.log`
- Windows: `%APPDATA%\Executor\logs\main.log`
- Linux: `~/.config/Executor/logs/main.log`

(Please don't attach anything from `~/.executor` — that's where your data and secrets live.)
placeholder: Drag the diagnostics zip or main.log here.
- type: input
id: run-id
attributes:
label: Run ID (optional)
description: If a crash report was sent, the Run ID from the diagnostics manifest helps us find it.
19 changes: 19 additions & 0 deletions apps/desktop/src/main/crash-screen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ export const sidecarCrashHtml = ({ reported }: CrashScreenOptions): string => `<
}
button.secondary { background: transparent; color: #fafafa; border-color: #3f3f46; }
#status { margin-top: 1.25rem; min-height: 1.2em; font-size: 0.8rem; color: #a1a1aa; }
.reset-row { margin-top: 1.75rem; font-size: 0.75rem; color: #71717a; }
.reset-row a { color: #f87171; text-decoration: underline; cursor: pointer; }
</style>
</head>
<body>
Expand All @@ -61,6 +63,11 @@ export const sidecarCrashHtml = ({ reported }: CrashScreenOptions): string => `<
<button id="export" class="secondary">Export diagnostics</button>
</div>
<p id="status"></p>
<p class="reset-row">
Still won't start after restarting?
<a id="reset" href="#">Reset data</a>
clears a damaged database to get the app running again.
</p>
</main>
<script>
const status = document.getElementById("status");
Expand Down Expand Up @@ -92,6 +99,18 @@ export const sidecarCrashHtml = ({ reported }: CrashScreenOptions): string => `<
status.textContent = "Export failed \\u2014 see the log file.";
}
});
document.getElementById("reset").addEventListener("click", async (event) => {
event.preventDefault();
try {
// Native confirm runs in main; on accept, state is backed up and
// the server restarts (window reloads on success). false = cancel.
const didReset = await window.executor.resetState();
if (!didReset) return;
status.textContent = "";
} catch {
status.textContent = "Reset failed \\u2014 see the log file.";
}
});
</script>
</body>
</html>`;
49 changes: 41 additions & 8 deletions apps/desktop/src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import {
reportAProblem,
} from "./diagnostics";
import { sidecarCrashHtml } from "./crash-screen";
import { announceBackup, confirmResetState, resetExecutorState } from "./reset-state";
import {
getServerProfiles,
getServerSettings,
Expand Down Expand Up @@ -333,6 +334,20 @@ const registerIpcHandlers = () => {
// fixed upstream. Reuses the menu flow — staged updates prompt to install,
// "no updates" / failures surface in their own dialogs.
ipcMain.handle("executor:updates:check", () => runUpdateCheck({ alertOnFail: true }));
// Crash-screen last resort for damaged state: confirm, move the data dir
// aside (never delete), then restart the sidecar against the fresh dir.
// Returns false when the user cancelled.
ipcMain.handle("executor:state:reset", async (): Promise<boolean> => {
if (!(await confirmResetState())) return false;
if (connection) {
await stopSidecar(connection.child);
connection = null;
}
const { backupDir } = resetExecutorState();
await restartSidecarAndReload();
await announceBackup(backupDir);
return true;
});
ipcMain.handle("executor:shell:open-external", async (_evt, rawUrl: unknown) => {
if (typeof rawUrl !== "string") return;
// oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: untrusted renderer string, URL ctor throws on malformed input
Expand Down Expand Up @@ -475,13 +490,24 @@ const handleFatalSidecarFailure = async (error: unknown) => {
}
// oxlint-disable-next-line executor/no-instanceof-error, executor/no-unknown-error-message -- boundary: sidecar startup failures arrive as plain Node errors and render in a native dialog
const detail = error instanceof Error ? (error.stack ?? error.message) : String(error);
await dialog.showMessageBox({
const { response } = await dialog.showMessageBox({
type: "error",
title: "Executor failed to start",
message: "The local Executor server crashed during startup.",
detail: `${detail.slice(0, 1800)}\n\nFull log: ${log.transports.file.getFile().path}`,
buttons: ["Quit"],
buttons: ["Quit", "Reset data and retry…"],
defaultId: 0,
cancelId: 0,
});
// Damaged executor state (failed migration, corrupt SQLite) makes startup
// fail forever — updating can't fix it. Offer the move-aside reset and one
// immediate retry. Returns true when boot should be attempted again.
if (response === 1 && (await confirmResetState())) {
const { backupDir } = resetExecutorState();
await announceBackup(backupDir);
return true;
}
return false;
};

const installApplicationMenu = () => {
Expand Down Expand Up @@ -545,15 +571,22 @@ const boot = async () => {
void runUpdateCheck({ alertOnFail: false });
});
connection = await startWithCurrentSettings();
if (!connection) {
if (!connection && lastSidecarStartError != null) {
// Port conflicts already showed their dialog inside
// startWithCurrentSettings; every other failure surfaces here so the app
// never silently bounces-and-vanishes. Pointing a window at the
// (unreachable) baseUrl would just show ECONNREFUSED — a placeholder URL
// would be worse. For now: explain, offer the updater a chance, quit.
if (lastSidecarStartError != null) {
await handleFatalSidecarFailure(lastSidecarStartError);
// never silently bounces-and-vanishes. The dialog offers a data reset
// (move-aside, for damaged state) — when taken, retry the boot once
// against the fresh dir.
const retryAfterReset = await handleFatalSidecarFailure(lastSidecarStartError);
if (retryAfterReset) {
lastSidecarStartError = null;
connection = await startWithCurrentSettings();
if (!connection && lastSidecarStartError != null) {
await handleFatalSidecarFailure(lastSidecarStartError);
}
}
}
if (!connection) {
app.quit();
return;
}
Expand Down
104 changes: 104 additions & 0 deletions apps/desktop/src/main/reset-state.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
/**
* Last-resort recovery for a data dir the sidecar can no longer open
* (failed migration, corrupted SQLite, …): move the executor state aside
* and start fresh.
*
* Strictly backup-then-move — nothing is ever deleted. data.db holds the
* user's connections and secrets, so the old state lands in a timestamped
* folder under ~/.executor/backups/ where it can be restored by copying
* the files back.
*
* Scope: only the sidecar-owned state (data.db + SQLite sidecar files and
* server-control/). The plugin manifest (executor.jsonc) is user-authored
* config, not state — a reset shouldn't discard hand-written setup — and
* desktop settings (port/auth) live in Electron's own store, unaffected.
*/

import { existsSync, mkdirSync, renameSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
import { dialog, shell } from "electron";
import log from "electron-log/main.js";

const STATE_ENTRIES = ["data.db", "data.db-wal", "data.db-shm", "server-control"];

const backupStamp = () =>
new Date()
.toISOString()
.replace(/[-:]/g, "")
.replace(/\.\d+Z$/, "Z");

export interface ResetStateResult {
readonly backupDir: string;
readonly moved: ReadonlyArray<string>;
}

/**
* Move the executor state into ~/.executor/backups/<stamp>/. The caller is
* responsible for stopping the sidecar first and restarting it after.
*/
export const resetExecutorState = (): ResetStateResult => {
const dataDir = join(homedir(), ".executor");
const backupDir = join(dataDir, "backups", backupStamp());
mkdirSync(backupDir, { recursive: true });
const moved: string[] = [];
for (const entry of STATE_ENTRIES) {
const from = join(dataDir, entry);
if (!existsSync(from)) continue;
renameSync(from, join(backupDir, entry));
moved.push(entry);
}
log.info("[reset-state] moved executor state to backup", { backupDir, moved });
return { backupDir, moved };
};

/**
* Confirmation dialog shared by every surface that offers a reset. Returns
* true when the user explicitly chose to reset.
*
* EXECUTOR_TEST_AUTO_CONFIRM_RESET=1 skips the dialog — native dialogs are
* unreachable from Playwright, and the e2e crash-recovery scenario needs to
* drive the full reset path.
*/
export const confirmResetState = async (): Promise<boolean> => {
if (process.env.EXECUTOR_TEST_AUTO_CONFIRM_RESET === "1") return true;
const { response } = await dialog.showMessageBox({
type: "warning",
title: "Reset Executor data?",
message: "Start over with a fresh data directory?",
detail:
"Your current data — integrations, connections, and history — will be moved to " +
"~/.executor/backups (not deleted), and Executor will restart with a clean slate. " +
"Use this when the app can't start because its data is damaged.",
buttons: ["Reset and back up", "Cancel"],
defaultId: 1,
cancelId: 1,
});
return response === 0;
};

/**
* Make the "your data is backed up" promise concrete after a reset: name the
* exact folder and offer to open it. Without this the user is told their data
* is safe somewhere but has no way to act on it.
*/
export const announceBackup = async (backupDir: string): Promise<void> => {
// Same test seam as confirmResetState: a modal with no one to dismiss it
// would hang the e2e reset path.
if (process.env.EXECUTOR_TEST_AUTO_CONFIRM_RESET === "1") {
log.info("[reset-state] backup announced (test mode, dialog skipped)", { backupDir });
return;
}
const { response } = await dialog.showMessageBox({
type: "info",
title: "Executor data reset",
message: "Your previous data has been backed up.",
detail:
`If you need anything from before the reset, it's all here:\n\n${backupDir}\n\n` +
"You can ignore this folder otherwise — Executor is now running on a fresh data directory.",
buttons: ["Show in folder", "OK"],
defaultId: 1,
cancelId: 1,
});
if (response === 0) shell.showItemInFolder(backupDir);
};
8 changes: 8 additions & 0 deletions apps/desktop/src/preload/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,14 @@ const api = {
checkForUpdates(): Promise<void> {
return ipcRenderer.invoke("executor:updates:check");
},
/**
* Last-resort recovery for damaged executor state: after a native confirm,
* back up the data dir (move-aside, never delete), then restart the
* sidecar fresh. Resolves false when the user cancels the confirm.
*/
resetState(): Promise<boolean> {
return ipcRenderer.invoke("executor:state:reset");
},
/**
* Crash-reporting config for the renderer. Null unless this desktop build
* shipped with a DSN baked in — the shared web UI only initializes its
Expand Down
Loading
Loading