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
2 changes: 1 addition & 1 deletion .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ jobs:
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
npx vite build
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
npx electron-builder --win dir nsis --x64 --publish never
npx electron-builder --win dir nsis portable --x64 --publish never

- name: Smoke test packaged Windows paths
run: npm run smoke:packaged-binaries
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -449,7 +449,7 @@ jobs:
npm run build:platform-native-helpers
npx tsc
npx vite build
npx electron-builder --win dir nsis --x64 --publish never
npx electron-builder --win dir nsis portable --x64 --publish never

- name: Smoke test packaged Windows x64 paths
run: npm run smoke:packaged-binaries
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/winget-releaser.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ jobs:
- uses: vedantmgoyal9/winget-releaser@v2
with:
identifier: Webadderall.Recordly
installers-regex: 'windows\-x64\.exe$'
installers-regex: 'windows\-setup\-x64\.exe$'
version: ${{ steps.tag.outputs.tag }}
release-tag: ${{ steps.tag.outputs.tag }}
token: ${{ secrets.WINGET_ACC_TOKEN }}
18 changes: 12 additions & 6 deletions electron-builder.json5
Original file line number Diff line number Diff line change
Expand Up @@ -103,12 +103,18 @@
},
"win": {
"target": [
"nsis"
"nsis",
"portable"
],
"icon": "icons/icons/win/icon.ico"
,
"executableName": "Recordly",
"artifactName": "${productName}-windows-${arch}.${ext}"
"icon": "icons/icons/win/icon.ico",
"executableName": "Recordly"
},
"nsis": {
"oneClick": false,
"allowToChangeInstallationDirectory": true,
"artifactName": "${productName}-windows-setup-${arch}.${ext}"
},
"portable": {
"artifactName": "${productName}-windows-portable-${arch}.${ext}"
Comment on lines +106 to +118

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

rg -n -C 5 \
  'electron-updater|autoUpdater|checkForUpdates|checkForUpdatesAndNotify|PORTABLE_EXECUTABLE_(FILE|DIR)' \
  --glob '*.ts' --glob '*.tsx' --glob '*.js' --glob '*.mjs' --glob '*.json' --glob '*.json5' . || true

Repository: webadderallorg/Recordly

Length of output: 12176


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '--- electron/updater.ts top-level and relevant blocks ---\n'
sed -n '1,100p' electron/updater.ts
sed -n '330,450p' electron/updater.ts
sed -n '560,660p' electron/updater.ts

printf '\n--- package manager files ---\n'
[ -f package.json ] && cat package.json | sed -n '1,80p'
[ -f electron-builder.json5 ] && sed -n '1,130p' electron-builder.json5

printf '\n--- files mentioning update initialization call / environment overrides ---\n'
rg -n -C 4 'initUpdater|RECORDLY_DISABLE_AUTO_UPDATES|AUTO_UPDATES_DISABLED|publishAutoUpdate|electronbuilder|publisher' --glob '*.ts' --glob '*.tsx' --glob '*.js' --glob '*.json' --glob '*.json5' . || true

Repository: webadderallorg/Recordly

Length of output: 252


🌐 Web query:

electron-builder 26.7.0 portable target autoUpdater portable auto-updates manual download only

💡 Result:

In electron-builder, the portable target is explicitly designed for scenarios where no installation is desired, and as a result, it does not support the built-in auto-update mechanism provided by the electron-updater package [1][2]. Official documentation and maintainer guidance confirm that auto-updates are currently only supported for standard installer-based targets on Windows, such as the NSIS target [1][3][4]. Because the portable target does not produce the necessary metadata (such as latest.yml) required for the electron-updater to function, you cannot use the standard auto-update flow [3][5][2]. To achieve update functionality for a portable application, you must implement a custom solution [1][2]. Common approaches for implementing custom "manual download only" updates include: 1. Custom Update Check: Your application can periodically query a remote JSON file (hosted on your own server or GitHub releases) that contains version and download URL information [3][5]. 2. Manual Download & Replace: If a newer version is detected, you can prompt the user to download the new executable [6][7]. Since a portable executable cannot easily self-update (replace its own file while running), this process typically involves: - Directing the user to a download page via the browser (shell.openExternal). - Downloading the new version to a temporary directory. - Using a small helper script or a secondary process to replace the old executable with the new one upon next launch. If your requirements allow for standard installers, migrating to the NSIS target is the recommended way to gain automatic, seamless updates with minimal configuration [1][3][4].

Citations:


🌐 Web query:

electron-updater 6.8.3 portable portableExecutables path auto-download quitAndInstall portable

💡 Result:

Electron-updater is designed primarily for applications installed via standard installers (like NSIS) [1][2]. It does not natively support an auto-update and "quitAndInstall" flow for applications built with the portable target [3]. Key technical details regarding your query: 1. Portable vs. Installer: The portable target in electron-builder creates a self-contained executable that does not require installation [3]. Because it lacks an underlying installation directory and registry presence, the standard quitAndInstall mechanism—which typically replaces files in an installation folder—is not compatible with the portable format [4][5]. 2. Auto-Download and quitAndInstall: While you can use electron-updater to check for and download updates in a portable app, the quitAndInstall() method will not perform an in-place update for a portable executable [4][5]. If you attempt to use it, the process will fail to replace the running executable because the file is locked by the operating system. 3. Managing Portable Updates: To update a portable application, the standard pattern is to download the new portable executable to a temporary location (e.g., using electron-updater's manual download features or a custom downloader) [6][1]. Your application must then launch the new executable, close itself, and allow the new version to overwrite or replace the old one, often facilitated by a small wrapper or updater script that handles the file replacement after the main process exits. 4. Pathing: Portable apps provide environment variables to help you locate your runtime context [3]: - PORTABLE_EXECUTABLE_DIR: The directory where your current portable executable is located. - PORTABLE_EXECUTABLE_FILE: The full path to the currently running portable executable. If you require seamless "quit and install" auto-updating, consider using the NSIS target instead, which is explicitly designed to handle file replacement and installation workflows [1][3].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- electron/updater.ts top-level and relevant blocks ---'
sed -n '1,100p' electron/updater.ts
sed -n '330,450p' electron/updater.ts
sed -n '560,660p' electron/updater.ts

printf '%s\n' ''
printf '%s\n' '--- package manager files ---'
if [ -f package.json ]; then cat package.json | sed -n '1,80p'; fi
if [ -f electron-builder.json5 ]; then sed -n '1,130p' electron-builder.json5; fi

printf '%s\n' ''
printf '%s\n' '--- files mentioning update initialization call / environment overrides ---'
rg -n -C 4 'initUpdater|RECORDLY_DISABLE_AUTO_UPDATES|AUTO_UPDATES_DISABLED|publishAutoUpdate|electronbuilder|publisher' --glob '*.ts' --glob '*.tsx' --glob '*.js' --glob '*.json' --glob '*.json5' . || true

Repository: webadderallorg/Recordly

Length of output: 19831


🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n -C 4 'setupAutoUpdates|checkForAppUpdates|installDownloadedUpdateNow|downloadAvailableUpdate|isAutoUpdateFeatureEnabled|autoUpdates' --glob '*.ts' --glob '*.tsx' --glob '*.js' --glob '*.jsx' --glob '*.mjs' . || true

printf '%s\n' ''
printf '%s\n' '--- appPaths.ts portable env usage ---'
if [ -f electron/appPaths.ts ]; then
  sed -n '1,200p' electron/appPaths.ts
fi

Repository: webadderallorg/Recordly

Length of output: 12970


Handle updates separately for portable builds.

electron-updater is initialized for all packaged releases and offers NSIS-style auto-download, download-and-install, and autoUpdater.quitAndInstall() paths. With publishAutoUpdate enabled and both nsis and portable targets added, portable builds can show update actions that target an installer-only flow. Add a portable-specific update path, such as offering only the portable download, or disable electron-updater when the portable executable is detected.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@electron-builder.json5` around lines 106 - 118, Update the portable-build
configuration and update handling so portable executables do not use NSIS-only
electron-updater actions such as auto-download, download-and-install, or
quitAndInstall. Add a portable-specific path that offers only downloading the
replacement executable, or disable electron-updater when the portable executable
is detected, while preserving the existing NSIS update flow.

Source: MCP tools

}
}

6 changes: 6 additions & 0 deletions electron/appPaths.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import path from "node:path";
import { app } from "electron";
import { getActiveWorkspaceLayout, initializeWorkspaceStorage } from "./storageSettings";

if (process.env["VITE_DEV_SERVER_URL"]) {
const devUserDataPath = path.join(app.getPath("appData"), "Recordly-dev");
Expand All @@ -8,4 +9,9 @@ if (process.env["VITE_DEV_SERVER_URL"]) {
}

export const USER_DATA_PATH = app.getPath("userData");
export const INITIAL_WORKSPACE_LAYOUT = initializeWorkspaceStorage(app, USER_DATA_PATH);
export const RECORDINGS_DIR = path.join(USER_DATA_PATH, "recordings");

export function getConfiguredWorkspaceLayout() {
return getActiveWorkspaceLayout();
}
38 changes: 38 additions & 0 deletions electron/electron-env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -858,6 +858,44 @@ interface Window {
message?: string;
error?: string;
}>;
getStorageStatus: () => Promise<{
success: boolean;
workspaceRoot?: string | null;
recordingsDir?: string;
projectsDir?: string;
tempDir?: string;
cacheDir?: string;
configuredTempDir?: string;
configuredCacheDir?: string;
restartRequired?: boolean;
initializationError?: string | null;
usage?: {
recordingsBytes: number;
projectsBytes: number;
tempBytes: number;
cacheBytes: number;
totalBytes: number;
} | null;
error?: string;
}>;
chooseWorkspaceDirectory: () => Promise<{
success: boolean;
canceled?: boolean;
workspaceRoot?: string;
recordingsDir?: string;
projectsDir?: string;
tempDir?: string;
cacheDir?: string;
restartRequired?: boolean;
error?: string;
}>;
openWorkspaceDirectory: () => Promise<{ success: boolean; error?: string }>;
Comment on lines +881 to +892

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Add missing message and path fields to match the actual handler return shapes.

chooseWorkspaceDirectory's catch branch in electron/ipc/register/project.ts returns message: "Failed to configure RecordlyData", but this type has no message field. openWorkspaceDirectory's success branch returns path: targetPath, but this type has no path field at all.

Proposed fix
 		chooseWorkspaceDirectory: () => Promise<{
 			success: boolean;
 			canceled?: boolean;
 			workspaceRoot?: string;
 			recordingsDir?: string;
 			projectsDir?: string;
 			tempDir?: string;
 			cacheDir?: string;
 			restartRequired?: boolean;
+			message?: string;
 			error?: string;
 		}>;
-		openWorkspaceDirectory: () => Promise<{ success: boolean; error?: string }>;
+		openWorkspaceDirectory: () => Promise<{ success: boolean; path?: string; error?: string }>;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
chooseWorkspaceDirectory: () => Promise<{
success: boolean;
canceled?: boolean;
workspaceRoot?: string;
recordingsDir?: string;
projectsDir?: string;
tempDir?: string;
cacheDir?: string;
restartRequired?: boolean;
error?: string;
}>;
openWorkspaceDirectory: () => Promise<{ success: boolean; error?: string }>;
chooseWorkspaceDirectory: () => Promise<{
success: boolean;
canceled?: boolean;
workspaceRoot?: string;
recordingsDir?: string;
projectsDir?: string;
tempDir?: string;
cacheDir?: string;
restartRequired?: boolean;
message?: string;
error?: string;
}>;
openWorkspaceDirectory: () => Promise<{ success: boolean; path?: string; error?: string }>;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@electron/electron-env.d.ts` around lines 881 - 892, Update the electron API
declarations for chooseWorkspaceDirectory and openWorkspaceDirectory to include
the handler response fields message and path, respectively, while preserving
their existing optionality and response shapes.

cleanupRecordlyTemporaryFiles: () => Promise<{
success: boolean;
removedCount?: number;
removedBytes?: number;
error?: string;
}>;
getShortcuts: () => Promise<Record<string, unknown> | null>;
saveShortcuts: (shortcuts: unknown) => Promise<{ success: boolean; error?: string }>;
getAppSetting: (key: string) => unknown;
Expand Down
7 changes: 2 additions & 5 deletions electron/ipc/project/manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ import {
MAX_RECENT_PROJECTS,
PROJECT_FILE_EXTENSION,
PROJECT_THUMBNAIL_SUFFIX,
PROJECTS_DIRECTORY_NAME,
RECENT_PROJECTS_FILE,
RECORDINGS_SETTINGS_FILE,
} from "../constants";
Expand All @@ -24,7 +23,7 @@ import {
} from "../state";
import type { ProjectLibraryEntry, RecordingSessionData } from "../types";
import {
getRecordingsDir,
getProjectsStorageDir,
normalizePath,
normalizeVideoSourcePath,
parseJsonWithByteOrderMark,
Expand Down Expand Up @@ -239,9 +238,7 @@ export async function resolveProjectMediaSources(
}

export async function getProjectsDir() {
const projectsDir = path.join(await getRecordingsDir(), PROJECTS_DIRECTORY_NAME);
await fs.mkdir(projectsDir, { recursive: true });
return projectsDir;
return getProjectsStorageDir();
}

export async function persistRecordingsDirectorySetting(nextDir: string) {
Expand Down
5 changes: 2 additions & 3 deletions electron/ipc/recording/prune.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,11 @@ import {
COMPANION_AUDIO_LAYOUTS,
LEGACY_PROJECT_FILE_EXTENSIONS,
PROJECT_FILE_EXTENSION,
PROJECTS_DIRECTORY_NAME,
} from "../constants";
import { currentVideoPath } from "../state";
import {
getRecordingsDir,
getProjectsStorageDir,
getTelemetryPathForVideo,
isAutoRecordingPath,
normalizePath,
Expand Down Expand Up @@ -39,8 +39,7 @@ export async function hasSiblingProjectFile(videoPath: string) {
export { isAutoRecordingPath };

async function loadSavedProjectMediaPaths() {
const recordingsDir = await getRecordingsDir();
const projectsDir = path.join(recordingsDir, PROJECTS_DIRECTORY_NAME);
const projectsDir = await getProjectsStorageDir();
const protectedPaths = new Set<string>();
const candidateExtensions = new Set([
PROJECT_FILE_EXTENSION,
Expand Down
53 changes: 51 additions & 2 deletions electron/ipc/recording/windows.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,15 @@
import { EventEmitter } from "node:events";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { PassThrough } from "node:stream";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { setWindowsCaptureOutputBuffer, setWindowsCaptureTargetPath } from "../state";
import { waitForWindowsCaptureStop } from "./windows";
import {
describeWindowsCaptureStartFailure,
prepareWindowsCaptureTempDirectory,
waitForWindowsCaptureStop,
} from "./windows";

vi.mock("electron", () => ({
app: {
Expand All @@ -25,6 +32,48 @@ class FakeCaptureProcess extends EventEmitter {
});
}

const temporaryRoots: string[] = [];

afterEach(async () => {
await Promise.all(
temporaryRoots.splice(0).map((root) => fs.rm(root, { recursive: true, force: true })),
);
});

describe("Windows capture temporary storage", () => {
it("accepts a writable Unicode temporary directory and removes its probe file", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "recordly-windows-temp-"));
temporaryRoots.push(root);
const unicodeDirectory = path.join(root, "录屏 临时目录");

const status = await prepareWindowsCaptureTempDirectory(unicodeDirectory, 0);

expect(status.directory).toBe(path.resolve(unicodeDirectory));
expect(await fs.readdir(unicodeDirectory)).toEqual([]);
});

it("rejects a temporary directory that does not have the required free space", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "recordly-windows-space-"));
temporaryRoots.push(root);

await expect(
prepareWindowsCaptureTempDirectory(root, Number.MAX_SAFE_INTEGER),
).rejects.toThrow("Recordly needs at least");
});

it("prefers the native helper error and includes an actionable temporary path", () => {
const detail = describeWindowsCaptureStartFailure(
new Error("Native helper exited"),
"INFO: Initializing\nERROR: Failed to initialize Media Foundation encoder\n",
"D:\\RecordlyTemp",
);

expect(detail).toContain("ERROR: Failed to initialize Media Foundation encoder");
expect(detail).toContain("D:\\RecordlyTemp");
expect(detail).toContain("choose another Recordly storage location");
});
});

describe("waitForWindowsCaptureStop", () => {
beforeEach(() => {
setWindowsCaptureOutputBuffer("");
Expand Down
94 changes: 87 additions & 7 deletions electron/ipc/recording/windows.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import type { ChildProcessWithoutNullStreams } from "node:child_process";
import { randomUUID } from "node:crypto";
import { constants as fsConstants } from "node:fs";
import fs from "node:fs/promises";
import path from "node:path";
import { BrowserWindow } from "electron";
import { getWindowsCaptureExePath } from "../paths/binaries";
import {
Expand All @@ -13,13 +15,91 @@ import {
windowsCaptureTargetPath,
windowsNativeCaptureActive,
} from "../state";
import {
AudioSyncAdjustment,
} from "../types";
import { AudioSyncAdjustment } from "../types";
import { moveFileWithOverwrite } from "../utils";
import { emitRecordingInterrupted } from "./events";

const WINDOWS_CAPTURE_STOP_TIMEOUT_MS = 45_000;
export const MIN_WINDOWS_CAPTURE_TEMP_FREE_BYTES = 512 * 1024 * 1024;

export type WindowsCaptureTempStatus = {
directory: string;
freeBytes: number | null;
};

function formatStorageSize(bytes: number) {
if (!Number.isFinite(bytes) || bytes < 0) {
return "an unknown amount of space";
}

if (bytes >= 1024 * 1024 * 1024) {
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
}

return `${Math.max(0, Math.round(bytes / (1024 * 1024)))} MB`;
}

export async function prepareWindowsCaptureTempDirectory(
tempDirectory: string,
minimumFreeBytes = MIN_WINDOWS_CAPTURE_TEMP_FREE_BYTES,
): Promise<WindowsCaptureTempStatus> {
const directory = path.resolve(tempDirectory);
let probePath: string | null = null;

try {
await fs.mkdir(directory, { recursive: true });
probePath = path.join(directory, `.recordly-write-test-${process.pid}-${randomUUID()}.tmp`);
await fs.writeFile(probePath, "Recordly temporary storage probe", { flag: "wx" });
} catch (error) {
throw new Error(
`Recordly cannot write to its temporary folder (${directory}). Choose another storage location or check the folder permissions. ${String(error)}`,
);
} finally {
if (probePath) {
await fs.rm(probePath, { force: true }).catch(() => undefined);
}
}

let freeBytes: number | null = null;
try {
const stats = await fs.statfs(directory);
const reportedFreeBytes = Number(stats.bavail) * Number(stats.bsize);
freeBytes = Number.isFinite(reportedFreeBytes) ? reportedFreeBytes : null;
} catch {
// Older Windows filesystems may not report capacity through statfs. The
// successful write probe is still enough to safely attempt capture.
}

if (freeBytes !== null && freeBytes < minimumFreeBytes) {
throw new Error(
`Recordly needs at least ${formatStorageSize(minimumFreeBytes)} free in its temporary folder (${directory}), but only ${formatStorageSize(freeBytes)} is available. Free disk space or choose another storage location.`,
);
}

return { directory, freeBytes };
}

export function describeWindowsCaptureStartFailure(
error: unknown,
processOutput: string,
tempDirectory: string,
) {
const outputLines = processOutput
.split(/\r?\n/u)
.map((line) => line.trim())
.filter(Boolean);
const helperError = [...outputLines]
.reverse()
.find((line) => line.startsWith("ERROR:") || line.startsWith("WARNING:"));
const errorMessage = error instanceof Error ? error.message : String(error);
const detail = helperError ?? errorMessage;

if (/temporary folder|free disk space|folder permissions/iu.test(detail)) {
return detail;
}

return `${detail} Temporary folder: ${path.resolve(tempDirectory)}. If this folder is on a full or restricted drive, choose another Recordly storage location.`;
}

export type NativeWindowsVideoPaddingResult = {
padded: boolean;
Expand Down Expand Up @@ -135,7 +215,9 @@ export function waitForWindowsCaptureStop(

const onClose = (code: number | null) => {
finish(() => {
const match = windowsCaptureOutputBuffer.match(/Recording stopped\. Output path: (.+)/);
const match = windowsCaptureOutputBuffer.match(
/Recording stopped\. Output path: (.+)/,
);
if (match?.[1]) {
resolve(match[1].trim());
return;
Expand Down Expand Up @@ -254,9 +336,7 @@ export async function muxNativeWindowsVideoWithAudio(
}
}

console.log(
`[PERF:MAIN] muxNativeWindowsVideoWithAudio: COMPLETED in ${Date.now() - start}ms`,
);
console.log(`[PERF:MAIN] muxNativeWindowsVideoWithAudio: COMPLETED in ${Date.now() - start}ms`);

return {
muxed: false,
Expand Down
Loading