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
6 changes: 3 additions & 3 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,7 @@ jobs:
run: |
VERSION="${{ steps.version.outputs.version }}"
ARCH="${{ matrix.arch }}"
DMG_NAME="OpenScreen-Mac-${ARCH}-${VERSION}.dmg"
DMG_NAME="Marquee-Mac-${ARCH}-${VERSION}.dmg"
RELEASE_DIR="release/${VERSION}"
DMG_OUTPUT="${RELEASE_DIR}/${DMG_NAME}"
STAGING="${RELEASE_DIR}/dmg-staging"
Expand All @@ -168,7 +168,7 @@ jobs:

hdiutil create \
-srcfolder "$STAGING" \
-volname "OpenScreen" \
-volname "Marquee" \
-fs HFS+ \
-fsargs "-c c=64,a=16,e=16" \
-format UDBZ \
Expand Down Expand Up @@ -346,7 +346,7 @@ jobs:
PRERELEASE_FLAG: ${{ steps.release.outputs.prerelease_flag }}
NOTES_START_TAG: ${{ steps.release.outputs.notes_start_tag }}
run: |
TITLE="OpenScreen Creator Edition ${TAG#v}"
TITLE="Marquee ${TAG#v}"
mapfile -t FILES < <(find artifacts -type f | sort)
if [[ "${#FILES[@]}" -eq 0 ]]; then
echo "::error::No installer artifacts were downloaded"
Expand Down
8 changes: 4 additions & 4 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# AGENTS.md

OpenScreen is a free, open-source screen recorder and video editor (Electron + React + TypeScript + Pixi.js) maintained as a continuation of the original v1.5.0 release. This file is the canonical guide for any AI coding agent working in this repo.
Marquee (formerly OpenScreen; the repository and the npm package keep the `openscreen` name) is a free, open-source screen recorder and video editor (Electron + React + TypeScript + Pixi.js) maintained as a continuation of the original v1.5.0 release. This file is the canonical guide for any AI coding agent working in this repo.

## Setup commands

Expand Down Expand Up @@ -68,13 +68,13 @@ Unit/browser tests can't exercise real capture (native screen recording, a physi
**Launch the app**

- Normal: `npm run dev` — Vite serves the renderer and `vite-plugin-electron` opens the Electron window. The main process logs `Global shortcut registered: CommandOrControl+Shift+O` (shows the HUD) and `Record shortcut registered: <accelerator>` (starts/stops a capture) when ready. The record shortcut is the first free one of Win+Shift+R, Ctrl+Alt+R, Win+Shift+X, Ctrl+Shift+Space, or the user's own from `app-settings.json`.
- The app is **tray-resident** on Windows/macOS: closing the HUD hides it, and a HUD hidden for 60 s (`OPENSCREEN_HUD_IDLE_RELEASE_MS` overrides) is destroyed and rebuilt on demand from persisted settings. With no window the process is still alive — quit from the tray menu or HUD **⋯ → Quit OpenScreen**. `--hidden` starts straight into the tray (used by Launch at login).
- The app is **tray-resident** on Windows/macOS: closing the HUD hides it, and a HUD hidden for 60 s (`OPENSCREEN_HUD_IDLE_RELEASE_MS` overrides) is destroyed and rebuilt on demand from persisted settings. With no window the process is still alive — quit from the tray menu or HUD **⋯ → Quit Marquee**. `--hidden` starts straight into the tray (used by Launch at login).
- The app is single-instance: a lock dir at `%TEMP%/openscreen-single-instance-<user>.lock` (macOS: `$TMPDIR`). If a stale Electron process holds it, a new launch quits silently (exit 0, no window). Kill leftover `electron` processes and delete that lock dir before relaunching.
- **From a git worktree** (no `node_modules`/native binaries): junction/symlink `node_modules` from the main checkout (deps are usually identical — check `package-lock.json`), and copy the prebuilt native capture binaries from `electron/native/bin/<platform>/` (gitignored — rebuilding needs the full VS/Xcode toolchain). Then `npm run dev` works normally.

**Granting access**

- `request_access` resolves names against installed apps. A **dev build runs as `electron.exe`** (or `Electron.app`), *not* the installed `Openscreen` — grant **`electron.exe`** or the dev window stays masked in screenshots. Non-allowlisted windows are masked (solid rectangles); the screenshot note lists their process names to add.
- `request_access` resolves names against installed apps. A **dev build runs as `electron.exe`** (or `Electron.app`), *not* the installed `Marquee` — grant **`electron.exe`** or the dev window stays masked in screenshots. Non-allowlisted windows are masked (solid rectangles); the screenshot note lists their process names to add.

**The HUD widget** (recording controller)

