Skip to content
Open
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
118 changes: 69 additions & 49 deletions electron/ipc/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1353,6 +1353,12 @@ function readNativeWindowsEncoderSelection(output: string) {
// which is what `salvageNativeWindowsFragmentedCapture` asks.
container?: string;
preferSoftwareEncoder?: boolean;
// Whether BeginWriting() actually landed on a hardware H.264 MFT, as
// opposed to `video` above, which only says which configuration path
// was tried. "default" plus a software runtime means the machine never
// got hardware acceleration in the first place -- see
// kVideoEncoderRuntime* in mf_encoder.h.
videoEncoderRuntime?: string;
};
} catch {
return null;
Expand Down Expand Up @@ -1658,6 +1664,66 @@ async function resolveMediaLinksForVideo(videoPath: string): Promise<{
return { resolvedVia: "none" };
}

/**
* Writes the diagnostic bundle a bug report needs: app/OS facts, the native
* helpers' raw stdout/stderr (which is where `[stop-timing]` and
* `encoder-selection` land — see nativeWindowsCaptureStop.ts), and the main
* process's own recent console output. Shared by the renderer's IPC call and
* the menu/tray "Save Diagnostics" entry point in main.ts, which has no
* renderer-side `projectState`/`logs` to offer and does not need to.
*/
export async function exportDiagnosticFile(payload: {
error: string;
stack?: string;
projectState: unknown;
logs: string[];
}) {
const { filePath, canceled } = await dialog.showSaveDialog({
title: "Save Diagnostic File",
defaultPath: `openscreen-diagnostic-${Date.now()}.json`,
filters: [{ name: "JSON", extensions: ["json"] }],
});

if (canceled || !filePath) return { success: false, canceled: true };

const HELPER_OUTPUT_MAX_BYTES = 64 * 1024;
const tail = (s: string, max: number) => (s.length <= max ? s : s.slice(s.length - max));

const diagnostic = {
timestamp: new Date().toISOString(),
appVersion: app.getVersion(),
platform: process.platform,
arch: process.arch,
// The same fact the About box leads with, and for the same reason: it is what
// explains why a copy does or does not offer an update check. This file is the
// artifact users actually attach, so it must not be the one that omits it.
channel: getInstallChannel(),
osRelease: os.release(),
osVersion: os.version(),
totalMemoryMB: Math.round(os.totalmem() / 1024 / 1024),
nodeVersion: process.versions.node,
electronVersion: process.versions.electron,
chromeVersion: process.versions.chrome,
error: payload.error,
stack: payload.stack,
projectState: payload.projectState,
recentLogs: payload.logs,
helperOutput: {
windows: tail(nativeWindowsCaptureOutput, HELPER_OUTPUT_MAX_BYTES),
mac: tail(nativeMacCaptureOutput, HELPER_OUTPUT_MAX_BYTES),
},
mainProcessLogs: mainLogBuffer.snapshot(),
};

try {
await fs.writeFile(filePath, JSON.stringify(diagnostic, null, 2), "utf-8");
return { success: true, path: filePath };
} catch (error) {
console.error("Failed to write diagnostic file:", error);
return { success: false, error: String(error) };
}
}

export function registerIpcHandlers(
createEditorWindow: () => void,
createSourceSelectorWindow: () => BrowserWindow,
Expand Down Expand Up @@ -2531,6 +2597,7 @@ export function registerIpcHandlers(
path: outputPath,
helperPath,
videoEncoderSelection: encoderSelection?.video ?? null,
videoEncoderRuntime: encoderSelection?.videoEncoderRuntime ?? null,
webcamUnavailable,
microphoneDefaulted,
};
Expand Down Expand Up @@ -4085,55 +4152,8 @@ export function registerIpcHandlers(

ipcMain.handle(
"save-diagnostic",
async (
_,
payload: { error: string; stack?: string; projectState: unknown; logs: string[] },
) => {
const { filePath, canceled } = await dialog.showSaveDialog({
title: "Save Diagnostic File",
defaultPath: `openscreen-diagnostic-${Date.now()}.json`,
filters: [{ name: "JSON", extensions: ["json"] }],
});

if (canceled || !filePath) return { success: false, canceled: true };

const HELPER_OUTPUT_MAX_BYTES = 64 * 1024;
const tail = (s: string, max: number) => (s.length <= max ? s : s.slice(s.length - max));

const diagnostic = {
timestamp: new Date().toISOString(),
appVersion: app.getVersion(),
platform: process.platform,
arch: process.arch,
// The same fact the About box leads with, and for the same reason: it is what
// explains why a copy does or does not offer an update check. This file is the
// artifact users actually attach, so it must not be the one that omits it.
channel: getInstallChannel(),
osRelease: os.release(),
osVersion: os.version(),
totalMemoryMB: Math.round(os.totalmem() / 1024 / 1024),
nodeVersion: process.versions.node,
electronVersion: process.versions.electron,
chromeVersion: process.versions.chrome,
error: payload.error,
stack: payload.stack,
projectState: payload.projectState,
recentLogs: payload.logs,
helperOutput: {
windows: tail(nativeWindowsCaptureOutput, HELPER_OUTPUT_MAX_BYTES),
mac: tail(nativeMacCaptureOutput, HELPER_OUTPUT_MAX_BYTES),
},
mainProcessLogs: mainLogBuffer.snapshot(),
};

try {
await fs.writeFile(filePath, JSON.stringify(diagnostic, null, 2), "utf-8");
return { success: true, path: filePath };
} catch (error) {
console.error("Failed to write diagnostic file:", error);
return { success: false, error: String(error) };
}
},
async (_, payload: { error: string; stack?: string; projectState: unknown; logs: string[] }) =>
exportDiagnosticFile(payload),
);

// One instance each, not one per call. DocumentService serialises saves of a
Expand Down
65 changes: 64 additions & 1 deletion electron/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,11 @@ import {
} from "./globalShortcut";
import { mainT, setMainLocale } from "./i18n";
import { getInstallChannel, offersUpdateCheck, platformOwnsUpdates } from "./install-channel";
import { getSelectedDesktopSource, registerIpcHandlers } from "./ipc/handlers";
import {
exportDiagnosticFile,
getSelectedDesktopSource,
registerIpcHandlers,
} from "./ipc/handlers";
import { installMainProcessErrorGuards } from "./main-process-errors";
import { registerSttIpc, shutdownStt } from "./stt";
import { checkLatestRelease } from "./update-checker";
Expand Down Expand Up @@ -211,6 +215,11 @@ function setupApplicationMenu() {
role: "about",
label: mainT("common", "actions.about") || "About OpenScreen",
},
{ type: "separator" as const },
{
label: mainT("common", "actions.saveDiagnostics") || "Save Diagnostics",
click: runSaveDiagnostics,
},
// Omitted entirely — here, in the Help menu and in the tray — where a package
// manager owns the update. See `canOfferUpdateCheck`.
...(canOfferUpdateCheck()
Expand Down Expand Up @@ -369,6 +378,11 @@ function setupApplicationMenu() {
label: mainT("common", "actions.about") || "About OpenScreen",
click: runAboutDialog,
},
{ type: "separator" as const },
{
label: mainT("common", "actions.saveDiagnostics") || "Save Diagnostics",
click: runSaveDiagnostics,
},
],
});
}
Expand Down Expand Up @@ -519,6 +533,47 @@ function runUpdateCheck() {
});
}

