diff --git a/apps/web/__tests__/unit/desktop-log-upload.test.ts b/apps/web/__tests__/unit/desktop-log-upload.test.ts new file mode 100644 index 0000000000..5bb4aede66 --- /dev/null +++ b/apps/web/__tests__/unit/desktop-log-upload.test.ts @@ -0,0 +1,258 @@ +import { describe, expect, it } from "vitest"; +import { analyzeDesktopDiagnostics } from "@/lib/desktop-diagnostic-analysis"; +import { + confirmedDesktopLogWebhookUrl, + createDesktopLogUpload, + diagnosticsSchema, +} from "@/lib/desktop-log-upload"; + +describe("desktop diagnostic uploads", () => { + it("identifies metadata omitted by the desktop request budget", async () => { + const form = createDesktopLogUpload({ + log: "recent log", + report: JSON.stringify({ capLogUploadOmission: "size_limit" }), + }); + const attachment = form.get("files[1]"); + if (!(attachment instanceof Blob)) throw new Error("Missing attachment"); + const parsed = JSON.parse(await attachment.text()); + expect(parsed.coverage.report).toBe("omitted_size_limit"); + expect(parsed.report).toBeUndefined(); + }); + it("requires confirmation of delivery while preserving the destination thread", () => { + const url = confirmedDesktopLogWebhookUrl( + "https://discord.com/api/webhooks/test/token?thread_id=123&wait=false", + ); + expect(url.searchParams.get("wait")).toBe("true"); + expect(url.searchParams.get("thread_id")).toBe("123"); + expect(url.pathname).toBe("/api/webhooks/test/token"); + }); + it("accepts Tauri device objects, GPUI names, and null OS fields", () => { + const tauri = diagnosticsSchema.parse({ + system: { macosVersion: null }, + cameras: [{ displayName: "Camera", deviceId: "device" }], + microphones: [{ name: "Microphone", channels: 2 }], + }); + expect(tauri.cameras).toEqual(["Camera"]); + expect(tauri.microphones).toEqual(["Microphone"]); + expect( + diagnosticsSchema.parse({ cameras: ["Camera"], microphones: [] }), + ).toMatchObject({ cameras: ["Camera"] }); + }); + + it("preserves the log, full diagnostic report, and operation context together", async () => { + const report = { schemaVersion: 1, recentRecordings: [{ mode: "studio" }] }; + const context = { + operations: { records: [{ operation: "export_mp4", elapsedMs: 123 }] }, + }; + const form = createDesktopLogUpload({ + log: "original log\n", + diagnostics: JSON.stringify({ hardware: { cpuCores: 8 } }), + report: JSON.stringify(report), + context: JSON.stringify(context), + }); + const log = form.get("files[0]"); + const diagnostic = form.get("files[1]"); + expect(log).toBeInstanceOf(Blob); + expect(diagnostic).toBeInstanceOf(Blob); + if (!(log instanceof Blob) || !(diagnostic instanceof Blob)) { + throw new Error("Missing upload attachments"); + } + expect(await log.text()).toBe("original log\n"); + const parsed = JSON.parse(await diagnostic.text()); + expect(parsed.report).toEqual(report); + expect(parsed.context).toEqual(context); + expect(parsed.diagnostics.hardware.cpuCores).toBe(8); + expect(parsed.coverage.context).toBe("included"); + expect( + JSON.parse(String(form.get("payload_json"))).allowed_mentions, + ).toEqual({ + parse: [], + }); + }); + + it("keeps legacy uploads working and makes invalid optional context visible", async () => { + const form = createDesktopLogUpload({ + log: "legacy log", + context: "{broken", + report: "x".repeat(2 * 1024 * 1024 + 1), + }); + const diagnostic = form.get("files[1]"); + if (!(diagnostic instanceof Blob)) throw new Error("Missing attachment"); + const parsed = JSON.parse(await diagnostic.text()); + expect(parsed.coverage).toMatchObject({ + diagnostics: "not_provided", + context: "invalid_json", + report: "omitted_size_limit", + }); + expect(parsed.report).toBeUndefined(); + expect(parsed.context).toBeUndefined(); + }); +}); + +const record = (overrides: { [key: string]: unknown } = {}) => ({ + schemaVersion: 2, + operationId: { session: 123, process: 456, sequence: 1 }, + parentOperationId: null, + revision: 1, + app: { + flavor: "tauri", + version: "test-build", + sourceRevision: "abc123", + sourceDirty: false, + debugBuild: false, + }, + os: "macos", + binaryArchitecture: "aarch64", + operation: "export_worker", + startedAtUnixMs: 123, + elapsedMs: 0, + stage: "started", + outcome: "in_progress", + fields: [{ name: "requested_fps", value: 60 }], + omittedFields: 0, + ...overrides, +}); +const logLine = (value: unknown) => `CAP_DIAGNOSTIC ${JSON.stringify(value)}\n`; + +describe("desktop diagnostic reconstruction", () => { + it("analyzes retained context beyond the former two-mebibyte scan limit", () => { + const analysis = analyzeDesktopDiagnostics( + logLine(record({ outcome: "returned_error" })) + + "ordinary log\n".repeat(200_000), + undefined, + ); + expect(analysis.counts.returnedError).toBe(1); + expect(analysis.coverage.logCharactersOmittedFromAnalysis).toBe(0); + }); + it("handles reversed rotated files and a delayed checkpoint after completion", () => { + const analysis = analyzeDesktopDiagnostics( + logLine(record({ revision: 3, outcome: "returned_ok", elapsedMs: 500 })) + + logLine(record({ revision: 2, stage: "rendering", elapsedMs: 600 })) + + logLine(record()), + { operations: { records: [record({ revision: 2 })] } }, + ); + expect(analysis.counts.returnedOk).toBe(1); + expect(analysis.counts.noTerminalRecord).toBe(0); + expect(analysis.operations[0]?.elapsedMs).toBe(500); + expect( + analysis.operations[0]?.stages.map((stage) => stage.revision), + ).toEqual([1, 2, 3]); + expect(analysis.operations[0]?.stages[1]).toEqual({ + revision: 2, + stage: "rendering", + elapsedMs: 600, + outcome: "in_progress", + }); + }); + + it("keeps the newest duplicate stage in either input order", () => { + const stale = record({ revision: 2, elapsedMs: 1 }); + const current = record({ revision: 2, stage: "rendering", elapsedMs: 600 }); + for (const [snapshot, logged] of [ + [stale, current], + [current, stale], + ]) { + const analysis = analyzeDesktopDiagnostics(logLine(logged), { + operations: { records: [snapshot] }, + }); + expect(analysis.operations[0]).toMatchObject({ + stage: "rendering", + elapsedMs: 600, + stages: [{ revision: 2, stage: "rendering", elapsedMs: 600 }], + }); + } + }); + + it("links a failed worker to its parent and retains the build and settings", () => { + const parent = record({ revision: 2, outcome: "returned_error" }); + const worker = record({ + operation: "export_mp4", + operationId: { session: 234, process: 567, sequence: 1 }, + parentOperationId: parent.operationId, + }); + const analysis = analyzeDesktopDiagnostics( + logLine(parent) + logLine(worker), + undefined, + ); + expect(analysis.counts.returnedError).toBe(1); + expect(analysis.counts.noTerminalRecord).toBe(1); + expect(analysis.operations[1]?.parentId).toBe(analysis.operations[0]?.id); + expect(analysis.operations[1]?.fields.requested_fps).toBe(60); + expect(analysis.operations[1]?.app?.version).toBe("test-build"); + }); + + it("exposes corrupt, unsupported and lost data without inventing a crash", () => { + const analysis = analyzeDesktopDiagnostics( + `legacy log\nCAP_DIAGNOSTIC {broken\n${logLine(record())}${logLine({ schemaVersion: 99 })}`, + { operations: { journalWriteFailures: 2, loggerDroppedMessages: 3 } }, + ); + expect(analysis.coverage).toMatchObject({ + invalidRecords: 1, + unsupportedRecords: 1, + journalWriteFailures: 2, + loggerDroppedMessages: 3, + }); + expect(analysis.counts.returnedError).toBe(0); + expect(analysis.counts.noTerminalRecord).toBe(1); + expect(analysis.missingTerminalSemantics).toBe( + "may_be_active_interrupted_or_missing_data", + ); + }); + + it("keeps recent incidents when retained history exceeds the operation limit", () => { + const log = Array.from({ length: 4200 }, (_, sequence) => + logLine( + record({ operationId: { session: 123, process: 456, sequence } }), + ), + ).join(""); + const analysis = analyzeDesktopDiagnostics(log, undefined); + expect(analysis.operations).toHaveLength(4096); + expect(analysis.coverage.recordsOmittedForOperationLimit).toBe(104); + expect( + analysis.operations.some((item) => item.operationId.sequence === 4199), + ).toBe(true); + expect( + analysis.operations.some((item) => item.operationId.sequence === 0), + ).toBe(false); + const current = record({ + operationId: { session: 123, process: 456, sequence: 4200 }, + }); + const withSnapshot = analyzeDesktopDiagnostics(log, { + operations: { records: [current] }, + }); + expect(withSnapshot.operations).toHaveLength(4096); + expect( + withSnapshot.operations.some( + (item) => item.operationId.sequence === 4200, + ), + ).toBe(true); + expect( + withSnapshot.operations.some( + (item) => item.operationId.sequence === 4199, + ), + ).toBe(true); + }); + + it("counts excluded records without claiming they are distinct operations", () => { + const old = [1, 2, 3] + .map((revision) => + logLine( + record({ + operationId: { session: 123, process: 456, sequence: 0 }, + revision, + }), + ), + ) + .join(""); + const recent = Array.from({ length: 4096 }, (_, index) => + logLine( + record({ + operationId: { session: 123, process: 456, sequence: index + 1 }, + }), + ), + ).join(""); + const analysis = analyzeDesktopDiagnostics(old + recent, undefined); + expect(analysis.operations).toHaveLength(4096); + expect(analysis.coverage.recordsOmittedForOperationLimit).toBe(3); + }); +}); diff --git a/apps/web/app/api/desktop/[...route]/root.ts b/apps/web/app/api/desktop/[...route]/root.ts index 9c14a656c7..7ae82cb19b 100644 --- a/apps/web/app/api/desktop/[...route]/root.ts +++ b/apps/web/app/api/desktop/[...route]/root.ts @@ -24,6 +24,10 @@ import { Effect, Option } from "effect"; import { type Context, Hono } from "hono"; import type Stripe from "stripe"; import { z } from "zod"; +import { + confirmedDesktopLogWebhookUrl, + createDesktopLogUpload, +} from "@/lib/desktop-log-upload"; import { getCheckoutRedirectUrls } from "@/lib/mobile-checkout"; import { runPromise } from "@/lib/server"; import { trackServerEvent } from "@/lib/server-analytics"; @@ -178,156 +182,6 @@ async function applyOrganizationLogoUpdate( }).pipe(runPromise); } -const diagnosticsSchema = z.object({ - system: z - .object({ - windowsVersion: z - .object({ - displayName: z.string(), - meetsRequirements: z.boolean().optional(), - isWindows11: z.boolean().optional(), - }) - .optional(), - macosVersion: z.object({ displayName: z.string() }).optional(), - linuxVersion: z.object({ displayName: z.string() }).optional(), - gpuInfo: z - .object({ - vendor: z.string(), - description: z.string(), - dedicatedVideoMemoryMb: z.number().optional(), - isSoftwareAdapter: z.boolean().optional(), - isBasicRenderDriver: z.boolean().optional(), - supportsHardwareEncoding: z.boolean().optional(), - }) - .optional(), - allGpus: z - .object({ - gpus: z.array( - z.object({ - vendor: z.string(), - description: z.string(), - dedicatedVideoMemoryMb: z.number().optional(), - }), - ), - isMultiGpuSystem: z.boolean().optional(), - hasDiscreteGpu: z.boolean().optional(), - }) - .optional(), - renderingStatus: z - .object({ - isUsingSoftwareRendering: z.boolean().optional(), - isUsingBasicRenderDriver: z.boolean().optional(), - hardwareEncodingAvailable: z.boolean().optional(), - warningMessage: z.string().optional(), - }) - .optional(), - availableEncoders: z.array(z.string()).optional(), - graphicsCaptureSupported: z.boolean().optional(), - screenCaptureSupported: z.boolean().optional(), - d3D11VideoProcessorAvailable: z.boolean().optional(), - }) - .optional(), - cameras: z.array(z.string()).optional(), - microphones: z.array(z.string()).optional(), - permissions: z - .object({ - screenRecording: z.string().optional(), - camera: z.string().optional(), - microphone: z.string().optional(), - }) - .optional(), -}); - -function formatDiagnosticsForDiscord( - diagnostics: z.infer, -): string { - const lines: string[] = []; - const sys = diagnostics.system; - - if (sys?.windowsVersion?.displayName) { - lines.push(`**OS:** ${sys.windowsVersion.displayName}`); - } else if (sys?.macosVersion?.displayName) { - lines.push(`**OS:** ${sys.macosVersion.displayName}`); - } else if (sys?.linuxVersion?.displayName) { - lines.push(`**OS:** ${sys.linuxVersion.displayName}`); - } - - if (sys?.gpuInfo) { - const gpu = sys.gpuInfo; - let gpuLine = `**GPU:** ${gpu.description}`; - if (gpu.vendor) gpuLine += ` (${gpu.vendor})`; - if (gpu.dedicatedVideoMemoryMb) - gpuLine += ` - ${gpu.dedicatedVideoMemoryMb}MB VRAM`; - lines.push(gpuLine); - - const flags: string[] = []; - if (gpu.isSoftwareAdapter) flags.push("⚠️ Software Adapter"); - if (gpu.isBasicRenderDriver) flags.push("⚠️ Basic Render Driver"); - if (gpu.supportsHardwareEncoding === false) flags.push("❌ No HW Encoding"); - if (gpu.supportsHardwareEncoding === true) flags.push("✅ HW Encoding"); - if (flags.length > 0) lines.push(`**GPU Status:** ${flags.join(", ")}`); - } - - if (sys?.allGpus?.gpus && sys.allGpus.gpus.length > 1) { - const gpuList = sys.allGpus.gpus - .map((g) => `${g.description} (${g.vendor})`) - .join(", "); - lines.push(`**All GPUs:** ${gpuList}`); - } - - if (sys?.renderingStatus?.warningMessage) { - lines.push(`**⚠️ Warning:** ${sys.renderingStatus.warningMessage}`); - } - - const captureSupported = - sys?.graphicsCaptureSupported ?? sys?.screenCaptureSupported; - if (captureSupported !== undefined) { - lines.push( - `**Screen Capture:** ${captureSupported ? "✅ Supported" : "❌ Not Supported"}`, - ); - } - - if (sys?.d3D11VideoProcessorAvailable !== undefined) { - lines.push( - `**D3D11 Video Processor:** ${sys.d3D11VideoProcessorAvailable ? "✅" : "❌"}`, - ); - } - - if (sys?.availableEncoders && sys.availableEncoders.length > 0) { - lines.push(`**Encoders:** ${sys.availableEncoders.join(", ")}`); - } - - if (diagnostics.permissions) { - const perms = diagnostics.permissions; - const permList = [ - perms.screenRecording && `Screen: ${perms.screenRecording}`, - perms.camera && `Camera: ${perms.camera}`, - perms.microphone && `Mic: ${perms.microphone}`, - ] - .filter(Boolean) - .join(", "); - if (permList) lines.push(`**Permissions:** ${permList}`); - } - - if (diagnostics.cameras && diagnostics.cameras.length > 0) { - lines.push( - `**Cameras (${diagnostics.cameras.length}):** ${diagnostics.cameras.join(", ")}`, - ); - } else { - lines.push("**Cameras:** None detected"); - } - - if (diagnostics.microphones && diagnostics.microphones.length > 0) { - lines.push( - `**Mics (${diagnostics.microphones.length}):** ${diagnostics.microphones.join(", ")}`, - ); - } else { - lines.push("**Mics:** None detected"); - } - - return lines.join("\n"); -} - app.post( "/logs", zValidator( @@ -337,6 +191,8 @@ app.post( os: z.string().optional(), version: z.string().optional(), diagnostics: z.string().optional(), + report: z.string().optional(), + context: z.string().optional(), }), ), withOptionalAuth, @@ -346,6 +202,8 @@ app.post( os, version, diagnostics: diagnosticsJson, + report, + context, } = c.req.valid("form"); const user = c.get("user"); @@ -354,43 +212,24 @@ app.post( if (!discordWebhookUrl) throw new Error("Discord webhook URL is not configured"); - const formData = new FormData(); - const logBlob = new Blob([log], { type: "text/plain" }); - const fileName = `cap-desktop-${os || "unknown"}-${version || "unknown"}-${Date.now()}.log`; - formData.append("file", logBlob, fileName); - - let diagnosticsContent = ""; - if (diagnosticsJson) { - try { - const parsed = JSON.parse(diagnosticsJson); - const validated = diagnosticsSchema.safeParse(parsed); - if (validated.success) { - diagnosticsContent = formatDiagnosticsForDiscord(validated.data); - } - } catch { - diagnosticsContent = ""; - } - } - - const content = [ - "📋 **New Log File Uploaded**", - "", - user ? `**User:** ${user.email} (${user.id})` : null, - os ? `**Platform:** ${os}` : null, - version ? `**App Version:** ${version}` : null, - diagnosticsContent ? "" : null, - diagnosticsContent || null, - ] - .filter((line): line is string => line !== null) - .join("\n"); - - formData.append("content", content); - - const response = await fetch(discordWebhookUrl, { - method: "POST", - body: formData, + const formData = createDesktopLogUpload({ + log, + os, + version, + diagnostics: diagnosticsJson, + report, + context, + user: user ? { email: user.email, id: user.id } : undefined, }); + const response = await fetch( + confirmedDesktopLogWebhookUrl(discordWebhookUrl), + { + method: "POST", + body: formData, + }, + ); + if (!response.ok) throw new Error( `Failed to send logs to Discord: ${response.statusText}`, diff --git a/apps/web/lib/desktop-diagnostic-analysis.ts b/apps/web/lib/desktop-diagnostic-analysis.ts new file mode 100644 index 0000000000..ba8d7753f2 --- /dev/null +++ b/apps/web/lib/desktop-diagnostic-analysis.ts @@ -0,0 +1,203 @@ +import { z } from "zod"; + +const MAX_SCAN_CHARACTERS = 4 * 1024 * 1024; +const MAX_OPERATIONS = 4096; +const label = z.string().regex(/^[a-z][a-z0-9_]{0,63}$/); +const counter = z.number().int().nonnegative().safe(); +const identity = z.object({ + session: counter, + process: counter, + sequence: counter, +}); +const recordSchema = z.object({ + schemaVersion: z.literal(2), + operationId: identity, + parentOperationId: identity.nullish(), + revision: counter, + app: z + .object({ + flavor: label, + version: z.string().max(128), + sourceRevision: z.string().max(64).nullish(), + sourceDirty: z.boolean().nullish(), + debugBuild: z.boolean(), + }) + .nullish(), + os: label, + binaryArchitecture: label, + operation: label, + startedAtUnixMs: counter, + elapsedMs: counter, + stage: label, + outcome: z.enum([ + "in_progress", + "returned_ok", + "returned_error", + "incomplete", + "observed", + ]), + fields: z + .array( + z + .object({ + name: label, + value: z.union([counter, z.boolean(), z.string().max(128)]), + }) + .nullable(), + ) + .max(16), + omittedFields: counter, +}); +type Record = z.infer; +type Stage = Pick; + +function operationKey(id: z.infer) { + return `${id.session}:${id.process}:${id.sequence}`; +} + +function isObject(value: unknown): value is { [key: string]: unknown } { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +export function analyzeDesktopDiagnostics(log: string, context: unknown) { + const operations = new Map< + string, + { latest: Record; stages: Map; updatesObserved: number } + >(); + let invalidRecords = 0; + let unsupportedRecords = 0; + let recordsOmittedForOperationLimit = 0; + let omittedStages = 0; + const accept = (value: unknown) => { + if (isObject(value) && value.schemaVersion !== 2) { + unsupportedRecords++; + return; + } + const parsed = recordSchema.safeParse(value); + if (!parsed.success) { + invalidRecords++; + return; + } + const record = parsed.data; + const key = operationKey(record.operationId); + let operation = operations.get(key); + if (!operation) { + if (operations.size >= MAX_OPERATIONS) { + recordsOmittedForOperationLimit++; + return; + } + operation = { latest: record, stages: new Map(), updatesObserved: 0 }; + operations.set(key, operation); + } + operation.updatesObserved++; + if ( + record.revision > operation.latest.revision || + (record.revision === operation.latest.revision && + record.elapsedMs > operation.latest.elapsedMs) + ) { + operation.latest = record; + } + const previousStage = operation.stages.get(record.revision); + if (!previousStage || record.elapsedMs > previousStage.elapsedMs) { + if (!previousStage && operation.stages.size >= 32) { + omittedStages++; + } else { + operation.stages.set(record.revision, { + revision: record.revision, + stage: record.stage, + elapsedMs: record.elapsedMs, + outcome: record.outcome, + }); + } + } + }; + const snapshot = + isObject(context) && isObject(context.operations) + ? context.operations + : undefined; + if (Array.isArray(snapshot?.records)) { + for (const record of snapshot.records.slice(-64)) accept(record); + } + const scanned = log.slice(-MAX_SCAN_CHARACTERS); + for (const line of scanned.split("\n").reverse()) { + if (!line.startsWith("CAP_DIAGNOSTIC ")) continue; + if (line.length > 4096) { + invalidRecords++; + continue; + } + try { + accept(JSON.parse(line.slice("CAP_DIAGNOSTIC ".length))); + } catch { + invalidRecords++; + } + } + const reconstructed = [...operations.entries()] + .map(([id, { latest, stages, updatesObserved }]) => ({ + id, + parentId: latest.parentOperationId + ? operationKey(latest.parentOperationId) + : null, + ...latest, + fields: Object.fromEntries( + latest.fields + .filter((field) => field !== null) + .map((field) => [field.name, field.value]), + ), + stages: [...stages.values()].sort((a, b) => a.revision - b.revision), + updatesObserved, + })) + .sort( + (a, b) => + a.startedAtUnixMs - b.startedAtUnixMs || + a.operationId.session - b.operationId.session || + a.operationId.process - b.operationId.process || + a.operationId.sequence - b.operationId.sequence, + ); + const counts = { + observedOperations: reconstructed.length, + returnedOk: 0, + returnedError: 0, + incomplete: 0, + noTerminalRecord: 0, + healthObservations: 0, + }; + for (const record of reconstructed) { + switch (record.outcome) { + case "returned_ok": + counts.returnedOk++; + break; + case "returned_error": + counts.returnedError++; + break; + case "incomplete": + counts.incomplete++; + break; + case "in_progress": + counts.noTerminalRecord++; + break; + case "observed": + counts.healthObservations++; + break; + } + } + return { + schemaVersion: 1, + scope: "uploaded_sample_only", + outcomeSemantics: "return_value_only_not_media_validation", + missingTerminalSemantics: "may_be_active_interrupted_or_missing_data", + healthEventScope: "process_only_not_proof_of_operation_causation", + counts, + coverage: { + invalidRecords, + unsupportedRecords, + recordsOmittedForOperationLimit, + omittedStages, + logCharactersOmittedFromAnalysis: log.length - scanned.length, + journalWriteFailures: snapshot?.journalWriteFailures ?? null, + loggerDroppedMessages: snapshot?.loggerDroppedMessages ?? null, + omittedRecords: snapshot?.omittedRecords ?? null, + historyDroppedUpdates: snapshot?.droppedUpdates ?? null, + }, + operations: reconstructed, + }; +} diff --git a/apps/web/lib/desktop-log-upload.ts b/apps/web/lib/desktop-log-upload.ts new file mode 100644 index 0000000000..ef8d8204b2 --- /dev/null +++ b/apps/web/lib/desktop-log-upload.ts @@ -0,0 +1,273 @@ +import { z } from "zod"; +import { analyzeDesktopDiagnostics } from "./desktop-diagnostic-analysis"; + +const MAX_CONTEXT_BYTES = 512 * 1024; +const MAX_REPORT_BYTES = 2 * 1024 * 1024; + +type DesktopLogUpload = { + log: string; + os?: string; + version?: string; + diagnostics?: string; + report?: string; + context?: string; + user?: { email: string; id: string }; +}; + +export function confirmedDesktopLogWebhookUrl(value: string): URL { + const url = new URL(value); + url.searchParams.set("wait", "true"); + return url; +} + +function parseAttachment(value: string | undefined, maxBytes: number) { + if (value === undefined) return { status: "not_provided" }; + if (value.length > maxBytes || Buffer.byteLength(value, "utf8") > maxBytes) { + return { status: "omitted_size_limit" }; + } + try { + const parsed: unknown = JSON.parse(value); + if ( + typeof parsed !== "object" || + parsed === null || + Array.isArray(parsed) + ) { + return { status: "invalid_object" }; + } + if ( + "capLogUploadOmission" in parsed && + parsed.capLogUploadOmission === "size_limit" + ) { + return { status: "omitted_size_limit" }; + } + return { status: "included", value: parsed }; + } catch { + return { status: "invalid_json" }; + } +} + +export function createDesktopLogUpload(input: DesktopLogUpload): FormData { + const diagnostics = parseAttachment(input.diagnostics, MAX_CONTEXT_BYTES); + const report = parseAttachment(input.report, MAX_REPORT_BYTES); + const context = parseAttachment(input.context, MAX_CONTEXT_BYTES); + const validated = diagnosticsSchema.safeParse(diagnostics.value); + const diagnosticsContent = validated.success + ? formatDiagnosticsForDiscord(validated.data) + : ""; + const uploadId = crypto.randomUUID(); + const analysis = analyzeDesktopDiagnostics(input.log, context.value); + const attachment = { + schemaVersion: 1, + uploadId, + receivedAt: new Date().toISOString(), + os: input.os, + version: input.version, + diagnostics: diagnostics.value, + report: report.value, + context: context.value, + analysis, + coverage: { + diagnostics: diagnostics.status, + diagnosticsSummary: validated.success ? "available" : "unavailable", + report: report.status, + context: context.status, + }, + }; + const form = new FormData(); + form.append( + "files[0]", + new Blob([input.log], { type: "text/plain" }), + `cap-desktop-${uploadId}.log`, + ); + form.append( + "files[1]", + new Blob([JSON.stringify(attachment)], { type: "application/json" }), + `cap-diagnostics-${uploadId}.json`, + ); + const content = [ + "📋 **New Log File Uploaded**", + `**Upload:** ${uploadId}`, + input.user ? `**User:** ${input.user.email} (${input.user.id})` : null, + input.os ? `**Platform:** ${input.os}` : null, + input.version ? `**App Version:** ${input.version}` : null, + `**Operations:** ${analysis.counts.observedOperations} observed; ${analysis.counts.returnedError} returned errors; ${analysis.counts.noTerminalRecord} without a completion record; ${analysis.counts.healthObservations} health observations`, + diagnosticsContent || null, + `**Context:** ${context.status}; **Report:** ${report.status}`, + ] + .filter((line): line is string => line !== null) + .join("\n") + .slice(0, 1950); + form.append( + "payload_json", + JSON.stringify({ content, allowed_mentions: { parse: [] } }), + ); + return form; +} + +export const diagnosticsSchema = z.object({ + system: z + .object({ + windowsVersion: z + .object({ + displayName: z.string(), + meetsRequirements: z.boolean().nullish(), + isWindows11: z.boolean().nullish(), + }) + .nullish(), + macosVersion: z.object({ displayName: z.string() }).nullish(), + linuxVersion: z.object({ displayName: z.string() }).nullish(), + gpuInfo: z + .object({ + vendor: z.string(), + description: z.string(), + dedicatedVideoMemoryMb: z.number().nullish(), + isSoftwareAdapter: z.boolean().nullish(), + isBasicRenderDriver: z.boolean().nullish(), + supportsHardwareEncoding: z.boolean().nullish(), + }) + .nullish(), + allGpus: z + .object({ + gpus: z.array( + z.object({ + vendor: z.string(), + description: z.string(), + dedicatedVideoMemoryMb: z.number().nullish(), + }), + ), + isMultiGpuSystem: z.boolean().nullish(), + hasDiscreteGpu: z.boolean().nullish(), + }) + .nullish(), + renderingStatus: z + .object({ + isUsingSoftwareRendering: z.boolean().nullish(), + isUsingBasicRenderDriver: z.boolean().nullish(), + hardwareEncodingAvailable: z.boolean().nullish(), + warningMessage: z.string().nullish(), + }) + .nullish(), + availableEncoders: z.array(z.string()).nullish(), + graphicsCaptureSupported: z.boolean().nullish(), + screenCaptureSupported: z.boolean().nullish(), + d3D11VideoProcessorAvailable: z.boolean().nullish(), + }) + .nullish(), + cameras: z + .array( + z.union([ + z.string(), + z + .object({ displayName: z.string() }) + .transform((camera) => camera.displayName), + ]), + ) + .nullish(), + microphones: z + .array( + z.union([ + z.string(), + z + .object({ name: z.string() }) + .transform((microphone) => microphone.name), + ]), + ) + .nullish(), + permissions: z + .object({ + screenRecording: z.string().nullish(), + camera: z.string().nullish(), + microphone: z.string().nullish(), + }) + .nullish(), +}); + +export function formatDiagnosticsForDiscord( + diagnostics: z.infer, +): string { + const lines: string[] = []; + const sys = diagnostics.system; + + if (sys?.windowsVersion?.displayName) { + lines.push(`**OS:** ${sys.windowsVersion.displayName}`); + } else if (sys?.macosVersion?.displayName) { + lines.push(`**OS:** ${sys.macosVersion.displayName}`); + } else if (sys?.linuxVersion?.displayName) { + lines.push(`**OS:** ${sys.linuxVersion.displayName}`); + } + + if (sys?.gpuInfo) { + const gpu = sys.gpuInfo; + let gpuLine = `**GPU:** ${gpu.description}`; + if (gpu.vendor) gpuLine += ` (${gpu.vendor})`; + if (gpu.dedicatedVideoMemoryMb) + gpuLine += ` - ${gpu.dedicatedVideoMemoryMb}MB VRAM`; + lines.push(gpuLine); + + const flags: string[] = []; + if (gpu.isSoftwareAdapter) flags.push("⚠️ Software Adapter"); + if (gpu.isBasicRenderDriver) flags.push("⚠️ Basic Render Driver"); + if (gpu.supportsHardwareEncoding === false) flags.push("❌ No HW Encoding"); + if (gpu.supportsHardwareEncoding === true) flags.push("✅ HW Encoding"); + if (flags.length > 0) lines.push(`**GPU Status:** ${flags.join(", ")}`); + } + + if (sys?.allGpus?.gpus && sys.allGpus.gpus.length > 1) { + const gpuList = sys.allGpus.gpus + .map((g) => `${g.description} (${g.vendor})`) + .join(", "); + lines.push(`**All GPUs:** ${gpuList}`); + } + + if (sys?.renderingStatus?.warningMessage) { + lines.push(`**⚠️ Warning:** ${sys.renderingStatus.warningMessage}`); + } + + const captureSupported = + sys?.graphicsCaptureSupported ?? sys?.screenCaptureSupported; + if (captureSupported != null) { + lines.push( + `**Screen Capture:** ${captureSupported ? "✅ Supported" : "❌ Not Supported"}`, + ); + } + + if (sys?.d3D11VideoProcessorAvailable != null) { + lines.push( + `**D3D11 Video Processor:** ${sys.d3D11VideoProcessorAvailable ? "✅" : "❌"}`, + ); + } + + if (sys?.availableEncoders && sys.availableEncoders.length > 0) { + lines.push(`**Encoders:** ${sys.availableEncoders.join(", ")}`); + } + + if (diagnostics.permissions) { + const perms = diagnostics.permissions; + const permList = [ + perms.screenRecording && `Screen: ${perms.screenRecording}`, + perms.camera && `Camera: ${perms.camera}`, + perms.microphone && `Mic: ${perms.microphone}`, + ] + .filter(Boolean) + .join(", "); + if (permList) lines.push(`**Permissions:** ${permList}`); + } + + if (diagnostics.cameras && diagnostics.cameras.length > 0) { + lines.push( + `**Cameras (${diagnostics.cameras.length}):** ${diagnostics.cameras.join(", ")}`, + ); + } else if (diagnostics.cameras) { + lines.push("**Cameras:** None detected"); + } + + if (diagnostics.microphones && diagnostics.microphones.length > 0) { + lines.push( + `**Mics (${diagnostics.microphones.length}):** ${diagnostics.microphones.join(", ")}`, + ); + } else if (diagnostics.microphones) { + lines.push("**Mics:** None detected"); + } + + return lines.join("\n"); +}