Expand All @@ -87,7 +87,7 @@ Unit/browser tests can't exercise real capture (native screen recording, a physi
**The tray icon** (bottom-right notification area)

- Because the HUD skips the taskbar and can be minimized/hidden, the **system-tray icon is the reliable way to refocus the app**: **left-click or double-click reopens/focuses the HUD** (`showMainWindow`). Its icon swaps to a red dot while recording.
- **Right-click → context menu**: *Record Area…* / *Open* / *Quit* when idle, or ***Stop Recording*** while recording (mirrors the HUD's stop). Tooltip shows `OpenScreen — <shortcut> to record` or `Recording: <source>`. Use this to stop a recording if the HUD isn't reachable.
- **Right-click → context menu**: *Record Area…* / *Open* / *Quit* when idle, or ***Stop Recording*** while recording (mirrors the HUD's stop). Tooltip shows `Marquee — <shortcut> to record` or `Recording: <source>`. Use this to stop a recording if the HUD isn't reachable.

**End-to-end flow (record → edit)**

Expand Down
43 changes: 43 additions & 0 deletions docs/decisions.md
Original file line number Diff line number Diff line change
Expand Up @@ -393,6 +393,49 @@ GPU и ~31 с на процессоре.
появится модель того же качества заметно меньшего размера или понадобится полностью офлайн-установка
(тогда — отдельный пакет с моделью).

## D-029 — Приложение называется Marquee

Дата: 2026-11-04.

Контекст: продукт переименован в Marquee (лендинг и логотип — `site/`), а установщики, окна, трей,
уведомления и папки ещё назывались OpenScreen.

Решение: всё, что видит пользователь, называется Marquee: `productName`, exe, файлы релиза
(`Marquee-<version>-Setup.exe`, `Marquee-Mac-<arch>-<version>.dmg`, `Marquee-Linux-…`), окна,
трей, уведомления, тексты всех 13 локалей, иконки (из `site/brand/app-icon-1024.png`). Прежним
остаётся то, что связывает новую версию с уже установленной и с данными пользователя:

- `appId` `com.etiennelescot.openscreen`: по нему установщик заменяет установленный OpenScreen,
macOS сохраняет разрешения на запись экрана, Windows — группировку на панели задач, автозапуск
и уведомления.
- Папка данных. Electron называет её по имени приложения в момент первого
`app.getPath("userData")`; у OpenScreen это было имя пакета, `openscreen`.
`electron/appIdentity.ts` подключается в `main.ts` первым, задаёт имя и, если папки `Marquee`
ещё нет, а `openscreen` есть, оставляет приложение в старой: настройки, пресеты, токен Яндекс
Диска, записи и модели речи остаются на месте, ничего не копируется.
- Папка клипов по умолчанию — `Videos\Marquee`, но уже существующая `Videos\OpenScreen`
остаётся папкой по умолчанию.
- Замок одного экземпляра `openscreen-single-instance-<user>.lock`: старый OpenScreen и Marquee
не работают одновременно и не делят горячую клавишу и трей.
- Внутренние имена: расширение проектов `.openscreen` (старые проекты открываются как раньше),
схема `openscreen-asr://`, ключи localStorage, переменные `OPENSCREEN_*`, нативные модули, папка
`OpenScreen Exports` на Яндекс Диске (к ней привязана публичная ссылка), идентичность в Microsoft
Store (имя зарезервировано в Partner Center), имя npm-пакета и репозитория.
- Nix-пакет: в меню и в описаниях модулей он Marquee, но имя пакета и команда `openscreen`,
опция `programs.openscreen` и файл `openscreen.desktop` прежние: на них ссылаются конфигурации,
а Wayland находит пункт меню для окна по имени пакета. На X11 пункт находится по
`startupWMClass`, а класс окна — имя приложения, поэтому там теперь `Marquee`.

Альтернативы: переносить папку данных при первом запуске — запущенный или возвращённый OpenScreen
остался бы без настроек, а неудачный перенос пришлось бы разбирать; сменить `appId` — рядом со
старым приложением появилось бы второе, а на macOS разрешения пришлось бы выдавать заново.

Последствия: у обновившихся пользователей папка данных так и называется `openscreen`, у новых —
`Marquee`. На macOS и Linux ключ, которым зашифрован токен Яндекс Диска, хранится под именем
приложения, поэтому токен придётся вставить заново; на Windows ключ лежит в той же папке данных.
Пересмотреть, если понадобится переименовать папку данных или расширение проектов (тогда — с
чтением старых).

## Как добавлять решения

Для каждого нового решения указывать дату, контекст, выбранный вариант, альтернативы, причину,
Expand Down
16 changes: 9 additions & 7 deletions electron-builder.json5
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
// @see - https://www.electron.build/configuration/configuration
{
"$schema": "https://raw.githubusercontent.com/electron-userland/electron-builder/master/packages/app-builder-lib/scheme.json",
// Kept from OpenScreen: upgrades replace the old install, keep its macOS permissions and
// its Windows shortcuts and notifications (decision D-029).
"appId": "com.etiennelescot.openscreen",
"asar": true,
// .node binaries can't be dlopen'd from inside an asar — must live unpacked.
"asarUnpack": [
"**/*.node"
],
"productName": "OpenScreen",
"productName": "Marquee",
// Copy the ONNX Runtime build transcription runs on into speech-runtime/ (idempotent).
"beforePack": "scripts/before-pack.cjs",
// Nothing from node_modules ships (see "files"), so there is nothing to rebuild for Electron.
Expand Down Expand Up @@ -76,10 +78,10 @@
}
],
"extendInfo": {
"NSAudioCaptureUsageDescription": "OpenScreen needs audio capture permission to record system audio.",
"NSMicrophoneUsageDescription": "OpenScreen needs microphone access to record voice audio.",
"NSCameraUsageDescription": "OpenScreen needs camera access to record webcam video.",
"NSScreenCaptureUsageDescription": "OpenScreen needs screen recording permission to detect and capture windows.",
"NSAudioCaptureUsageDescription": "Marquee needs audio capture permission to record system audio.",
"NSMicrophoneUsageDescription": "Marquee needs microphone access to record voice audio.",
"NSCameraUsageDescription": "Marquee needs camera access to record webcam video.",
"NSScreenCaptureUsageDescription": "Marquee needs screen recording permission to detect and capture windows.",
"NSCameraUseContinuityCameraDeviceType": true
}
},
Expand All @@ -95,12 +97,12 @@
"category": "AudioVideo"
},
"win": {
"executableName": "OpenScreen",
"executableName": "Marquee",
"target": [
"nsis"
],
"icon": "icons/icons/win/icon.ico",
"artifactName": "${productName}-Custom-${version}-Setup.${ext}",
"artifactName": "${productName}-${version}-Setup.${ext}",
"extraResources": [
{
"from": "electron/native/bin",
Expand Down
64 changes: 64 additions & 0 deletions electron/appIdentity.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

const electron = vi.hoisted(() => ({
appData: "",
customProfile: false,
setName: vi.fn(),
setPath: vi.fn(),
}));

vi.mock("electron", () => ({
app: {
setName: electron.setName,
setPath: electron.setPath,
getPath: () => electron.appData,
commandLine: { hasSwitch: () => electron.customProfile },
},
}));

describe("app identity", () => {
let appData: string;

beforeEach(() => {
appData = fs.mkdtempSync(path.join(os.tmpdir(), "marquee-identity-"));
electron.appData = appData;
electron.customProfile = false;
electron.setName.mockClear();
electron.setPath.mockClear();
vi.resetModules();
});

afterEach(() => {
fs.rmSync(appData, { recursive: true, force: true });
});

it("keeps an install upgraded from OpenScreen in its old data folder", async () => {
fs.mkdirSync(path.join(appData, "openscreen"));
await import("./appIdentity");
expect(electron.setName).toHaveBeenCalledWith("Marquee");
expect(electron.setPath).toHaveBeenCalledWith("userData", path.join(appData, "openscreen"));
});

it("uses its own folder once it has one", async () => {
fs.mkdirSync(path.join(appData, "openscreen"));
fs.mkdirSync(path.join(appData, "Marquee"));
await import("./appIdentity");
expect(electron.setPath).not.toHaveBeenCalled();
});

it("starts a fresh install in its own folder", async () => {
await import("./appIdentity");
expect(electron.setName).toHaveBeenCalledWith("Marquee");
expect(electron.setPath).not.toHaveBeenCalled();
});

it("leaves a profile passed with --user-data-dir alone", async () => {
fs.mkdirSync(path.join(appData, "openscreen"));
electron.customProfile = true;
await import("./appIdentity");
expect(electron.setPath).not.toHaveBeenCalled();
});
});
22 changes: 22 additions & 0 deletions electron/appIdentity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import fs from "node:fs";
import path from "node:path";
import { app } from "electron";

const APP_NAME = "Marquee";
// OpenScreen's data folder, named after package.json "name".
const LEGACY_USER_DATA_NAME = "openscreen";

// Imported first by main.ts: Electron settles the userData folder, and creates it, on the
// first app.getPath("userData") call, so the name and the folder are set before anything asks.
app.setName(APP_NAME);

// An install upgraded from OpenScreen keeps working in its old data folder: settings, presets,
// the Yandex Disk token, recordings and speech models all live there (decision D-029). A run
// with --user-data-dir (tests, headless checks) brings its own profile.
if (!app.commandLine.hasSwitch("user-data-dir")) {
const appData = app.getPath("appData");
const legacyDir = path.join(appData, LEGACY_USER_DATA_NAME);
if (!fs.existsSync(path.join(appData, APP_NAME)) && fs.existsSync(legacyDir)) {
app.setPath("userData", legacyDir);
}
}
33 changes: 33 additions & 0 deletions electron/appSettings.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

const videos = vi.hoisted(() => ({ dir: "" }));

vi.mock("electron", () => ({ app: { getPath: () => videos.dir } }));

import { AppSettingsStore } from "./appSettings";

describe("default clips folder", () => {
beforeEach(() => {
videos.dir = fs.mkdtempSync(path.join(os.tmpdir(), "marquee-videos-"));
});

afterEach(() => {
fs.rmSync(videos.dir, { recursive: true, force: true });
});

it("is Videos\\Marquee for a new install", async () => {
const folder = await new AppSettingsStore(videos.dir).resolveSaveFolder();
expect(folder).toBe(path.join(videos.dir, "Marquee"));
expect(fs.existsSync(folder)).toBe(true);
});

it("stays Videos\\OpenScreen when clips were saved there before the rename", async () => {
fs.mkdirSync(path.join(videos.dir, "OpenScreen"));
const folder = await new AppSettingsStore(videos.dir).resolveSaveFolder();
expect(folder).toBe(path.join(videos.dir, "OpenScreen"));
expect(fs.existsSync(path.join(videos.dir, "Marquee"))).toBe(false);
});
});
16 changes: 12 additions & 4 deletions electron/appSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@ import {
} from "../src/lib/appSettings";

const SETTINGS_FILE_NAME = "app-settings.json";
const DEFAULT_SAVE_FOLDER_NAME = "OpenScreen";
const DEFAULT_SAVE_FOLDER_NAME = "Marquee";
// Where clips went before the rename; an existing folder stays the default (decision D-029).
const LEGACY_SAVE_FOLDER_NAME = "OpenScreen";

/** Main-process settings persisted next to the other user data files. */
export class AppSettingsStore {
Expand Down Expand Up @@ -43,8 +45,14 @@ export class AppSettingsStore {
return this.get();
}

getDefaultSaveFolder(): string {
return path.join(app.getPath("videos"), DEFAULT_SAVE_FOLDER_NAME);
async getDefaultSaveFolder(): Promise<string> {
const videos = app.getPath("videos");
const legacy = path.join(videos, LEGACY_SAVE_FOLDER_NAME);
const hasLegacy = await fs.stat(legacy).then(
(stats) => stats.isDirectory(),
() => false,
);
return hasLegacy ? legacy : path.join(videos, DEFAULT_SAVE_FOLDER_NAME);
}

/** The folder clips are written to, created on demand. */
Expand All @@ -59,7 +67,7 @@ export class AppSettingsStore {
}
}

const fallback = this.getDefaultSaveFolder();
const fallback = await this.getDefaultSaveFolder();
await fs.mkdir(fallback, { recursive: true });
return fallback;
}
Expand Down
4 changes: 2 additions & 2 deletions electron/integrations/yandexDisk.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,13 +147,13 @@ describe("Yandex Disk uploads", () => {
fs.rmSync(workDir, { recursive: true, force: true });
});

it("refuses files OpenScreen did not create", async () => {
it("refuses files Marquee did not create", async () => {
const stray = path.join(clipsDir, "stray.mp4");
fs.writeFileSync(stray, "video");

expect(await upload({ filePath: stray })).toEqual({
success: false,
error: "Only files created by OpenScreen can be uploaded.",
error: "Only files created by Marquee can be uploaded.",
});
expect(statuses()).toHaveLength(0);
});
Expand Down
4 changes: 2 additions & 2 deletions electron/integrations/yandexDisk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -383,7 +383,7 @@ class YandexDiskService {
this.approvedUploadPaths.has(this.normalizeApprovedPath(filePath)) ||
this.approvedUploadPaths.has(this.normalizeApprovedPath(resolvedFilePath));
if (!stats.isFile() || !isOpenScreenFile) {
return { success: false, error: "Only files created by OpenScreen can be uploaded." };
return { success: false, error: "Only files created by Marquee can be uploaded." };
}

const settings = await this.readSettings();
Expand Down Expand Up @@ -566,7 +566,7 @@ class YandexDiskService {
fs.realpath(this.recordingsDirectory),
]);
if (!isPathWithinDirectory(resolvedFilePath, resolvedRecordingsDirectory)) {
throw new Error("Refusing to delete a file outside the OpenScreen recordings folder.");
throw new Error("Refusing to delete a file outside the Marquee recordings folder.");
}
await fs.unlink(resolvedFilePath);
this.broadcast({
Expand Down
Loading
Loading