/**
* Menu and tray entry point for exporting a diagnostic bundle. The backend
* (`exportDiagnosticFile`) and its "Save Diagnostics" label already existed —
* nothing in the app ever called it (getopenscreen/openscreen#460). Reveals
* the written file on success, the same confirmation the export flow's "Show
* in folder" gives, so there is no need for a second dialog on top of the
* native Save dialog the user already went through.
*
* No renderer `projectState`/`logs` to attach from here, unlike the in-app
* crash path this shares a payload shape with — the diagnostic value for a
* capture bug is almost entirely `helperOutput`/`mainProcessLogs`, which
* `exportDiagnosticFile` reads straight from the main process regardless.
*/
function runSaveDiagnostics() {
exportDiagnosticFile({ error: "Manual diagnostic export", projectState: null, logs: [] })
.then((result) => {
if (result.canceled) return;
if (!result.success) {
// exportDiagnosticFile resolves rather than rejects on a write
// failure, so this is the branch that turns "user picked a save
// location and got silence" into a visible error instead of a
// menu action that looks like it did nothing.
showMessageBox({
type: "error",
title: PRODUCT_NAME,
message: mainT("dialogs", "export.failed") || "Export Failed",
detail: result.error,
}).catch((error) => {
console.error("[diagnostics] failure dialog failed", error);
});
return;
}
if (result.path) {
shell.showItemInFolder(result.path);
}
})
.catch((error) => {
console.error("[diagnostics] save failed", error);
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

/** Mirrors the flag that already drives the tray icon. An update must never interrupt a take —
* and on Windows it physically cannot, because the capture helpers spawn from inside the
* install directory and NSIS cannot overwrite a running .exe. */
Expand Down Expand Up @@ -730,6 +785,14 @@ function updateTrayMenu(recording: boolean = false) {
label: mainT("common", "actions.about") || "About OpenScreen",
click: runAboutDialog,
},
// Right next to About, and reachable without opening any window: this is the
// one place in the app most likely to still be usable right after a recording
// failed to stop, which is exactly when the [stop-timing]/encoder-selection
// lines this exports are worth the most (getopenscreen/openscreen#460).
{
label: mainT("common", "actions.saveDiagnostics") || "Save Diagnostics",
click: runSaveDiagnostics,
},
{ type: "separator" as const },
{
label: mainT("common", "actions.quit") || "Quit",
Expand Down
9 changes: 8 additions & 1 deletion electron/native/wgc-capture/src/main.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -865,7 +865,14 @@ int main(int argc, char* argv[]) {
<< "\",\"container\":\"" << encoder.containerFormat()
<< "\",\"preferSoftwareEncoder\":"
<< (config.preferSoftwareEncoder ? "true" : "false")
<< "}" << std::endl;
// What BeginWriting() actually landed on, not what the "video"
// field above asked for -- see kVideoEncoderRuntime* in
// mf_encoder.h. "default" plus "software" here means the machine
// never got a hardware encoder in the first place, which is a
// different bug report than "default" plus "hardware" stalling
// on stop.
<< ",\"videoEncoderRuntime\":\"" << encoder.videoEncoderRuntime()
<< "\"}" << std::endl;
MFEncoder webcamEncoder;
if (writeSeparateWebcam) {
MFEncoderOptions webcamEncoderOptions = encoderOptions;
Expand Down
Loading