diff --git a/.github/workflows/homebrew-tap.yml b/.github/workflows/homebrew-tap.yml index 5f8549ec9..3e8810d41 100644 --- a/.github/workflows/homebrew-tap.yml +++ b/.github/workflows/homebrew-tap.yml @@ -22,7 +22,7 @@ jobs: env: GH_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }} TAP_REPO: ${{ github.event.inputs.tap_repo || vars.HOMEBREW_TAP_REPO || 'webadderall/homebrew-tap' }} - AUTO_MERGE_TAP_PR: ${{ vars.HOMEBREW_TAP_AUTO_MERGE || 'true' }} + AUTO_MERGE_TAP_PR: ${{ vars.HOMEBREW_TAP_AUTO_MERGE || 'false' }} steps: - name: Validate token run: | diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 337dafc89..d14b04755 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -371,18 +371,33 @@ jobs: with: python-version: '3.11' - - name: Configure Windows signing + - name: Validate Windows signing secrets shell: pwsh env: WINDOWS_SIGNING_CERTIFICATE_P12_BASE64: ${{ secrets.WINDOWS_SIGNING_CERTIFICATE_P12_BASE64 }} WINDOWS_SIGNING_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_SIGNING_CERTIFICATE_PASSWORD }} run: | - if ([string]::IsNullOrWhiteSpace($env:WINDOWS_SIGNING_CERTIFICATE_P12_BASE64) -or [string]::IsNullOrWhiteSpace($env:WINDOWS_SIGNING_CERTIFICATE_PASSWORD)) { - Write-Warning 'Windows signing secrets are missing. Building an unsigned installer.' - Add-Content -Path $env:GITHUB_ENV -Value 'CSC_IDENTITY_AUTO_DISCOVERY=false' - exit 0 + $missing = @() + foreach ($name in 'WINDOWS_SIGNING_CERTIFICATE_P12_BASE64', 'WINDOWS_SIGNING_CERTIFICATE_PASSWORD') { + $value = [Environment]::GetEnvironmentVariable($name) + if ([string]::IsNullOrWhiteSpace($value)) { + $missing += $name + } } + if ($missing.Count -gt 0) { + foreach ($name in $missing) { + Write-Error "Missing required Windows release secret: $name" + } + exit 1 + } + + - name: Configure Windows signing + shell: pwsh + env: + WINDOWS_SIGNING_CERTIFICATE_P12_BASE64: ${{ secrets.WINDOWS_SIGNING_CERTIFICATE_P12_BASE64 }} + WINDOWS_SIGNING_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_SIGNING_CERTIFICATE_PASSWORD }} + run: | $certPath = Join-Path $env:RUNNER_TEMP 'windows-signing-cert.p12' [IO.File]::WriteAllBytes($certPath, [Convert]::FromBase64String($env:WINDOWS_SIGNING_CERTIFICATE_P12_BASE64)) Add-Content -Path $env:GITHUB_ENV -Value "WIN_CSC_LINK=$certPath" diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 62a6ea1b5..8e147e298 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -49,7 +49,7 @@ Areas where help is especially valuable: ## Reporting Issues -If you encounter a bug or have a feature request, please open an issue in the [Issues](https://github.com/webadderallorg/Recordly/issues) section of this repository. Provide as much detail as possible to help us address the issue effectively. +If you encounter a bug or have a feature request, please open an issue in the [Issues](https://github.com/Alfredoalv13/Recordly/issues) section of this repository. Provide as much detail as possible to help us address the issue effectively. ## Style Guide @@ -58,6 +58,6 @@ If you encounter a bug or have a feature request, please open an issue in the [I ## License -By contributing to this project, you agree that your contributions will be licensed under the [MIT License](./LICENSE). +By contributing to this project, you agree that your contributions will be licensed under the [GNU Affero General Public License v3.0](./LICENSE.md), matching the project's actual license (this repo is an AGPLv3 fork of Recordly; it is not MIT-licensed). Thank you for your contributions! diff --git a/README.md b/README.md index 121ef9343..a4ddb9a20 100644 --- a/README.md +++ b/README.md @@ -1,409 +1,79 @@ -Language: EN | [简中](README.zh-CN.md) +# VybeClip

- Recordly Logo + VybeClip app icon

-

- macOS Windows Linux - AGPL 3.0 license -

- -### Create polished demo videos in minutes -[Recordly](https://www.recordly.dev) is your **open-source screen recorder** and editor for **walkthroughs, demos, product videos**, and more. -**Accepting PRs.** - -MP4 to GIF export (4) - - ---- -### Backed by the community -CodeRabbit Typemark - ---- - -## What is Recordly? - -Recordly is a desktop app for recording and editing screen captures with motion-driven presentation tools built in. Instead of sending raw footage to a motion designer just to add zooms, cursor polish, or a styled background, Recordly handles that workflow in one place for free. - -Recordly runs on: - -- **macOS** 14.0+ -- **Windows** 10 Build 19041+ -- **Linux** on modern distros - -Platform notes: - -- **macOS** uses native ScreenCaptureKit-based capture helpers. -- **Windows** uses a native Windows Graphics Capture (WGC) helper on supported builds, with native WASAPI audio support. -- **Linux** records through Electron capture APIs. Cursor hiding is not supported on Linux today. - ---- - -# Core Features - -## Auto-zooms, cursor polish, and styled frames -Recordly can automatically emphasize activity with zoom suggestions, smooth cursor movement, add motion effects, and place the final composition inside a styled frame with wallpapers, colors, gradients, blur, padding, and shadows. - -

- Recordly cursor and zoom demo video -

- -## Dynamic webcam bubble overlays -Add webcam footage as an overlay bubble, position it with presets or custom coordinates, mirror it, control shadow and roundness, and optionally make it react to zoom so it stays visually balanced during motion. - -

- Recordly webcam overlay demo video -

- -## Timeline editing built for demos -Use drag-and-drop timeline tools for zooms, trims, speed regions, annotations, extra audio regions, and crop-aware edits. Save and reopen work as `.recordly` project files. - -

- timeline editor -

- -## Extensions & Marketplace - -Recordly has a community-driven extension system. Anyone can build and publish extensions that add new capabilities to Recordly — cursor click sounds, device frames, browser mockups, wallpapers, render hooks, settings panels, and more. - -Browse and install community extensions from the [Recordly Marketplace](https://marketplace.recordly.dev/extensions). - ---- - -## All Features - -### Recording - -- Record an entire display or a single app window -- Jump directly from recording into the editor -- Capture microphone audio and system audio -- Use native capture backends where supported -- Resume editing from saved `.recordly` project files -- Open existing recordings or existing project files from the app +VybeClip is an open-source desktop screen recorder and video editor for polished product demos, walkthroughs, tutorials, and branded clips. -### Timeline and Editing +The app is currently moving toward its first macOS MVP. The core recording and editing engine comes from Recordly, with a new VybeClip Studio experience, product identity, project format, and workflow maintained by Box Creative Studio. -- Drag-and-drop timeline editing -- Trim unwanted sections -- Add manual zoom regions -- Use automatic zoom suggestions based on cursor activity -- Add speed-up and slow-down regions -- Add text, image, and figure annotations -- Add extra audio regions on the timeline -- Crop the recorded frame -- Save and reopen projects with editor state preserved +## MVP Features -### Cursor Controls +- Record a display, window, or application with optional microphone, system audio, and webcam +- Import an existing video into the editor +- Add automatic or manual zooms, cursor styling, backgrounds, framing, crop, captions, audio, and annotations +- Save and reopen `.vybeclip` projects +- Open legacy `.recordly` and `.openscreen` projects +- Export H.264 MP4 video, including source-quality 1080p output +- Reopen recent projects from VybeClip Studio -- Show or hide the rendered cursor overlay -- Cursor size adjustment -- Cursor smoothing -- Cursor motion blur -- Cursor click bounce -- Cursor sway -- Cursor loop mode for cleaner looping exports -- macOS-style cursor assets for the rendered overlay +## Development -### Webcam Overlay +Requirements: -- Enable or disable webcam overlay footage -- Upload, replace, or remove webcam footage -- Mirror webcam footage -- Size control -- Preset positions and custom X/Y placement -- Margin control -- Roundness control -- Shadow control -- Optional zoom-reactive webcam scaling +- Node.js 20 or newer +- npm +- macOS is the primary MVP platform -### Frame Styling and Backgrounds - -- Built-in wallpapers -- Runtime wallpaper discovery from the wallpapers directory -- Custom uploaded backgrounds -- Solid color backgrounds -- Gradient backgrounds -- Frame padding -- Rounded corners -- Background blur -- Drop shadows -- Aspect ratio presets for the final frame - -### Export - -- MP4 export -- GIF export -- Export quality selection -- GIF frame-rate selection -- GIF loop toggle -- GIF size presets -- Aspect ratio and output dimension controls -- Reveal exported files in the system file manager - -### Workflow and Usability - -- Customizable keyboard shortcuts -- In-app shortcut reference -- Feedback and issue links from the editor -- Project persistence for editor preferences -- Faster preview recovery after export ---- - -# Screenshots - -

- Recordly recording interface screenshot -

- -

- Recordly editor screenshot -

- -

- Recordly timeline screenshot -

- ---- - -# Installation - -## Download a build - -Prebuilt releases are available at: - -https://github.com/webadderallorg/Recordly/releases - ---- - -## Arch Linux / Manjaro (yay) - -Install from the AUR ([recordly-bin](https://aur.archlinux.org/packages/recordly-bin)): +Install dependencies and start the renderer: ```bash -yay -S recordly-bin -``` - -PKGBUILD, desktop entry, release sync, and optional **local-from-source** packaging live in **[recordly-aur](https://github.com/firtoz/recordly-aur)** so this repository stays free of Arch release chores. For maintainer contact and how the package is updated, see that repo or the AUR package page. - ---- - -## Build from source - -### Prerequisites - -**macOS:** Xcode Command Line Tools (`xcode-select --install`). - -**Linux (Ubuntu/Debian):** - -```bash -sudo apt install build-essential cmake libx11-dev libxtst-dev libxrandr-dev libxt-dev -``` - -**Windows:** Visual Studio 2022 (or Build Tools) with the C++ workload and CMake. - -### Steps - -```bash -git clone https://github.com/webadderallorg/Recordly.git recordly -cd recordly npm install npm run dev ``` -For packaged builds: +In a second terminal, launch the Electron app: ```bash -npm run build +npx electron . ``` -Target-specific build commands are also available: - -- `npm run build:mac` -- `npm run build:win` -- `npm run build:linux` - ---- - -## macOS: "App cannot be opened" - -Locally built apps may be quarantined by macOS. - -Remove the quarantine flag with: +Run the verification suite: ```bash -xattr -rd com.apple.quarantine /Applications/Recordly.app +npm test +npx tsc --noEmit ``` ---- - -# System Requirements - -| Platform | Minimum version | Notes | -|---|---|---| -| **macOS** | macOS 14.0 (Sonoma) | Required for ScreenCaptureKit audio and microphone capture. | -| **Windows** | Windows 10 20H1 (Build 19041, May 2020) | Required for the native Windows Graphics Capture (WGC) helper and best cursor-hiding behavior. | -| **Linux** | Any modern distro | Recording works through Electron capture. System audio generally requires PipeWire. | - -> [!IMPORTANT] -> On Windows builds older than 19041, recording can still work through fallback capture, but the real OS cursor may remain visible in recordings. - ---- - -# Usage - -## Record - -1. Launch Recordly. -2. Select a screen or window. -3. Choose microphone and system-audio options. -4. Start recording. -5. Stop recording to open the editor. - -## Edit - -Inside the editor you can: - -- add trims, zooms, speed regions, and annotations -- tune cursor behavior and preview volume -- style the frame with wallpapers, colors, gradients, blur, padding, and corners -- add or adjust webcam overlay footage -- add extra audio regions -- crop the frame and choose an aspect ratio - -Save your work anytime as a `.recordly` project. - -## Export - -Export options include: - -- **MP4** for standard video output -- **GIF** for lightweight sharing and loops - -You can adjust format-specific settings such as quality, GIF frame rate, GIF looping, and output size before export. - ---- - -# Limitations - -### Cursor capture +Build the macOS packages: -Recordly renders a polished cursor overlay on top of the recording. Platform cursor-hiding behavior still depends on OS support. - -**macOS** -- ScreenCaptureKit can exclude the real cursor cleanly. - -**Windows** -- Best results require Windows 10 Build 19041+ and the native capture helper. -- Older builds fall back to Electron capture, so the real cursor may remain visible. - -**Linux** -- Electron desktop capture does not currently support cursor hiding. -- If you also enable the rendered cursor overlay, exports may show both the real cursor and the styled cursor. - -### System audio - -System audio support varies by platform. - -**Windows** -- Native WASAPI support - -**Linux** -- Usually requires PipeWire - -**macOS** -- Requires macOS 14.0+ and the ScreenCaptureKit-based workflow - ---- - -# How It Works - -Recordly combines a platform-specific capture layer with a renderer-driven editor and export pipeline. - -**Capture** -- Electron coordinates recording and application flow -- macOS uses native ScreenCaptureKit helpers -- Windows uses a native Windows Graphics Capture (WGC) helper and native audio helpers where available - -**Editing** -- Timeline regions define zooms, trims, speed changes, audio overlays, and annotations -- Cursor and webcam styling are applied in the editor state - -**Rendering** -- Scene composition is handled by **PixiJS** - -**Export** -- The same scene logic used in preview is rendered into exported MP4 or GIF output - -**Projects** -- `.recordly` files store the source media path plus editor state so work can be reopened later - ---- - -# Contribution - -Contributions are welcome. - -Areas where help is especially useful: - -- Linux capture and cursor behavior -- Export performance and stability -- UI and UX refinement -- Localisation work -- Additional editor tools and workflow polish - -Please keep pull requests focused, test recording/edit/export flows, and avoid unrelated refactors. - -See `CONTRIBUTING.md` for guidelines. - ---- - -# Community - -Bug reports and feature requests: - -https://github.com/webadderallorg/Recordly/issues - -Pull requests are welcome. +```bash +npm run build:mac +``` ---- +Build artifacts are written to `release/`. -# Hall of Supporters +See the [MVP release guide](./_documentation/MVP_RELEASE.md) for acceptance checks, signing, artifacts, and notarization. -[![Ko-Fi](https://img.shields.io/badge/Ko--fi-F16061?style=for-the-badge&logo=ko-fi&logoColor=white)](https://ko-fi.com/webadderall) +## macOS Permissions -- Tom Egan @tomegan on X -- Robin Ebers @robinebers on X -- Tadees -- buildwithfur -- piccinato -- Tobias -- Anonymous Supporter -- Tandava Appadoo -- Digitalfastmind -- Roberto Marcelino -- Tony -- Rajan RK -- Francesco -- Erwan -- Anonymous supporter +VybeClip requests Screen Recording and Accessibility access when recording requires them. Microphone and Camera access are requested only when those capture options are enabled. ---- +After changing a macOS Privacy & Security permission, quit and reopen VybeClip so the operating system applies it consistently. -# License +## Project Files -Recordly is licensed under the **AGPL 3.0**. +New projects use the `.vybeclip` extension. A project stores editor state and references its source media; it does not embed the original recording. Keep source media available at its original path when reopening a project. ---- +Legacy `.recordly` and `.openscreen` files remain supported for migration. A legacy project continues saving in place until it is saved as a new VybeClip project. -# Credits +## Repository -## Acknowledgements +Issues and MVP feedback are tracked in the [GitHub issue tracker](https://github.com/Alfredoalv13/Recordly/issues). -Recordly originally started as a fork of [OpenScreen](https://github.com/siddharthvaddem/openscreen). Over 80% of code has diverged since. -Many features of OpenScreen such as its zoom animations are directly ported from early versions of Recordly. +## License And Attribution -Created by -[@webadderall](https://x.com/webadderall) +VybeClip is licensed under the [GNU Affero General Public License v3.0](./LICENSE.md). ---- +VybeClip is based on [Recordly](https://github.com/webadderallorg/Recordly), which originated as a fork of [OpenScreen](https://github.com/siddharthvaddem/openscreen). Their work remains an important part of this codebase and is preserved under the project license and Git history. diff --git a/README.zh-CN.md b/README.zh-CN.md index b1ca9f556..f26d41c9a 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -1,400 +1,81 @@ 语言: [EN](README.md) | 简中 -

- Recordly logo -

- -

- macOS Windows Linux - AGPL 3.0 license -

- -### 无需额外剪辑,也能做出精致的屏幕录制。 -[Recordly](https://www.recordly.dev) 是一款**开源屏幕录制器**和编辑器,适合制作**操作讲解、演示、产品视频**等内容。 -**欢迎提交 PR。** [赞助](https://ko-fi.com/webadderall/goal?g=0) - -https://github.com/user-attachments/assets/9b66c71d-ac97-49ff-a0c9-63ac26edf2e4 - ---- - -## Recordly 是什么? - -Recordly 是一款桌面应用,用于录制并编辑屏幕内容,内置面向演示视频的动态呈现工具。你不需要再把原始素材交给动效设计师去补缩放、光标润色或样式化背景,Recordly 可以在一个地方免费完成整套流程。 - -Recordly 支持: - -- **macOS** 14.0+ -- **Windows** 10 Build 19041+ -- **Linux** 现代发行版 - -平台说明: - -- **macOS** 使用基于 ScreenCaptureKit 的原生捕获辅助程序。 -- **Windows** 在支持的系统版本上使用原生 Windows Graphics Capture(WGC)辅助程序,并支持原生 WASAPI 音频。 -- **Linux** 通过 Electron 捕获 API 录制。目前 Linux 还不支持隐藏真实光标。 - ---- - -# 核心功能 - -## 自动缩放、光标润色与样式化画面 -Recordly 可以根据操作自动强调重点区域,平滑光标运动,添加动态效果,并将最终画面放进带有壁纸、纯色、渐变、模糊、留白和阴影的样式化边框中。 - -

- Recordly cursor and zoom demo video -

- -## 动态摄像头气泡叠加 -你可以把摄像头素材作为气泡叠加层加入画面,使用预设位置或自定义坐标摆放,支持镜像、阴影和圆角调节,也可以让它跟随缩放变化,保证动态镜头里整体视觉更协调。 - -

- Recordly webcam overlay demo video -

- -## 为演示设计的时间线编辑 -使用拖拽式时间线工具处理缩放、裁剪、变速区域、注释、额外音频区域以及裁切感知编辑,并将工作保存为 `.recordly` 项目文件,之后随时回来继续编辑。 - -

- timeline editor -

- -## 扩展与市场 - -Recordly 拥有一个社区驱动的扩展系统。任何人都可以构建和发布扩展来为 Recordly 添加新功能,例如光标点击音效、设备边框、浏览器模拟外壳、壁纸、渲染钩子、设置面板等等。 - -浏览并安装社区扩展:[Recordly 扩展市场](https://marketplace.recordly.dev/extensions)。 - ---- - -## 全部功能 - -### 录制 - -- 录制整个显示器或单个应用窗口 -- 录制完成后直接进入编辑器 -- 录制麦克风音频和系统音频 -- 在支持的平台上使用原生捕获后端 -- 从保存的 `.recordly` 项目文件继续编辑 -- 可在应用中打开已有录像或已有项目文件 - -### 时间线与编辑 - -- 拖拽式时间线编辑 -- 裁掉不需要的片段 -- 添加手动缩放区域 -- 根据光标活动生成自动缩放建议 -- 添加加速和减速区域 -- 添加文本、图片和图形注释 -- 在时间线上添加额外音频区域 -- 裁切录制画面 -- 保存并重新打开项目,保留编辑状态 - -### 光标控制 - -- 显示或隐藏渲染后的光标叠加层 -- 调整光标大小 -- 光标平滑 -- 光标运动模糊 -- 点击弹跳效果 -- 光标摆动效果 -- 光标循环模式,方便导出更自然的循环片段 -- 使用 macOS 风格的渲染光标素材 - -### 摄像头叠加 - -- 启用或禁用摄像头叠加素材 -- 上传、替换或移除摄像头素材 -- 镜像摄像头画面 -- 调整尺寸 -- 使用预设位置或自定义 X/Y 坐标 -- 调整边距 -- 调整圆角程度 -- 调整阴影强度 -- 可选的缩放联动摄像头缩放效果 - -### 画面样式与背景 - -- 内置壁纸 -- 运行时自动发现 wallpapers 目录中的壁纸 -- 上传自定义背景图片 -- 纯色背景 -- 渐变背景 -- 画面留白 -- 圆角 -- 背景模糊 -- 投影阴影 -- 最终画面的宽高比预设 - -### 导出 - -- MP4 导出 -- GIF 导出 -- 导出质量选择 -- GIF 帧率选择 -- GIF 循环开关 -- GIF 尺寸预设 -- 宽高比和输出尺寸控制 -- 在系统文件管理器中定位导出文件 - -### 工作流与易用性 - -- 可自定义键盘快捷键 -- 应用内快捷键说明 -- 在编辑器中直接打开反馈和问题链接 -- 编辑器偏好设置持久化 -- 导出后更快恢复预览 - ---- - -# 截图 - -

- Recordly recording interface screenshot -

+# VybeClip

- Recordly editor screenshot + VybeClip app icon

-

- Recordly timeline screenshot -

+VybeClip 是一款开源桌面屏幕录制器和视频编辑器,用于制作精致的产品演示、操作讲解、教程和品牌短视频。 ---- +该应用目前正朝着首个 macOS MVP 版本推进。核心的录制与编辑引擎来自 Recordly,全新的 VybeClip Studio 体验、产品身份、项目格式和工作流程由 Box Creative Studio 维护。 -# 安装 +## MVP 功能 -## 下载构建版本 +- 录制显示器、窗口或应用程序,可选启用麦克风、系统音频和摄像头 +- 将已有视频导入编辑器 +- 添加自动或手动缩放、光标样式、背景、画面比例、裁剪、字幕、音频和注释 +- 保存并重新打开 `.vybeclip` 项目 +- 打开旧版 `.recordly` 和 `.openscreen` 项目 +- 导出 H.264 MP4 视频,包括源画质 1080p 输出 +- 从 VybeClip Studio 重新打开最近的项目 -预构建发布版本请见: +## 开发 -https://github.com/webadderallorg/Recordly/releases +前置要求: ---- +- Node.js 20 或更高版本 +- npm +- macOS 是当前 MVP 阶段的主要支持平台 -## Arch Linux / Manjaro(yay) - -可通过 AUR 安装([recordly-bin](https://aur.archlinux.org/packages/recordly-bin)): +安装依赖并启动渲染进程: ```bash -yay -S recordly-bin -``` - -PKGBUILD、桌面入口、发布同步,以及可选的**本地源码打包**都维护在 **[recordly-aur](https://github.com/firtoz/recordly-aur)** 中,因此这个仓库本身不需要承担 Arch 发布维护工作。关于维护者联系方式和软件包更新方式,请查看该仓库或 AUR 软件包页面。 - ---- - -## 从源码构建 - -### 前置依赖 - -**macOS:** 安装 Xcode Command Line Tools(`xcode-select --install`)。 - -**Linux(Ubuntu / Debian):** - -```bash -sudo apt install build-essential cmake libx11-dev libxtst-dev libxrandr-dev libxt-dev -``` - -**Windows:** 安装 Visual Studio 2022(或 Build Tools),并勾选 C++ 工作负载和 CMake。 - -### 步骤 - -```bash -git clone https://github.com/webadderallorg/Recordly.git recordly -cd recordly npm install npm run dev ``` -如果需要打包构建: +在另一个终端中启动 Electron 应用: ```bash -npm run build +npx electron . ``` -也可以使用平台专用构建命令: - -- `npm run build:mac` -- `npm run build:win` -- `npm run build:linux` - ---- - -## macOS:“App cannot be opened” - -本地构建的应用可能会被 macOS 隔离。 - -可以用以下命令移除隔离标记: +运行验证套件: ```bash -xattr -rd com.apple.quarantine /Applications/Recordly.app +npm test +npx tsc --noEmit ``` ---- - -# 系统要求 - -| 平台 | 最低版本 | 说明 | -|---|---|---| -| **macOS** | macOS 14.0 (Sonoma) | 使用 ScreenCaptureKit 捕获音频和麦克风所必需。 | -| **Windows** | Windows 10 20H1(Build 19041,2020 年 5 月) | 原生 Windows Graphics Capture(WGC)辅助程序及最佳光标隐藏行为所必需。 | -| **Linux** | 任意现代发行版 | 通过 Electron 捕获录制。系统音频通常需要 PipeWire。 | - -> [!IMPORTANT] -> 在 Windows 19041 之前的版本上,录制仍可能通过回退捕获方式工作,但真实系统光标可能仍会出现在视频中。 - ---- - -# 使用方法 - -## 录制 - -1. 启动 Recordly。 -2. 选择屏幕或窗口。 -3. 选择麦克风和系统音频选项。 -4. 开始录制。 -5. 停止录制后进入编辑器。 - -## 编辑 - -在编辑器中,你可以: - -- 添加裁剪、缩放、变速区域和注释 -- 调整光标行为和预览音量 -- 使用壁纸、纯色、渐变、模糊、留白和圆角来美化画面 -- 添加或调整摄像头叠加素材 -- 添加额外音频区域 -- 裁切画面并选择宽高比 - -你可以随时将工作保存为 `.recordly` 项目。 - -## 导出 - -支持以下导出格式: - -- **MP4**,适合常规视频输出 -- **GIF**,适合轻量分享和循环片段 - -你可以在导出前调整格式相关设置,例如质量、GIF 帧率、GIF 循环方式和输出尺寸。 - ---- - -# 限制 - -### 光标捕获 - -Recordly 会在录制画面上渲染一个经过美化的光标叠加层,但真实系统光标是否能被隐藏仍取决于平台能力。 +构建 macOS 安装包: -**macOS** -- ScreenCaptureKit 可以较干净地排除真实光标。 - -**Windows** -- 最佳效果需要 Windows 10 Build 19041+ 和原生捕获辅助程序。 -- 较旧版本会回退到 Electron 捕获,因此真实光标可能仍会显示。 - -**Linux** -- Electron 桌面捕获目前不支持隐藏真实光标。 -- 如果同时启用渲染光标叠加,导出中可能会同时看到真实光标和样式化光标。 - -### 系统音频 - -系统音频支持因平台而异。 - -**Windows** -- 原生 WASAPI 支持 - -**Linux** -- 通常需要 PipeWire - -**macOS** -- 需要 macOS 14.0+ 和基于 ScreenCaptureKit 的工作流 - ---- - -# 工作原理 - -Recordly 将平台相关的捕获层与基于渲染器的编辑、导出流程结合在一起。 - -**捕获** -- Electron 负责录制流程和应用级控制 -- macOS 使用原生 ScreenCaptureKit 辅助程序 -- Windows 在可用时使用原生 Windows Graphics Capture(WGC)辅助程序和原生音频辅助程序 - -**编辑** -- 时间线区域定义缩放、裁剪、变速、音频叠加和注释 -- 光标和摄像头样式都保存在编辑器状态中 - -**渲染** -- 场景合成由 **PixiJS** 负责 - -**导出** -- 预览使用的同一套场景逻辑会被用于导出 MP4 或 GIF - -**项目** -- `.recordly` 文件会保存源媒体路径和编辑器状态,方便后续继续编辑 - ---- - -# 贡献 - -欢迎贡献。 - -特别需要帮助的方向包括: - -- Linux 录制与光标行为改进 -- 导出性能与稳定性优化 -- UI 和 UX 打磨 -- 本地化工作 -- 更多编辑工具与工作流优化 - -请尽量让 Pull Request 保持聚焦,测试录制、编辑、导出流程,并避免无关重构。 - -请参阅 `CONTRIBUTING.md` 了解具体指南。 - ---- - -# 社区 - -问题反馈和功能建议: - -https://github.com/webadderallorg/Recordly/issues - -欢迎提交 Pull Request。 +```bash +npm run build:mac +``` ---- +构建产物会写入 `release/` 目录。 -# 支持者名单 +有关验收检查、签名、构建产物和公证的说明,请参阅 [MVP 发布指南](./_documentation/MVP_RELEASE.md)。 -[![Ko-Fi](https://img.shields.io/badge/Ko--fi-F16061?style=for-the-badge&logo=ko-fi&logoColor=white)](https://ko-fi.com/webadderall) +## macOS 权限 -- Tadees -- buildwithfur -- Tobias -- Anonymous Supporter -- Tandava Appadoo -- Digitalfastmind -- Roberto Marcelino -- Rajan RK -- Francesco -- Erwan -- Anonymous supporter +当录制需要时,VybeClip 会请求屏幕录制和辅助功能权限。仅在启用麦克风或摄像头采集选项时,才会请求相应权限。 ---- +在修改 macOS 隐私与安全性权限后,请退出并重新打开 VybeClip,以确保系统一致地应用该权限设置。 -# 许可证 +## 项目文件 -Recordly 基于 **AGPL 3.0** 发布。 +新项目使用 `.vybeclip` 扩展名。项目会保存编辑器状态并引用其源媒体文件,但不会内嵌原始录制内容。重新打开项目时,请确保源媒体文件仍位于其原始路径。 ---- +旧版 `.recordly` 和 `.openscreen` 文件仍可用于迁移。旧版项目在被另存为新的 VybeClip 项目之前,会继续原地保存。 -# 致谢 +## 仓库 -## 鸣谢 +问题反馈和 MVP 相关意见请提交至 [GitHub issue 跟踪器](https://github.com/Alfredoalv13/Recordly/issues)。 -Recordly 最初是从 [OpenScreen](https://github.com/siddharthvaddem/openscreen) 分叉而来,之后已逐步演变为一个不同的项目。 +## 许可证与致谢 -创建者 -[@webadderall](https://x.com/webadderall) +VybeClip 基于 [GNU Affero 通用公共许可证 v3.0](./LICENSE.md) 发布。 ---- +VybeClip 基于 [Recordly](https://github.com/webadderallorg/Recordly) 开发,而 Recordly 最初由 [OpenScreen](https://github.com/siddharthvaddem/openscreen) 分叉而来。他们的工作仍是本代码库的重要组成部分,并依据本项目许可证和 Git 历史记录得以保留。 diff --git a/_documentation/MVP_RELEASE.md b/_documentation/MVP_RELEASE.md new file mode 100644 index 000000000..490d14a1a --- /dev/null +++ b/_documentation/MVP_RELEASE.md @@ -0,0 +1,82 @@ +# VybeClip MVP Release + +Status: release candidate built on July 1, 2026. + +## MVP Acceptance + +- VybeClip Studio is the main desktop window. +- Record, Import, Open Project, Recent Projects, and Projects Folder are connected to Electron. +- Screen Recording and Accessibility onboarding is available on macOS. +- New projects use `.vybeclip`; `.recordly` and `.openscreen` projects remain compatible. +- The editor saves project state and exports MP4. +- A real Electron smoke export produced H.264/AAC at 1920 x 1080, 30 fps, and 3 seconds. +- The complete test suite passes: 92 files and 786 tests. +- TypeScript, production renderer, Electron main-process smoke, and strict code-signature checks pass. +- The signed Apple Silicon app launches from the packaged release bundle. + +## Release Artifacts + +Artifacts are generated in `release/`: + +- `VybeClip-arm64.dmg` for Apple Silicon Macs +- `VybeClip-arm64.zip` for Apple Silicon updates/manual distribution +- `VybeClip-x64.dmg` for Intel Macs +- `VybeClip-x64.zip` for Intel updates/manual distribution +- `SHA256SUMS.txt` for artifact verification + +DMG checksums for this release candidate: + +```text +10e3dcfb9c839830c34188a046cd0efe68af204fc59a0a8184e1ddde21eaab5f VybeClip-arm64.dmg +e1bfba990d47b2537bc37c136c430dd4ca95845782a78b67ae84e9191d4fe35d VybeClip-x64.dmg +``` + +## Code Signing + +The app is signed with: + +```text +Developer ID Application: Alfredo Alvarez (2733S52X97) +Bundle ID: studio.boxcreative.vybeclip +``` + +This Mac has duplicate copies of the same Developer ID certificate, so electron-builder cannot select it by display name. Package an unsigned app, then sign by SHA-1 fingerprint: + +```bash +CSC_IDENTITY_AUTO_DISCOVERY=false npx electron-builder --mac --arm64 --dir +VYBECLIP_CODESIGN_IDENTITY= npm run sign:mac -- release/mac-arm64/VybeClip.app +CSC_IDENTITY_AUTO_DISCOVERY=false npx electron-builder --mac dmg zip --arm64 --prepackaged release/mac-arm64/VybeClip.app +``` + +Use `--x64` and `release/mac/VybeClip.app` for the Intel build. + +Verify a signed app with: + +```bash +codesign --verify --deep --strict --verbose=2 release/mac-arm64/VybeClip.app +``` + +## Notarization + +The release candidate is Developer ID signed and timestamped, but not notarized. Gatekeeper reports `Unnotarized Developer ID` until Apple notarization credentials are configured. + +Before public distribution, configure an Apple notarization profile or the supported electron-builder Apple credentials, submit both DMGs, staple the accepted tickets, and rerun Gatekeeper assessment: + +```bash +spctl --assess --type execute --verbose=4 release/mac-arm64/VybeClip.app +``` + +The expected final result is `accepted` with a notarized Developer ID source. + +## Manual Release Check + +Before publishing a version tag: + +1. Install the DMG on a clean macOS user account. +2. Grant Screen Recording and Accessibility when prompted. +3. Record a display with microphone and system audio. +4. Stop recording and confirm the editor opens. +5. Add one zoom or visual style change. +6. Save a `.vybeclip` project, quit, reopen it from Recent Projects, and confirm the edit remains. +7. Export source-quality MP4 and play the entire file with audio. +8. Confirm the app icon, app name, permission copy, project extension, and exported filename all show VybeClip branding. diff --git a/_documentation/vybeclip_studio_mockup.html b/_documentation/vybeclip_studio_mockup.html new file mode 100644 index 000000000..2ec3e00da --- /dev/null +++ b/_documentation/vybeclip_studio_mockup.html @@ -0,0 +1,105 @@ + + + + + + VybeClip | Studio Interface + + + + + + + +
+
+ + + + + + + VybeClip_Studio_v01 +
+ +
+ + + +
+
+ +
+ + + + + +
+
+
+
Canvas Stage
+
+
+ + +
+
+ Timeline +
+
+
Source_Clip_01.mp4
+
+
+
+
+
+
+ + + +
+ + + + diff --git a/electron-builder.json5 b/electron-builder.json5 index 1aaa56af1..1bc13d82a 100644 --- a/electron-builder.json5 +++ b/electron-builder.json5 @@ -1,7 +1,7 @@ // @see - https://www.electron.build/configuration/configuration { "$schema": "https://raw.githubusercontent.com/electron-userland/electron-builder/master/packages/app-builder-lib/scheme.json", - "appId": "dev.recordly.app", + "appId": "studio.boxcreative.vybeclip", "electronUpdaterCompatibility": ">=2.16", "asar": true, "asarUnpack": [ @@ -10,7 +10,7 @@ "node_modules/uiohook-napi/**", "electron/native/**" ], - "productName": "Recordly", + "productName": "VybeClip", "npmRebuild": true, "buildDependenciesFromSource": true, "compression": "normal", @@ -41,8 +41,8 @@ "publish": [ { "provider": "github", - "owner": "webadderall", - "repo": "Recordly", + "owner": "Alfredoalv13", + "repo": "Recordly", "tagNamePrefix": "v", "publishAutoUpdate": true } @@ -65,9 +65,9 @@ "icon": "icons/icons/mac/icon.icns", "artifactName": "${productName}-${arch}.${ext}", "extendInfo": { - "NSAudioCaptureUsageDescription": "Recordly needs audio capture permission to record system audio.", - "NSCameraUsageDescription": "Recordly needs camera access to record webcam video.", - "NSMicrophoneUsageDescription": "Recordly needs microphone access to record voice audio.", + "NSAudioCaptureUsageDescription": "VybeClip needs audio capture permission to record system audio.", + "NSCameraUsageDescription": "VybeClip needs camera access to record webcam video.", + "NSMicrophoneUsageDescription": "VybeClip needs microphone access to record voice audio.", "NSCameraUseContinuityCameraDeviceType": true, "com.apple.security.device.audio-input": true } @@ -86,7 +86,7 @@ ], "icon": "icons/icons/win/icon.ico" , - "executableName": "Recordly", + "executableName": "VybeClip", "artifactName": "${productName}-windows-${arch}.${ext}" } } diff --git a/electron/appPaths.ts b/electron/appPaths.ts index 68d6cfc39..834c269f4 100644 --- a/electron/appPaths.ts +++ b/electron/appPaths.ts @@ -2,7 +2,7 @@ import path from "node:path"; import { app } from "electron"; if (process.env["VITE_DEV_SERVER_URL"]) { - const devUserDataPath = path.join(app.getPath("appData"), "Recordly-dev"); + const devUserDataPath = path.join(app.getPath("appData"), "VybeClip-dev"); app.setPath("userData", devUserDataPath); app.setPath("sessionData", path.join(devUserDataPath, "session")); } diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index 6dc7d3966..f776bf962 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -221,6 +221,7 @@ interface Window { getSources: (opts: Electron.SourcesOptions) => Promise; switchToEditor: () => Promise; openSourceSelector: () => Promise; + showRecordingControls: () => Promise<{ success: boolean }>; selectSource: (source: ProcessedDesktopSource) => Promise; showSourceHighlight: (source: ProcessedDesktopSource) => Promise<{ success: boolean }>; getSelectedSource: () => Promise; diff --git a/electron/extensions/extensionIpc.ts b/electron/extensions/extensionIpc.ts index d1a32b673..806740ec5 100644 --- a/electron/extensions/extensionIpc.ts +++ b/electron/extensions/extensionIpc.ts @@ -71,7 +71,16 @@ export function registerExtensionIpcHandlers(): void { return setExtensionStatus(id, "disabled"); }); - // Install an extension from a folder picker + // Install an extension from a folder picker. + // + // Security: this handler intentionally takes no renderer-supplied path + // argument. The only path ever passed to installExtensionFromPath() is + // the one the OS-native dialog.showOpenDialog() call below returns — + // mirroring the whisper-executable-picker pattern in + // electron/ipc/register/captions.ts (approveWhisperExecutablePath). A + // compromised renderer cannot invoke this with an attacker-controlled + // path because there is nothing for it to supply: do not add a `path` + // parameter to this handler. ipcMain.handle("extensions:install-from-folder", async (event) => { const window = BrowserWindow.fromWebContents(event.sender); const result = await dialog.showOpenDialog(window!, { @@ -84,7 +93,9 @@ export function registerExtensionIpcHandlers(): void { return { success: false, reason: "cancelled" }; } - const info = await installExtensionFromPath(result.filePaths[0]); + // Only the path returned by the dialog above is ever trusted here. + const dialogSelectedPath = result.filePaths[0]; + const info = await installExtensionFromPath(dialogSelectedPath); if (!info) { return { success: false, diff --git a/electron/extensions/extensionMarketplace.ts b/electron/extensions/extensionMarketplace.ts index e8bc66ddf..55532f2da 100644 --- a/electron/extensions/extensionMarketplace.ts +++ b/electron/extensions/extensionMarketplace.ts @@ -28,6 +28,30 @@ import type { const MARKETPLACE_API_BASE = "https://marketplace.recordly.dev/extensions/api/v1"; const REQUEST_TIMEOUT_MS = 15_000; +// --------------------------------------------------------------------------- +// Testing-phase gate +// +// Remote marketplace installation is disabled for now: the marketplace has +// only an origin allowlist (no checksum/signature verification) and +// extension code loads directly into the main renderer with full +// window/document/fetch/WebSocket access. Until a real trust model +// (signing + a sandboxed execution context) is in place, installing +// extensions from the network is blocked here — locally bundled/first-party +// extensions and "install from folder" (native dialog only) are unaffected. +// --------------------------------------------------------------------------- + +const REMOTE_INSTALL_DISABLED_MESSAGE = + "Installing extensions from the marketplace is temporarily disabled during the testing phase. Locally bundled extensions continue to work normally."; + +/** + * Whether downloading and installing extensions from the remote marketplace + * is currently permitted. Kept as a single toggle so it can be flipped back + * on later once signing/verification lands — defaults to disabled. + */ +export function isRemoteMarketplaceInstallEnabled(): boolean { + return process.env.RECORDLY_ENABLE_MARKETPLACE_INSTALL === "1"; +} + // --------------------------------------------------------------------------- // Zip-slip protection: recursively verify all extracted files stay within the // expected directory. Rejects symlinks that point outside and any entry whose @@ -166,6 +190,14 @@ export async function downloadAndInstallExtension( extensionId: string, downloadUrl: string, ): Promise<{ success: boolean; error?: string }> { + // Testing-phase gate: remote/network extension installs are disabled + // until the marketplace has real checksum/signature verification and + // extension code runs in a sandboxed context. See + // isRemoteMarketplaceInstallEnabled() above. + if (!isRemoteMarketplaceInstallEnabled()) { + return { success: false, error: REMOTE_INSTALL_DISABLED_MESSAGE }; + } + // Validate download URL against allowed marketplace origins const allowedOrigins = [ "https://marketplace.recordly.dev", @@ -181,6 +213,28 @@ export async function downloadAndInstallExtension( return { success: false, error: "Invalid download URL" }; } + // Defense in depth: never install an extension whose marketplace review + // status isn't "approved", even if the remote-install gate above is + // later re-enabled. This protects against a compromised/mocked + // marketplace response, or a stale downloadUrl for an extension that + // was flagged/rejected after the caller fetched its listing. + // getMarketplaceExtension() already catches its own errors and returns + // null on any failure (network error, deleted extension, non-200), so + // treat "couldn't verify" as fail-closed rather than silently proceeding. + const listing = await getMarketplaceExtension(extensionId); + if (!listing) { + return { + success: false, + error: "Unable to verify extension review status; refusing to install.", + }; + } + if (listing.reviewStatus !== "approved") { + return { + success: false, + error: `Extension is not approved for installation (review status: ${listing.reviewStatus})`, + }; + } + const tempDir = path.join(app.getPath("temp"), `recordly-ext-${extensionId}-${Date.now()}`); const zipPath = path.join(tempDir, "extension.zip"); diff --git a/electron/ipc/captions/generate.ts b/electron/ipc/captions/generate.ts index 33c345848..1c6d63e9d 100644 --- a/electron/ipc/captions/generate.ts +++ b/electron/ipc/captions/generate.ts @@ -7,7 +7,7 @@ import { app } from "electron"; import { getFfmpegBinaryPath } from "../ffmpeg/binary"; import { getBundledWhisperExecutableCandidates } from "../paths/binaries"; import { parseWhisperJsonCues, parseSrtCues, shouldRetryWhisperWithoutJson } from "./parser"; -import { normalizeVideoSourcePath } from "../utils"; +import { isApprovedWhisperExecutablePath, normalizeVideoSourcePath } from "../utils"; import { resolveRecordingSession } from "../project/session"; const execFileAsync = promisify(execFile); @@ -32,9 +32,31 @@ export async function isExecutableFile(filePath: string) { } } +// A whisper binary is only ever allowed to run if it's one of the app's own +// bundled/default candidates, or a path the user explicitly selected via the +// open-whisper-executable-picker dialog (see approvedWhisperExecutablePaths / +// isApprovedWhisperExecutablePath). This mirrors the approvedLocalReadPaths / +// isTrustedProjectPath / isOwnedExportPath allowlist pattern used elsewhere in +// electron/ipc so a compromised renderer can't smuggle an arbitrary path into +// execFileAsync just by naming it in the IPC payload. +export function isAllowedWhisperExecutablePath(candidatePath: string): boolean { + const normalized = path.resolve(candidatePath); + const bundledCandidates = getBundledWhisperExecutableCandidates().map((candidate) => + path.resolve(candidate), + ); + return bundledCandidates.includes(normalized) || isApprovedWhisperExecutablePath(normalized); +} + export async function resolveWhisperExecutablePath(preferredPath?: string | null) { + const trimmedPreferredPath = preferredPath?.trim() || null; + if (trimmedPreferredPath && !isAllowedWhisperExecutablePath(trimmedPreferredPath)) { + throw new Error( + "The provided Whisper executable path was not approved. Select the Whisper binary via the picker dialog, or use the bundled default.", + ); + } + const candidatePaths = [ - preferredPath?.trim() || null, + trimmedPreferredPath, ...getBundledWhisperExecutableCandidates(), process.env["WHISPER_CPP_PATH"]?.trim() || null, process.platform === "darwin" ? "/opt/homebrew/bin/whisper-cli" : null, @@ -71,7 +93,7 @@ export async function resolveWhisperExecutablePath(preferredPath?: string | null } throw new Error( - "No Whisper runtime was found. Recordly looked for a bundled binary first, then checked common system install locations.", + "No Whisper runtime was found. VybeClip looked for a bundled binary first, then checked common system install locations.", ); } diff --git a/electron/ipc/constants.ts b/electron/ipc/constants.ts index 2c5cf8f17..8004da53d 100644 --- a/electron/ipc/constants.ts +++ b/electron/ipc/constants.ts @@ -1,8 +1,8 @@ import path from "node:path"; import { USER_DATA_PATH } from "../appPaths"; -export const PROJECT_FILE_EXTENSION = "recordly"; -export const LEGACY_PROJECT_FILE_EXTENSIONS = ["openscreen"]; +export const PROJECT_FILE_EXTENSION = "vybeclip"; +export const LEGACY_PROJECT_FILE_EXTENSIONS = ["recordly", "openscreen"]; export const PROJECTS_DIRECTORY_NAME = "Projects"; export const PROJECT_THUMBNAIL_SUFFIX = ".preview.png"; export const RECENT_PROJECTS_FILE = path.join(USER_DATA_PATH, "recent-projects.json"); diff --git a/electron/ipc/paths/binaries.ts b/electron/ipc/paths/binaries.ts index 739f7df84..2a4bf83fd 100644 --- a/electron/ipc/paths/binaries.ts +++ b/electron/ipc/paths/binaries.ts @@ -49,6 +49,14 @@ export function getPrebundledNativeHelperPath(binaryName: string): string { return resolveUnpackedAppPath("electron", "native", "bin", getNativeArchTag(), binaryName); } +function getWindowsNativeArchTag(): string { + return process.arch === "arm64" ? "win32-arm64" : "win32-x64"; +} + +function getPrebundledWindowsNativeHelperPath(binaryName: string): string { + return resolveUnpackedAppPath("electron", "native", "bin", getWindowsNativeArchTag(), binaryName); +} + export function resolvePreferredWindowsNativeHelperPath( helperDirectory: string, binaryName: string, @@ -61,7 +69,7 @@ export function resolvePreferredWindowsNativeHelperPath( "Release", binaryName, ); - const prebundledPath = getPrebundledNativeHelperPath(binaryName); + const prebundledPath = getPrebundledWindowsNativeHelperPath(binaryName); if (app.isPackaged && existsSync(prebundledPath)) { return prebundledPath; diff --git a/electron/ipc/project/manager.test.ts b/electron/ipc/project/manager.test.ts index 3fa810da8..3740f8e2b 100644 --- a/electron/ipc/project/manager.test.ts +++ b/electron/ipc/project/manager.test.ts @@ -151,6 +151,15 @@ describe("local media path policy", () => { await expect(fs.readFile(thumbnailPath, "utf8")).resolves.toBe("png-thumbnail"); }); + it("recognizes VybeClip projects and legacy Recordly/OpenScreen projects", async () => { + const { hasProjectFileExtension } = await import("./manager"); + + expect(hasProjectFileExtension("demo.vybeclip")).toBe(true); + expect(hasProjectFileExtension("demo.recordly")).toBe(true); + expect(hasProjectFileExtension("demo.openscreen")).toBe(true); + expect(hasProjectFileExtension("demo.json")).toBe(false); + }); + it("loads project files that start with a UTF-8 byte order mark", async () => { const videoPath = path.join(tempPath, "recording.mp4"); const projectPath = path.join(tempPath, "recording.recordly"); diff --git a/electron/ipc/project/manager.ts b/electron/ipc/project/manager.ts index 2f6e4fc21..f336c2fcc 100644 --- a/electron/ipc/project/manager.ts +++ b/electron/ipc/project/manager.ts @@ -469,3 +469,69 @@ export function isTrustedProjectPath(filePath?: string | null): boolean { if (!filePath || !currentProjectPath) return false; return normalizePath(filePath) === normalizePath(currentProjectPath); } + +/** + * Confines project-open requests to locations the app itself considers safe: + * the managed projects directory, a path the app has already recorded in the + * recent-projects list (which is only ever populated via trusted dialog- + * driven save/load flows, never from an arbitrary renderer-supplied string), + * or the project fixture path the *main process* (not the renderer) launched + * the editor window with for an automated smoke export + * (RECORDLY_SMOKE_EXPORT_PROJECT, gated behind RECORDLY_SMOKE_EXPORT=1). That + * env var is only ever set by whoever spawns the Electron process for local/ + * CI smoke testing and is read once here directly from process.env, so it + * cannot be influenced by a compromised renderer the way an arbitrary + * `open-project-file-at-path` argument could be. Smoke-export fixtures + * intentionally live outside the managed projects directory (e.g. a CI temp + * dir), so folding them into the projects-dir/recent-projects allowlist would + * either require copying fixtures into app-managed storage (fragile for CI) + * or widening the allowlist to arbitrary paths (defeats the fix); trusting + * only this exact, main-process-controlled path preserves the security intent + * while keeping the smoke-export dev/CI flow working. + * This mirrors isAllowedLocalReadPath's canonicalization so a symlink cannot + * be used to smuggle in a path outside these locations. + */ +export async function isTrustedOpenableProjectPath(filePath?: string | null): Promise { + if (!filePath || typeof filePath !== "string" || !filePath.trim()) { + return false; + } + + if (!hasProjectFileExtension(filePath)) { + return false; + } + + const normalizedCandidatePath = normalizePath(filePath); + const projectsDir = await getProjectsDir(); + const recentProjectPaths = await loadRecentProjectPaths(); + const trustedPaths = [projectsDir, ...recentProjectPaths]; + if (process.env.RECORDLY_SMOKE_EXPORT === "1" && process.env.RECORDLY_SMOKE_EXPORT_PROJECT) { + trustedPaths.push(process.env.RECORDLY_SMOKE_EXPORT_PROJECT); + } + const trustedLexicalPaths = new Set(trustedPaths.map((value) => normalizePath(value))); + + const isLexicallyTrusted = + isPathInsideDirectory(normalizedCandidatePath, normalizePath(projectsDir)) || + trustedLexicalPaths.has(normalizedCandidatePath); + if (!isLexicallyTrusted) { + return false; + } + + // Canonicalize to guard against a symlink under a trusted location that + // resolves outside of it. + let canonicalCandidatePath = normalizedCandidatePath; + try { + canonicalCandidatePath = normalizePath(realpathSync(normalizedCandidatePath)); + } catch { + // File may not exist yet / cannot be resolved; the lexical check above + // already constrained it, so fall through with the lexical path. + } + + if (canonicalCandidatePath === normalizedCandidatePath) { + return true; + } + + return ( + isPathInsideDirectory(canonicalCandidatePath, normalizePath(projectsDir)) || + trustedLexicalPaths.has(canonicalCandidatePath) + ); +} diff --git a/electron/ipc/register/assets.ts b/electron/ipc/register/assets.ts index a0b132ea2..98320e555 100644 --- a/electron/ipc/register/assets.ts +++ b/electron/ipc/register/assets.ts @@ -5,17 +5,36 @@ import { pathToFileURL } from "node:url"; import { ipcMain } from "electron"; import { USER_DATA_PATH } from "../../appPaths"; import { normalizePath } from "../utils"; -import { getAssetRootPath } from "../project/manager"; +import { getAssetRootPath, isAllowedLocalReadPath } from "../project/manager"; export function registerAssetHandlers() { + // Confines reads to app-managed directories (bundled asset root, recordings + // dir, userData, temp) or paths the app has explicitly approved (e.g. a file + // chosen via a native dialog.showOpenDialog result, tracked server-side via + // approveUserPath/rememberApprovedLocalReadPath). This mirrors the allowlist + // used for the loopback media server and native exports so a compromised + // renderer cannot use these handlers to read arbitrary files on disk (SSH + // keys, config files with secrets, etc). Both the lexical and the + // canonicalized (realpath) form must satisfy the policy so a symlink placed + // under an allowed prefix cannot smuggle in a target outside it. async function resolveReadableLocalFilePath(filePath: string) { const normalizedPath = normalizePath(filePath) + if (!isAllowedLocalReadPath(normalizedPath)) { + throw new Error('Path is not approved for local reads') + } + const resolvedPath = await fs.realpath(normalizedPath).catch(() => normalizedPath) const stats = await fs.stat(resolvedPath) if (!stats.isFile()) { throw new Error('Path is not a readable file') } - return normalizePath(resolvedPath) + + const canonicalPath = normalizePath(resolvedPath) + if (!isAllowedLocalReadPath(canonicalPath)) { + throw new Error('Path is not approved for local reads') + } + + return canonicalPath } // Generate a tiny thumbnail for a wallpaper image and cache it in userData. @@ -67,7 +86,8 @@ export function registerAssetHandlers() { return { success: true, data: jpegData! } } catch (error) { - return { success: false, error: String(error) } + console.error('Failed to generate wallpaper thumbnail:', error) + return { success: false, error: 'Failed to generate thumbnail' } } }) @@ -103,24 +123,24 @@ export function registerAssetHandlers() { return { success: true, files } } catch (error) { console.error('Failed to list asset directory:', error) - return { success: false, error: String(error) } + return { success: false, error: 'Failed to list asset directory' } } }) ipcMain.handle('read-local-file', async (_, filePath: string) => { try { - // Intentionally more permissive than the media-server allowlist: this IPC - // is used for direct renderer-side local file reads after the app has - // already accepted a path, while URL-based media serving must stay scoped - // to approved/app-managed locations. We still canonicalize the path and - // require a real on-disk file so this cannot be used to read directories. + // Uses the same allowlist as the media server and native exports: the + // bundled asset root, recordings dir, userData, temp dir, or a path the + // app has explicitly approved (e.g. the result of a dialog.showOpenDialog + // call). An arbitrary string supplied by a compromised renderer that + // isn't one of those is rejected here. const resolved = await resolveReadableLocalFilePath(filePath) const data = await fs.readFile(resolved) return { success: true, data } } catch (error) { console.error('Failed to read local file:', error) - return { success: false, error: String(error) } + return { success: false, error: 'Failed to read local file' } } }) diff --git a/electron/ipc/register/captions.ts b/electron/ipc/register/captions.ts index 9d9b7f060..0f3fcbf54 100644 --- a/electron/ipc/register/captions.ts +++ b/electron/ipc/register/captions.ts @@ -7,7 +7,7 @@ import { sendWhisperModelDownloadProgress, } from "../captions/whisper"; import { generateAutoCaptionsFromVideo } from "../captions/generate"; -import { approveUserPath, getRecordingsDir } from "../utils"; +import { approveUserPath, approveWhisperExecutablePath, getRecordingsDir } from "../utils"; export function registerCaptionHandlers() { ipcMain.handle('open-video-file-picker', async () => { @@ -89,6 +89,9 @@ export function registerCaptionHandlers() { } approveUserPath(result.filePaths[0]) + // Only a path the user explicitly picked in this dialog is trusted for + // execFileAsync in generate-auto-captions; see approvedWhisperExecutablePaths. + approveWhisperExecutablePath(result.filePaths[0]) return { success: true, path: result.filePaths[0] } } catch (error) { console.error('Failed to open Whisper executable picker:', error) @@ -143,7 +146,7 @@ export function registerCaptionHandlers() { return { success: true, path: modelPath } } catch (error) { console.error('Failed to download Whisper small model:', error) - return { success: false, error: String(error) } + return { success: false, error: 'Failed to download Whisper model' } } }) @@ -173,9 +176,9 @@ export function registerCaptionHandlers() { status: 'error', progress: 0, path: null, - error: String(error), + error: 'Failed to delete Whisper model', }) - return { success: false, error: String(error) } + return { success: false, error: 'Failed to delete Whisper model' } } }) @@ -198,7 +201,7 @@ export function registerCaptionHandlers() { console.error('Failed to generate auto captions:', error) return { success: false, - error: String(error), + error: 'Failed to generate auto captions', message: 'Failed to generate auto captions', } } diff --git a/electron/ipc/register/export.ts b/electron/ipc/register/export.ts index c4410a271..ac260a4db 100644 --- a/electron/ipc/register/export.ts +++ b/electron/ipc/register/export.ts @@ -1,10 +1,12 @@ import type { ChildProcessByStdio } from "node:child_process"; import { spawn } from "node:child_process"; +import { realpathSync } from "node:fs"; import fs from "node:fs/promises"; import path from "node:path"; import type { Readable, Writable } from "node:stream"; import type { SaveDialogOptions } from "electron"; import { app, BrowserWindow, dialog, ipcMain } from "electron"; +import { RECORDINGS_DIR, USER_DATA_PATH } from "../../appPaths"; import { parseCaptionSidecarPayload, type CaptionSidecarPayload, @@ -49,8 +51,12 @@ import { type NativeExportEncodingMode, type NativeVideoExportFinishOptions, } from "../nativeVideoExport"; -import { isAllowedLocalReadPath, resolveApprovedLocalMediaPath } from "../project/manager"; -import { approveUserPath } from "../utils"; +import { + isAllowedLocalReadPath, + isPathInsideDirectory, + resolveApprovedLocalMediaPath, +} from "../project/manager"; +import { approveUserPath, normalizePath } from "../utils"; function getPartialExportDestinationPath(destinationPath: string) { const parsed = path.parse(destinationPath); @@ -252,6 +258,42 @@ function isTempPathSafe(tempPath: string): boolean { return candidate.startsWith(withSep); } +// Security: "write-exported-video-to-path" lets the renderer choose an +// arbitrary destination (used by dev/smoke-export tooling), so — unlike a +// save-dialog result — it is not implicitly scoped to a user-chosen path. +// Confine writes to app-managed directories the same way isAllowedLocalReadPath +// confines reads, and canonicalize so a symlink can't smuggle the write outside +// the allowlist. +function isAllowedExportWritePath(candidatePath: string): boolean { + const allowedPrefixes = [ + app.getPath("temp"), + app.getPath("downloads"), + USER_DATA_PATH, + RECORDINGS_DIR, + ]; + const normalizedCandidatePath = normalizePath(candidatePath); + if (!allowedPrefixes.some((prefix) => isPathInsideDirectory(normalizedCandidatePath, prefix))) { + return false; + } + + // The destination file itself usually doesn't exist yet, so canonicalize the + // parent directory instead and rejoin the (non-existent) file name. + let canonicalDirectory: string; + try { + canonicalDirectory = normalizePath(realpathSync(path.dirname(normalizedCandidatePath))); + } catch { + // Parent directory doesn't exist yet (e.g. it will be mkdir'd below); the + // lexical check above is all we can rely on in that case. + return true; + } + + const canonicalCandidatePath = path.join( + canonicalDirectory, + path.basename(normalizedCandidatePath), + ); + return allowedPrefixes.some((prefix) => isPathInsideDirectory(canonicalCandidatePath, prefix)); +} + export function registerExportHandlers() { ipcMain.handle( "native-video-export-start", @@ -387,7 +429,7 @@ export function registerExportHandlers() { ); return { success: false, - error: String(error), + error: "Failed to start native video export session", }; } }, @@ -415,7 +457,7 @@ export function registerExportHandlers() { console.warn("[probe-native-video-metadata] Failed:", error); return { success: false, - error: error instanceof Error ? error.message : String(error), + error: "Failed to probe video metadata", }; } }); @@ -430,7 +472,7 @@ export function registerExportHandlers() { console.warn("[native-export-capabilities] Failed:", error); return { success: false, - error: error instanceof Error ? error.message : String(error), + error: "Failed to get native export capabilities", }; } }); @@ -474,7 +516,7 @@ export function registerExportHandlers() { console.warn("[native-static-layout-export] Failed:", error); return { success: false, - error: error instanceof Error ? error.message : String(error), + error: "Failed to export static layout video", }; } }, @@ -912,6 +954,15 @@ export function registerExportHandlers() { captionSidecar?: CaptionSidecarPayload, ) => { try { + if (typeof outputPath !== "string" || outputPath.trim().length === 0) { + return { + success: false, + message: "Invalid output path", + canceled: false, + error: "write-exported-video-to-path requires an output path", + }; + } + const sidecarPayload = parseCaptionSidecarPayload(captionSidecar); const sizeError = getInMemoryExportTooLargeMessage(videoData.byteLength); if (sizeError) { @@ -924,6 +975,19 @@ export function registerExportHandlers() { } const resolvedPath = path.resolve(outputPath); + // Security: this path comes directly from the renderer (e.g. dev/ + // smoke-export tooling), not from a tracked dialog.showSaveDialog + // result, so confine it to app-managed directories the same way + // reads are confined via isAllowedLocalReadPath. Without this an + // arbitrary/compromised renderer could write anywhere on disk. + if (!isAllowedExportWritePath(resolvedPath)) { + return { + success: false, + message: "Output path is not an approved export destination", + canceled: false, + error: "Output path is not an approved export destination", + }; + } await fs.mkdir(path.dirname(resolvedPath), { recursive: true }); await fs.writeFile(resolvedPath, Buffer.from(videoData)); const captionSidecarResult = await writeCaptionSidecarsBestEffort( diff --git a/electron/ipc/register/project.ts b/electron/ipc/register/project.ts index a65fd9070..a1778c2fe 100644 --- a/electron/ipc/register/project.ts +++ b/electron/ipc/register/project.ts @@ -13,6 +13,7 @@ import { getProjectsDir, getProjectThumbnailPath, isPathInsideDirectory, + isTrustedOpenableProjectPath, isTrustedProjectPath, listProjectLibraryEntries, loadProjectFromPath, @@ -64,8 +65,9 @@ function normalizeProjectSaveName(projectName?: string | null) { return null; } + const acceptedExtensions = [PROJECT_FILE_EXTENSION, ...LEGACY_PROJECT_FILE_EXTENSIONS].join("|"); const withoutExtension = trimmedName.replace( - new RegExp(`\\.${PROJECT_FILE_EXTENSION}$`, "i"), + new RegExp(`\\.(${acceptedExtensions})$`, "i"), "", ); const withoutInvalidFilesystemChars = withoutExtension.replace(/[<>:"/\\|?*]/g, ""); @@ -226,7 +228,7 @@ export function registerProjectHandlers() { return { success: true, message: 'Could not reveal item, but opened directory.' }; } catch (openError) { console.error(`Error opening directory: ${path.dirname(filePath)}`, openError); - return { success: false, error: String(error) }; + return { success: false, error: 'Failed to reveal item in folder' }; } } }); @@ -242,7 +244,7 @@ export function registerProjectHandlers() { return { success: true }; } catch (error) { console.error('Failed to open recordings folder:', error); - return { success: false, error: String(error), message: 'Failed to open recordings folder.' }; + return { success: false, error: 'Failed to open recordings folder', message: 'Failed to open recordings folder.' }; } }); @@ -284,7 +286,8 @@ export function registerProjectHandlers() { return { success: true, path: selectedPath, isDefault: selectedPath === RECORDINGS_DIR } } catch (error) { - return { success: false, error: String(error), message: 'Failed to set recordings folder' } + console.error('Failed to set recordings folder:', error); + return { success: false, error: 'Failed to set recordings folder', message: 'Failed to set recordings folder' } } }) @@ -313,10 +316,10 @@ export function registerProjectHandlers() { const defaultName = `${safeName}.${PROJECT_FILE_EXTENSION}` const result = await dialog.showSaveDialog({ - title: 'Save Recordly Project', + title: 'Save VybeClip Project', defaultPath: path.join(projectsDir, defaultName), filters: [ - { name: 'Recordly Project', extensions: [PROJECT_FILE_EXTENSION] }, + { name: 'VybeClip Project', extensions: [PROJECT_FILE_EXTENSION] }, { name: 'JSON', extensions: ['json'] } ], properties: ['createDirectory', 'showOverwriteConfirmation'] @@ -432,10 +435,10 @@ export function registerProjectHandlers() { try { const projectsDir = await getProjectsDir() const result = await dialog.showOpenDialog({ - title: 'Open Recordly Project', + title: 'Open VybeClip Project', defaultPath: projectsDir, filters: [ - { name: 'Recordly Project', extensions: [PROJECT_FILE_EXTENSION, ...LEGACY_PROJECT_FILE_EXTENSIONS] }, + { name: 'VybeClip Project', extensions: [PROJECT_FILE_EXTENSION, ...LEGACY_PROJECT_FILE_EXTENSIONS] }, { name: 'JSON', extensions: ['json'] }, { name: 'All Files', extensions: ['*'] } ], @@ -508,6 +511,15 @@ export function registerProjectHandlers() { ipcMain.handle('open-project-file-at-path', async (_, filePath: string) => { try { + if (!(await isTrustedOpenableProjectPath(filePath))) { + console.warn(`[open-project-file-at-path] Blocked untrusted path: ${filePath}`) + return { + success: false, + message: 'Failed to open project file', + error: 'Project path is not in an allowed location', + } + } + return await loadProjectFromPath(filePath) } catch (error) { console.error('Failed to open project file at path:', error) diff --git a/electron/ipc/register/recording.ts b/electron/ipc/register/recording.ts index b13453c74..1d73a9da2 100644 --- a/electron/ipc/register/recording.ts +++ b/electron/ipc/register/recording.ts @@ -691,12 +691,12 @@ export function registerRecordingHandlers( !ALLOW_RECORDLY_WINDOW_CAPTURE && source?.id?.startsWith("window:") && appName && - (appName === ownAppName || appName === "recordly") + (appName === ownAppName || appName === "vybeclip" || appName === "recordly") ) { return { success: false, message: - "Cannot record Recordly windows. Please select another app window.", + "Cannot record VybeClip windows. Please select another app window.", }; } @@ -805,8 +805,8 @@ export function registerRecordingHandlers( type: "warning", title: "Screen Recording Permission Required", message: - "Recordly needs screen recording permission to capture your screen.", - detail: "Please open System Settings > Privacy & Security > Screen Recording, make sure Recordly is toggled ON, then try recording again.", + "VybeClip needs screen recording permission to capture your screen.", + detail: "Please open System Settings > Privacy & Security > Screen Recording, make sure VybeClip is toggled ON, then try recording again.", buttons: ["Open System Settings", "Cancel"], defaultId: 0, cancelId: 1, @@ -838,8 +838,8 @@ export function registerRecordingHandlers( const { response } = await dialog.showMessageBox({ type: "warning", title: "Microphone Permission Required", - message: "Recordly needs microphone permission to record audio.", - detail: "Please open System Settings > Privacy & Security > Microphone, make sure Recordly is toggled ON, then try recording again.", + message: "VybeClip needs microphone permission to record audio.", + detail: "Please open System Settings > Privacy & Security > Microphone, make sure VybeClip is toggled ON, then try recording again.", buttons: ["Open System Settings", "Cancel"], defaultId: 0, cancelId: 1, diff --git a/electron/ipc/register/settings.ts b/electron/ipc/register/settings.ts index e84f63171..498b890a3 100644 --- a/electron/ipc/register/settings.ts +++ b/electron/ipc/register/settings.ts @@ -135,7 +135,7 @@ export function registerSettingsHandlers() { return { success: true }; } catch (error) { console.error("Failed to save shortcuts:", error); - return { success: false, error: String(error) }; + return { success: false, error: "Failed to save shortcuts" }; } }); @@ -196,7 +196,7 @@ export function registerSettingsHandlers() { return { success: true }; } catch (error) { console.error("Failed to save recording preferences:", error); - return { success: false, error: String(error) }; + return { success: false, error: "Failed to save recording preferences" }; } }, ); @@ -221,7 +221,7 @@ export function registerSettingsHandlers() { return { success: true }; } catch (error) { console.error("Failed to save countdown delay:", error); - return { success: false, error: String(error) }; + return { success: false, error: "Failed to save countdown delay" }; } }); diff --git a/electron/ipc/register/sources.ts b/electron/ipc/register/sources.ts index 33c9ee74c..cf20b7a5e 100644 --- a/electron/ipc/register/sources.ts +++ b/electron/ipc/register/sources.ts @@ -82,6 +82,7 @@ export function registerSourceHandlers({ const ownWindowNames = new Set( [ app.getName(), + "VybeClip", "Recordly", ...BrowserWindow.getAllWindows().flatMap((win) => { const title = win.getTitle().trim(); diff --git a/electron/ipc/state.ts b/electron/ipc/state.ts index a0a41744e..8a0eb09fe 100644 --- a/electron/ipc/state.ts +++ b/electron/ipc/state.ts @@ -21,6 +21,14 @@ export let currentRecordingSession: RecordingSessionData | null = null; // ── Security: approved read paths ───────────────────────────────────────────── export const approvedLocalReadPaths = new Set(); +// ── Security: approved Whisper executable paths ─────────────────────────────── +// Populated only when the user explicitly selects a binary via the +// open-whisper-executable-picker dialog (plus the bundled/default binaries, +// which are always allowed). generate-auto-captions must reject any +// whisperExecutablePath that isn't in this set, so a compromised renderer +// cannot smuggle an arbitrary path into execFileAsync. +export const approvedWhisperExecutablePaths = new Set(); + // ── Native macOS capture ────────────────────────────────────────────────────── export let nativeScreenRecordingActive = false; export let nativeCaptureProcess: ChildProcessWithoutNullStreams | null = null; diff --git a/electron/ipc/utils.ts b/electron/ipc/utils.ts index 3f2efb065..9ab45b79c 100644 --- a/electron/ipc/utils.ts +++ b/electron/ipc/utils.ts @@ -7,6 +7,7 @@ import { RECORDINGS_DIR } from "../appPaths"; import { AUTO_RECORDING_PREFIX, RECORDINGS_SETTINGS_FILE } from "./constants"; import { approvedLocalReadPaths, + approvedWhisperExecutablePaths, customRecordingsDir, recordingsDirLoaded, setCustomRecordingsDir, @@ -130,3 +131,20 @@ export function approveUserPath(filePath: string | null | undefined): void { } } +export function approveWhisperExecutablePath(filePath: string | null | undefined): void { + if (!filePath) return; + try { + approvedWhisperExecutablePaths.add(path.resolve(filePath)); + } catch { + // Ignore invalid paths; later reads will surface the underlying error. + } +} + +export function isApprovedWhisperExecutablePath(filePath?: string | null): boolean { + if (!filePath) return false; + try { + return approvedWhisperExecutablePaths.has(path.resolve(filePath)); + } catch { + return false; + } +} diff --git a/electron/main.ts b/electron/main.ts index ed05ffeb6..34c9ecd29 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -25,8 +25,8 @@ import { killWindowsCaptureProcess, registerIpcHandlers, } from "./ipc/handlers"; -import { ensureMediaServer } from "./mediaServer"; -import { ensurePackagedRendererServer } from "./rendererServer"; +import { ensureMediaServer, getMediaServerBaseUrl } from "./mediaServer"; +import { ensurePackagedRendererServer, getPackagedRendererBaseUrl } from "./rendererServer"; import type { UpdateToastPayload } from "./updater"; import { checkForAppUpdates, @@ -45,6 +45,7 @@ import { createEditorWindow, createHudOverlayWindow, createSourceSelectorWindow, + createStudioWindow, getHudOverlayWindow, getUpdateToastWindow, hideUpdateToastWindow, @@ -132,8 +133,72 @@ process.env.VITE_PUBLIC = VITE_DEV_SERVER_URL ? path.join(process.env.APP_ROOT, "public") : RENDERER_DIST; +/** + * Registers a Content-Security-Policy header for every response served to + * our renderers. This is the mitigation that lets `webSecurity` stay enabled + * (see electron/windows.ts) while still allowing the app to talk to its own + * local-only HTTP servers: + * - ensurePackagedRendererServer() serves the packaged index.html/js/css + * from http://127.0.0.1: (electron/rendererServer.ts). + * - ensureMediaServer() streams allow-listed local video/audio files from + * http://127.0.0.1:/video (electron/mediaServer.ts). + * - In dev, the renderer is loaded from the Vite dev server + * (VITE_DEV_SERVER_URL, typically http://localhost:5173), which is + * already covered by 'self'. + * `file:` is additionally allowed for img-src/media-src only, since + * user-installed extensions (already fully-trusted local code — see + * EXTENSIONS.md) reference their own icon/asset files via file:// URLs + * (see src/components/video-editor/ExtensionIcon.tsx), and + * getRenderableVideoUrl() falls back to file:// if the media server is + * unavailable (src/lib/assetPath.ts). + */ +function configureContentSecurityPolicy() { + const selfOrigins = new Set(["'self'"]); + + const rendererBaseUrl = getPackagedRendererBaseUrl(); + if (rendererBaseUrl) { + selfOrigins.add(rendererBaseUrl); + } + + const mediaBaseUrl = getMediaServerBaseUrl(); + if (mediaBaseUrl) { + selfOrigins.add(mediaBaseUrl); + } + + if (VITE_DEV_SERVER_URL) { + selfOrigins.add(VITE_DEV_SERVER_URL); + } + + const originList = Array.from(selfOrigins).join(" "); + + const csp = [ + `default-src 'self' ${originList}`, + // The renderer bundle currently relies on inline/style-injected code + // paths (React dev overlays in dev mode, dynamically constructed + // styles for the editor canvas), so 'unsafe-inline' is kept for + // script/style until those call sites are audited and tightened. + `script-src 'self' 'unsafe-inline' 'unsafe-eval' ${originList}`, + `style-src 'self' 'unsafe-inline' ${originList}`, + `img-src 'self' data: blob: file: ${originList}`, + `media-src 'self' data: blob: file: ${originList}`, + `font-src 'self' data: ${originList}`, + `connect-src 'self' ${originList} ws: wss:`, + "object-src 'none'", + ].join("; "); + + session.defaultSession.webRequest.onHeadersReceived((details, callback) => { + callback({ + responseHeaders: { + ...details.responseHeaders, + "Content-Security-Policy": [csp], + }, + }); + }); +} + // Window references let mainWindow: BrowserWindow | null = null; +let studioWindow: BrowserWindow | null = null; let sourceSelectorWindow: BrowserWindow | null = null; let tray: Tray | null = null; let trayContextMenu: Menu | null = null; @@ -142,6 +207,7 @@ let editorHasUnsavedChanges = false; let isForceClosing = false; let isCreatingMainWindow = false; let isCreatingEditorWindow = false; +let isAppQuitting = false; let activeUpdateNotification: Notification | null = null; let activeUpdateNotificationKey: string | null = null; const shouldEnforceSingleInstanceLock = !IS_DEV; @@ -170,7 +236,7 @@ function restoreWindowSafely(window: BrowserWindow | null) { return; } - if (!isEditorWindow(window) && process.platform === "win32") { + if (window === getHudOverlayWindow() && process.platform === "win32") { showHudOverlayFromTray(); return; } @@ -200,8 +266,7 @@ let defaultTrayIcon: ReturnType | null = null; let recordingTrayIcon: ReturnType | null = null; function getPlatformAppIconFilename(size: 32 | 128 | 512) { - const baseName = process.platform === "darwin" ? "recordlymac" : "recordly"; - return `app-icons/${baseName}-${size}.png`; + return `app-icons/vybeclip-${size}.png`; } function getDefaultTrayIcon() { @@ -241,6 +306,16 @@ function showHudOverlayFromTray() { return true; } +function showOrCreateRecordingControls() { + const existingHud = getHudOverlayWindow(); + if (existingHud) { + showHudOverlayFromTray(); + return existingHud; + } + + return createHudOverlayWindow(); +} + ipcMain.on("set-has-unsaved-changes", (_event, hasChanges: boolean) => { editorHasUnsavedChanges = hasChanges; }); @@ -259,23 +334,21 @@ function createWindow() { return; } - if (mainWindow && !mainWindow.isDestroyed()) { - restoreWindowSafely(mainWindow); - return; - } - - const existingHudWindow = getHudOverlayWindow(); - if (existingHudWindow) { - mainWindow = existingHudWindow; - restoreWindowSafely(existingHudWindow); + if (studioWindow && !studioWindow.isDestroyed()) { + mainWindow = studioWindow; + restoreWindowSafely(studioWindow); return; } isCreatingMainWindow = true; - const createdHudWindow = createHudOverlayWindow(); - mainWindow = createdHudWindow; - createdHudWindow.once("closed", () => { - if (mainWindow === createdHudWindow) { + const createdStudioWindow = createStudioWindow(); + studioWindow = createdStudioWindow; + mainWindow = createdStudioWindow; + createdStudioWindow.once("closed", () => { + if (studioWindow === createdStudioWindow) { + studioWindow = null; + } + if (mainWindow === createdStudioWindow) { mainWindow = null; } }); @@ -291,47 +364,11 @@ function focusOrCreateMainWindow() { } if (!mainWindow || mainWindow.isDestroyed()) { - const existingHud = getHudOverlayWindow(); - if (existingHud && !existingHud.isDestroyed()) { - mainWindow = existingHud; - } else { - createWindow(); - return; - } + createWindow(); + return; } if (mainWindow && !mainWindow.isDestroyed()) { - // On Linux/Wayland, focus() often doesn't take effect (compositor ignores it). Apps like Telegram - // work because they receive an XDG activation token via StatusNotifierItem.ProvideXdgActivationToken; - // Electron's tray doesn't handle that yet. Workaround: destroy and recreate the HUD so the new - // window gets focus (creation path works). Only for HUD, not editor. - if ( - process.platform === "linux" && - !mainWindow.isFocused() && - !isEditorWindow(mainWindow) - ) { - const win = mainWindow; - mainWindow = null; - win.once("closed", () => createWindow()); - win.destroy(); - return; - } - - // On Win32 with mouse passthrough enabled (Win11+), calling - // show/moveTop/focus on the transparent HUD overlay permanently corrupts - // setIgnoreMouseEvents forwarding, making it click-through. Only focus - // the editor window; the HUD is alwaysOnTop so it doesn't need explicit - // focus. On Win10 (passthrough disabled), the HUD is always interactive - // and can be safely shown/restored. - if ( - process.platform === "win32" && - !isEditorWindow(mainWindow) && - isHudOverlayMousePassthroughSupported() - ) { - showHudOverlayFromTray(); - return; - } - mainWindow.show(); if (mainWindow.isMinimized()) mainWindow.restore(); mainWindow.moveTop(); @@ -519,22 +556,22 @@ function syncDockIcon() { function getUpdateNotificationTitle(payload: UpdateToastPayload) { switch (payload.phase) { case "available": - return `Recordly ${payload.version} is available`; + return `VybeClip ${payload.version} is available`; case "downloading": - return `Downloading Recordly ${payload.version}`; + return `Downloading VybeClip ${payload.version}`; case "ready": - return `Recordly ${payload.version} is ready`; + return `VybeClip ${payload.version} is ready`; case "error": - return `Recordly ${payload.version} needs attention`; + return `VybeClip ${payload.version} needs attention`; } } function getUpdateNotificationBody(payload: UpdateToastPayload) { switch (payload.phase) { case "available": - return "Click to install the update and restart Recordly."; + return "Click to install the update and restart VybeClip."; case "downloading": - return "Recordly is downloading the update and will restart when it is ready."; + return "VybeClip is downloading the update and will restart when it is ready."; case "ready": return "Click to install the downloaded update and restart."; case "error": @@ -709,22 +746,21 @@ ipcMain.handle("check-for-app-updates", async () => { function updateTrayMenu(recording: boolean = false) { if (!tray) return; const trayIcon = recording ? getRecordingTrayIcon() : getDefaultTrayIcon(); - const trayToolTip = recording ? `Recording: ${selectedSourceName}` : "Recordly"; + const trayToolTip = recording ? `Recording: ${selectedSourceName}` : "VybeClip"; const menuTemplate = recording ? [ { label: "Show Controls", click: () => { - if (!showHudOverlayFromTray()) { - focusOrCreateMainWindow(); - } + showOrCreateRecordingControls(); }, }, { label: "Stop Recording", click: () => { - if (mainWindow && !mainWindow.isDestroyed()) { - mainWindow.webContents.send("stop-recording-from-tray"); + const hud = getHudOverlayWindow(); + if (hud && !hud.isDestroyed()) { + hud.webContents.send("stop-recording-from-tray"); } }, }, @@ -733,9 +769,7 @@ function updateTrayMenu(recording: boolean = false) { { label: "Open", click: () => { - if (!showHudOverlayFromTray()) { - focusOrCreateMainWindow(); - } + focusOrCreateMainWindow(); }, }, { @@ -754,6 +788,48 @@ function updateTrayMenu(recording: boolean = false) { } } +/** + * Shows the "you have unsaved changes" confirmation dialog for the given editor + * window and, if the user chooses to save, waits for the renderer to finish + * saving via the existing request-save-before-close/save-before-close-done + * IPC round trip. + * + * Returns: + * - "close": it is safe to close/quit (either there was nothing to save, the + * user chose to discard, or the save completed successfully). + * - "cancel": the user canceled; closing/quitting should be aborted. + */ +async function promptToSaveUnsavedChangesBeforeClosing( + editorWindow: BrowserWindow, +): Promise<"close" | "cancel"> { + const choice = dialog.showMessageBoxSync(editorWindow, { + type: "warning", + buttons: ["Save & Close", "Discard & Close", "Cancel"], + defaultId: 0, + cancelId: 2, + title: "Unsaved Changes", + message: "You have unsaved changes.", + detail: "Do you want to save your project before closing?", + }); + + if (choice === 1) { + return "close"; + } + + if (choice !== 0) { + return "cancel"; + } + + editorWindow.webContents.send("request-save-before-close"); + const saved = await new Promise((resolve) => { + ipcMain.once("save-before-close-done", (_event, savedResult: boolean) => { + resolve(savedResult); + }); + }); + + return saved ? "close" : "cancel"; +} + function createEditorWindowWrapper() { const existingEditorWindow = getExistingEditorWindow(); if (existingEditorWindow) { @@ -798,6 +874,10 @@ function createEditorWindowWrapper() { const editorWindow = createEditorWindow(); mainWindow = editorWindow; editorHasUnsavedChanges = false; + const hudWindow = getHudOverlayWindow(); + if (hudWindow && !hudWindow.isDestroyed()) { + hudWindow.hide(); + } editorWindow.on("closed", () => { if (mainWindow === editorWindow) { @@ -806,6 +886,9 @@ function createEditorWindowWrapper() { isCreatingEditorWindow = false; isForceClosing = false; editorHasUnsavedChanges = false; + if (!isAppQuitting && !IS_SMOKE_EXPORT) { + createWindow(); + } }); editorWindow.on("close", (event) => { @@ -815,26 +898,11 @@ function createEditorWindowWrapper() { event.preventDefault(); - const choice = dialog.showMessageBoxSync(editorWindow, { - type: "warning", - buttons: ["Save & Close", "Discard & Close", "Cancel"], - defaultId: 0, - cancelId: 2, - title: "Unsaved Changes", - message: "You have unsaved changes.", - detail: "Do you want to save your project before closing?", + void promptToSaveUnsavedChangesBeforeClosing(editorWindow).then((outcome) => { + if (outcome === "close") { + closeEditorWindowBypassingUnsavedPrompt(editorWindow); + } }); - - if (choice === 0) { - editorWindow.webContents.send("request-save-before-close"); - ipcMain.once("save-before-close-done", (_event, saved: boolean) => { - if (saved) { - closeEditorWindowBypassingUnsavedPrompt(editorWindow); - } - }); - } else if (choice === 1) { - closeEditorWindowBypassingUnsavedPrompt(editorWindow); - } }); return editorWindow; @@ -848,13 +916,68 @@ function createSourceSelectorWindowWrapper() { return sourceSelectorWindow; } +// Guards re-entrancy for the before-quit handler below: once we've confirmed +// it's safe to quit (or timed out waiting), further before-quit events +// (triggered by our own app.quit() call) should fall through immediately +// instead of re-prompting or re-waiting. +let isQuitConfirmed = false; +// True while we're already waiting on an in-flight autosave / showing the +// unsaved-changes dialog, so a second before-quit event (e.g. the user +// mashing Cmd+Q) doesn't spawn a second prompt. +let isQuitCheckInProgress = false; + +const QUIT_AUTOSAVE_WAIT_TIMEOUT_MS = 5000; + // On macOS, applications and their menu bar stay active until the user quits // explicitly with Cmd + Q. -app.on("before-quit", () => { - killWindowsCaptureProcess(); - showCursor(); - cleanupNativeVideoExportSessions(); - void cleanupAllExportStreams(); +app.on("before-quit", (event) => { + if (isQuitConfirmed) { + isAppQuitting = true; + killWindowsCaptureProcess(); + showCursor(); + cleanupNativeVideoExportSessions(); + void cleanupAllExportStreams(); + return; + } + + const editorWindow = getExistingEditorWindow(); + + if (!editorWindow || !editorHasUnsavedChanges) { + isQuitConfirmed = true; + return; + } + + // There's an unsaved (or still in-flight autosaving) editor project. + // Block quitting until we've either waited for the save to finish or + // asked the user what to do, reusing the same dialog/IPC flow as + // window-close. Never hang indefinitely: fall back to quitting anyway + // after a short timeout so a stuck save can't trap the app open. + event.preventDefault(); + + if (isQuitCheckInProgress) { + return; + } + isQuitCheckInProgress = true; + + const timeoutPromise = new Promise<"timeout">((resolve) => { + setTimeout(() => resolve("timeout"), QUIT_AUTOSAVE_WAIT_TIMEOUT_MS); + }); + + Promise.race([promptToSaveUnsavedChangesBeforeClosing(editorWindow), timeoutPromise]) + .then((outcome) => { + isQuitCheckInProgress = false; + if (outcome === "cancel") { + return; + } + // "close" or "timeout": proceed with quitting. + isQuitConfirmed = true; + app.quit(); + }) + .catch(() => { + isQuitCheckInProgress = false; + isQuitConfirmed = true; + app.quit(); + }); }); app.on("window-all-closed", () => { @@ -876,7 +999,7 @@ app.on("second-instance", () => { // Register all IPC handlers when app is ready app.whenReady().then(async () => { if (process.platform === "win32") { - app.setAppUserModelId("dev.recordly.app"); + app.setAppUserModelId("studio.boxcreative.vybeclip"); } session.defaultSession.setPermissionCheckHandler((_webContents, permission) => { @@ -891,17 +1014,7 @@ app.whenReady().then(async () => { session.defaultSession.setDevicePermissionHandler((_details) => true); - if (process.platform === "darwin") { - const cameraStatus = systemPreferences.getMediaAccessStatus("camera"); - if (cameraStatus !== "granted") { - await systemPreferences.askForMediaAccess("camera"); - } - - const micStatus = systemPreferences.getMediaAccessStatus("microphone"); - if (micStatus !== "granted") { - await systemPreferences.askForMediaAccess("microphone"); - } - } else if (process.platform === "win32") { + if (process.platform === "win32") { const cameraStatus = systemPreferences.getMediaAccessStatus("camera"); const micStatus = systemPreferences.getMediaAccessStatus("microphone"); if (cameraStatus !== "granted") { @@ -954,6 +1067,10 @@ app.whenReady().then(async () => { console.warn("[media-server] Failed to start media server:", error); } + // Must run after the local servers above are started so the CSP can + // allow-list their actual (randomly-assigned) ports. + configureContentSecurityPolicy(); + registerIpcHandlers( createEditorWindowWrapper, createSourceSelectorWindowWrapper, @@ -975,6 +1092,11 @@ app.whenReady().then(async () => { registerExtensionIpcHandlers(); + ipcMain.handle("show-recording-controls", () => { + showOrCreateRecordingControls(); + return { success: true }; + }); + if (IS_SMOKE_EXPORT || process.env.RECORDLY_DEV_OPEN_RECORDING_INPUT) { await logSmokeExportGpuDiagnostics(); if (IS_SMOKE_EXPORT) { diff --git a/electron/preload.ts b/electron/preload.ts index 6f941e802..0912992c0 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -482,6 +482,9 @@ contextBridge.exposeInMainWorld("electronAPI", { openSourceSelector: () => { return ipcRenderer.invoke("open-source-selector"); }, + showRecordingControls: () => { + return ipcRenderer.invoke("show-recording-controls"); + }, selectSource: (source: ProcessedDesktopSource) => { return ipcRenderer.invoke("select-source", source); }, diff --git a/electron/updater.ts b/electron/updater.ts index 86a02aecf..31d96b91a 100644 --- a/electron/updater.ts +++ b/electron/updater.ts @@ -212,10 +212,10 @@ function createDownloadingUpdateToastPayload( phase: "downloading", detail: normalizedProgress >= 100 - ? "Finishing the update download. Recordly will restart as soon as the installer is ready." + ? "Finishing the update download. VybeClip will restart as soon as the installer is ready." : remainingMb !== null - ? `${remainingMb.toFixed(1)} MB left before Recordly restarts.` - : "Downloading the update now. Recordly will restart when it finishes.", + ? `${remainingMb.toFixed(1)} MB left before VybeClip restarts.` + : "Downloading the update now. VybeClip will restart when it finishes.", delayMs: UPDATE_REMINDER_DELAY_MS, progressPercent: normalizedProgress, transferredBytes, @@ -412,7 +412,7 @@ export async function downloadAvailableUpdate( setUpdateStatusSummary({ status: "downloading", availableVersion, - detail: `Downloading Recordly ${availableVersion}`, + detail: `Downloading VybeClip ${availableVersion}`, }); emitUpdateToastState( sendToRenderer, @@ -524,7 +524,7 @@ async function showAvailableUpdateDialog( const result = await showMessageBox(getMainWindow, { type: "info", title: "Update Available", - message: `Recordly ${version} is available.`, + message: `VybeClip ${version} is available.`, detail: "Install and restart now, or remind me later.", buttons: ["Install & Restart", "Later"], defaultId: 0, @@ -550,8 +550,8 @@ async function showDownloadedUpdateDialog( type: "info", title: "Update Ready", message: isPreview - ? `Recordly ${version} is ready to install.` - : `Recordly ${version} has been downloaded.`, + ? `VybeClip ${version} is ready to install.` + : `VybeClip ${version} has been downloaded.`, detail: isPreview ? "Development preview of the native update prompt. No real update will be installed." : "Install and restart now, or remind me later.", @@ -674,7 +674,7 @@ export function setupAutoUpdates( setUpdateStatusSummary({ status: "available", availableVersion: info.version, - detail: `Recordly ${info.version} is available.`, + detail: `VybeClip ${info.version} is available.`, }); if (skippedVersion === info.version) { manualCheckRequested = false; @@ -704,7 +704,7 @@ export function setupAutoUpdates( setUpdateStatusSummary({ status: "up-to-date", availableVersion: null, - detail: `Recordly ${app.getVersion()} is up to date.`, + detail: `VybeClip ${app.getVersion()} is up to date.`, }); clearVisibleUpdateToast(sendToRenderer); manualCheckRequested = false; @@ -719,7 +719,7 @@ export function setupAutoUpdates( setUpdateStatusSummary({ status: "downloading", availableVersion, - detail: `Downloading Recordly ${availableVersion}`, + detail: `Downloading VybeClip ${availableVersion}`, }); writeUpdaterLog( `Download progress for ${availableVersion}: ${progress.percent.toFixed(1)}%`, @@ -775,7 +775,7 @@ export function setupAutoUpdates( setUpdateStatusSummary({ status: "ready", availableVersion: info.version, - detail: `Recordly ${info.version} is ready to install.`, + detail: `VybeClip ${info.version} is ready to install.`, }); clearDeferredReminderTimer(); diff --git a/electron/windows.ts b/electron/windows.ts index 478584b55..38f4ab1c0 100644 --- a/electron/windows.ts +++ b/electron/windows.ts @@ -14,8 +14,7 @@ const nodeRequire = createRequire(import.meta.url); const APP_ROOT = path.join(electronWindowsDir, ".."); const VITE_DEV_SERVER_URL = process.env["VITE_DEV_SERVER_URL"]; const RENDERER_DIST = path.join(APP_ROOT, "dist"); -const WINDOW_ICON_FILENAME = - process.platform === "darwin" ? "recordlymac-512.png" : "recordly-512.png"; +const WINDOW_ICON_FILENAME = "vybeclip-512.png"; const WINDOW_ICON_PATH = path.join( process.env.VITE_PUBLIC || RENDERER_DIST, "app-icons", @@ -453,7 +452,10 @@ export function createHudOverlayWindow(): BrowserWindow { preload: path.join(electronWindowsDir, "preload.mjs"), nodeIntegration: false, contextIsolation: true, - webSecurity: false, + // webSecurity is left enabled (default). Local media/extension assets + // are served through mediaServer.ts (http://127.0.0.1) or loaded as + // data: URLs — see the CSP registered in main.ts for the allowed + // origins/schemes. backgroundThrottling: false, }, }); @@ -823,6 +825,80 @@ function loadPackagedEditorWindow(win: BrowserWindow) { }); } +function loadStudioWindow(win: BrowserWindow) { + const query = { windowType: "studio" }; + if (VITE_DEV_SERVER_URL) { + void win.loadURL(`${VITE_DEV_SERVER_URL}?windowType=studio`); + return; + } + + const packagedRendererBaseUrl = getPackagedRendererBaseUrl(); + if (packagedRendererBaseUrl) { + void win.loadURL(`${packagedRendererBaseUrl}/?windowType=studio`).catch((error) => { + console.warn("[studio-window] packaged renderer URL failed, falling back to file", { + error: error instanceof Error ? error.message : String(error), + }); + if (!win.isDestroyed()) { + void win.loadFile(path.join(RENDERER_DIST, "index.html"), { query }); + } + }); + return; + } + + void win.loadFile(path.join(RENDERER_DIST, "index.html"), { query }); +} + +export function createStudioWindow(): BrowserWindow { + const isMac = process.platform === "darwin"; + const { workAreaSize } = getScreen().getPrimaryDisplay(); + const width = Math.min(1360, Math.max(960, Math.round(workAreaSize.width * 0.82))); + const height = Math.min(900, Math.max(680, Math.round(workAreaSize.height * 0.82))); + + const win = new BrowserWindow({ + width, + height, + minWidth: 900, + minHeight: 640, + ...(process.platform !== "darwin" && { + icon: WINDOW_ICON_PATH, + }), + ...(isMac && { + titleBarStyle: "hiddenInset", + trafficLightPosition: { x: 14, y: 18 }, + }), + autoHideMenuBar: !isMac, + resizable: true, + title: "VybeClip Studio", + show: false, + backgroundColor: "#05080A", + webPreferences: { + preload: path.join(electronWindowsDir, "preload.mjs"), + nodeIntegration: false, + contextIsolation: true, + // webSecurity is left enabled (default). Local media/extension assets + // are served through mediaServer.ts (http://127.0.0.1) or loaded as + // data: URLs — see the CSP registered in main.ts for the allowed + // origins/schemes. + backgroundThrottling: false, + }, + }); + + win.once("ready-to-show", () => { + if (!win.isDestroyed()) { + win.show(); + } + }); + + win.webContents.on("did-finish-load", () => { + if (!win.isDestroyed() && !win.isVisible()) { + win.show(); + } + }); + + loadStudioWindow(win); + return win; +} + export function createEditorWindow(): BrowserWindow { const perfStart = Date.now(); console.log("[PERF:MAIN] createEditorWindow: STARTED"); @@ -852,14 +928,17 @@ export function createEditorWindow(): BrowserWindow { resizable: true, alwaysOnTop: false, skipTaskbar: false, - title: "Recordly", + title: "VybeClip", show: false, backgroundColor: "#000000", webPreferences: { preload: path.join(electronWindowsDir, "preload.mjs"), nodeIntegration: false, contextIsolation: true, - webSecurity: false, + // webSecurity is left enabled (default). Local media/extension assets + // are served through mediaServer.ts (http://127.0.0.1) or loaded as + // data: URLs — see the CSP registered in main.ts for the allowed + // origins/schemes. backgroundThrottling: false, }, }); diff --git a/icons/icons/mac/icon.icns b/icons/icons/mac/icon.icns index b9f34ea75..ba7cb12d9 100644 Binary files a/icons/icons/mac/icon.icns and b/icons/icons/mac/icon.icns differ diff --git a/icons/icons/png/1024x1024.png b/icons/icons/png/1024x1024.png index 7cc41c028..749357135 100644 Binary files a/icons/icons/png/1024x1024.png and b/icons/icons/png/1024x1024.png differ diff --git a/icons/icons/png/128x128.png b/icons/icons/png/128x128.png index 82b456e9d..0090e1e96 100644 Binary files a/icons/icons/png/128x128.png and b/icons/icons/png/128x128.png differ diff --git a/icons/icons/png/16x16.png b/icons/icons/png/16x16.png index e1f45c1f4..bb7dccec3 100644 Binary files a/icons/icons/png/16x16.png and b/icons/icons/png/16x16.png differ diff --git a/icons/icons/png/24x24.png b/icons/icons/png/24x24.png index e146b337a..074778c60 100644 Binary files a/icons/icons/png/24x24.png and b/icons/icons/png/24x24.png differ diff --git a/icons/icons/png/256x256.png b/icons/icons/png/256x256.png index 0592161ab..67afcbf2a 100644 Binary files a/icons/icons/png/256x256.png and b/icons/icons/png/256x256.png differ diff --git a/icons/icons/png/32x32.png b/icons/icons/png/32x32.png index 6fb056f8a..f36b8f306 100644 Binary files a/icons/icons/png/32x32.png and b/icons/icons/png/32x32.png differ diff --git a/icons/icons/png/48x48.png b/icons/icons/png/48x48.png index 48af277bf..6113e4701 100644 Binary files a/icons/icons/png/48x48.png and b/icons/icons/png/48x48.png differ diff --git a/icons/icons/png/512x512.png b/icons/icons/png/512x512.png index 4016ff592..f3448e490 100644 Binary files a/icons/icons/png/512x512.png and b/icons/icons/png/512x512.png differ diff --git a/icons/icons/png/64x64.png b/icons/icons/png/64x64.png index e132d69a4..5c6218847 100644 Binary files a/icons/icons/png/64x64.png and b/icons/icons/png/64x64.png differ diff --git a/icons/icons/win/icon.ico b/icons/icons/win/icon.ico index 52e47c894..4b38f4e35 100644 Binary files a/icons/icons/win/icon.ico and b/icons/icons/win/icon.ico differ diff --git a/icons/vybeclip-icon-1024.png b/icons/vybeclip-icon-1024.png new file mode 100644 index 000000000..cec01a8f5 Binary files /dev/null and b/icons/vybeclip-icon-1024.png differ diff --git a/icons/vybeclip-icon.svg b/icons/vybeclip-icon.svg new file mode 100644 index 000000000..9879c89bd --- /dev/null +++ b/icons/vybeclip-icon.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/package-lock.json b/package-lock.json index a255b232f..1d6ae851b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,11 +1,11 @@ { - "name": "recordly", + "name": "vybeclip", "version": "1.3.4", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "recordly", + "name": "vybeclip", "version": "1.3.4", "hasInstallScript": true, "dependencies": { @@ -122,6 +122,7 @@ "integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.3", @@ -351,6 +352,7 @@ "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=6.9.0" } @@ -1358,7 +1360,6 @@ "dev": true, "license": "BSD-2-Clause", "optional": true, - "peer": true, "dependencies": { "cross-dirname": "^0.1.0", "debug": "^4.3.4", @@ -1380,7 +1381,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", @@ -1397,7 +1397,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "universalify": "^2.0.0" }, @@ -1412,7 +1411,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "engines": { "node": ">= 10.0.0" } @@ -2102,6 +2100,7 @@ "integrity": "sha512-LTATglVUPGkPf15zX1wTMlZ0+AU7cGEGF6ekVF1crA8eHUWsGjrYTB+Ht4E3HTrCok8weQG+K01rJndCp/l4XA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.7.2", "@jimp/core": "^0.16.13" @@ -2144,6 +2143,7 @@ "integrity": "sha512-8Z1k96ZFxlhK2bgrY1JNWNwvaBeI/bciLM0yDOni2+aZwfIIiC7Y6PeWHTAvjHNjphz+XCt01WQmOYWCn0ML6g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.7.2", "@jimp/utils": "^0.16.13" @@ -2158,6 +2158,7 @@ "integrity": "sha512-PvLrfa8vkej3qinlebyhLpksJgCF5aiysDMSVhOZqwH5nQLLtDE9WYbnsofGw4r0VVpyw3H/ANCIzYTyCtP9Cg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.7.2", "@jimp/utils": "^0.16.13" @@ -2186,6 +2187,7 @@ "integrity": "sha512-xW+9BtEvoIkkH/Wde9ql4nAFbYLkVINhpgAE7VcBUsuuB34WUbcBl/taOuUYQrPEFQJ4jfXiAJZ2H/rvKjCVnQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.7.2", "@jimp/utils": "^0.16.13", @@ -2235,6 +2237,7 @@ "integrity": "sha512-WEl2tPVYwzYL8OKme6Go2xqiWgKsgxlMwyHabdAU4tXaRwOCnOI7v4021gCcBb9zn/oWwguHuKHmK30Fw2Z/PA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.7.2", "@jimp/utils": "^0.16.13" @@ -2378,6 +2381,7 @@ "integrity": "sha512-qoqtN8LDknm3fJm9nuPygJv30O3vGhSBD2TxrsCnhtOsxKAqVPJtFVdGd/qVuZ8nqQANQmTlfqTiK9mVWQ7MiQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.7.2", "@jimp/utils": "^0.16.13" @@ -2392,6 +2396,7 @@ "integrity": "sha512-Ev+Jjmj1nHYw897z9C3R9dYsPv7S2/nxdgfFb/h8hOwK0Ovd1k/+yYS46A0uj/JCKK0pQk8wOslYBkPwdnLorw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.7.2", "@jimp/utils": "^0.16.13" @@ -2409,6 +2414,7 @@ "integrity": "sha512-05POQaEJVucjTiSGMoH68ZiELc7QqpIpuQlZ2JBbhCV+WCbPFUBcGSmE7w4Jd0E2GvCho/NoMODLwgcVGQA97A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.7.2", "@jimp/utils": "^0.16.13" @@ -2827,7 +2833,6 @@ "integrity": "sha512-a6R+bXKeXMDcRmjYQoBIK+v2EYqxSX49wcjAY579EYM/WrFKS98nSees6lqVUcLKrcQh2DT9srJHX7XMny3voQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@pixi/colord": "^2.9.6" } @@ -2844,8 +2849,7 @@ "resolved": "https://registry.npmjs.org/@pixi/constants/-/constants-7.4.3.tgz", "integrity": "sha512-QGmwJUNQy/vVEHzL6VGQvnwawLZ1wceZMI8HwJAT4/I2uAzbBeFDdmCS8WsTpSWLZjF/DszDc1D8BFp4pVJ5UQ==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@pixi/core": { "version": "7.4.3", @@ -2874,8 +2878,7 @@ "resolved": "https://registry.npmjs.org/@pixi/extensions/-/extensions-7.4.3.tgz", "integrity": "sha512-FhoiYkHQEDYHUE7wXhqfsTRz6KxLXjuMbSiAwnLb9uG1vAgp6q6qd6HEsf4X30YaZbLFY8a4KY6hFZWjF+4Fdw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@pixi/filter-drop-shadow": { "version": "5.2.0", @@ -2915,16 +2918,14 @@ "resolved": "https://registry.npmjs.org/@pixi/math/-/math-7.4.3.tgz", "integrity": "sha512-/uJOVhR2DOZ+zgdI6Bs/CwcXT4bNRKsS+TqX3ekRIxPCwaLra+Qdm7aDxT5cTToDzdxbKL5+rwiLu3Y1egILDw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@pixi/runner": { "version": "7.4.3", "resolved": "https://registry.npmjs.org/@pixi/runner/-/runner-7.4.3.tgz", "integrity": "sha512-TJyfp7y23u5vvRAyYhVSa7ytq0PdKSvPLXu4G3meoFh1oxTLHH6g/RIzLuxUAThPG2z7ftthuW3qWq6dRV+dhw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@pixi/settings": { "version": "7.4.3", @@ -2932,7 +2933,6 @@ "integrity": "sha512-SmGK8smc0PxRB9nr0UJioEtE9hl4gvj9OedCvZx3bxBwA3omA5BmP3CyhQfN8XJ29+o2OUL01r3zAPVol4l4lA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@pixi/constants": "7.4.3", "@types/css-font-loading-module": "^0.0.12", @@ -2945,7 +2945,6 @@ "integrity": "sha512-tHsAD0iOUb6QSGGw+c8cyRBvxsq/NlfzIFBZLEHhWZ+Bx4a0MmXup6I/yJDGmyPCYE+ctCcAfY13wKAzdiVFgQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@pixi/extensions": "7.4.3", "@pixi/settings": "7.4.3", @@ -2958,7 +2957,6 @@ "integrity": "sha512-NO3Y9HAn2UKS1YdxffqsPp+kDpVm8XWvkZcS/E+rBzY9VTLnNOI7cawSRm+dacdET3a8Jad3aDKEDZ0HmAqAFA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@pixi/color": "7.4.3", "@pixi/constants": "7.4.3", @@ -2974,24 +2972,21 @@ "resolved": "https://registry.npmjs.org/@types/earcut/-/earcut-2.1.4.tgz", "integrity": "sha512-qp3m9PPz4gULB9MhjGID7wpo3gJ4bTGXm7ltNDsmOvsPduTeHp8wSW9YckBj3mljeOh4F0m2z/0JKAALRKbmLQ==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@pixi/utils/node_modules/earcut": { "version": "2.2.4", "resolved": "https://registry.npmjs.org/earcut/-/earcut-2.2.4.tgz", "integrity": "sha512-/pjZsA1b4RPHbeWZQn66SWS8nZZWLQQ23oE3Eam7aroEFGEvwKAsJfZ9ytiEMycfzXWpca4FA9QIOehf7PocBQ==", "dev": true, - "license": "ISC", - "peer": true + "license": "ISC" }, "node_modules/@pixi/utils/node_modules/eventemitter3": { "version": "4.0.7", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", @@ -4515,6 +4510,7 @@ "integrity": "sha512-RFA/bURkcKzx/X9oumPG9Vp3D3JUgus/d0b67KB0t5S/raciymilkOa66olh78MUI92QLbEJevO7rvqU/kjwKA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.0.2" @@ -4526,6 +4522,7 @@ "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", "dev": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^18.0.0" } @@ -4819,6 +4816,7 @@ "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -5610,6 +5608,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.9", "caniuse-lite": "^1.0.30001746", @@ -5828,7 +5827,6 @@ "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" @@ -6262,8 +6260,7 @@ "integrity": "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==", "dev": true, "license": "MIT", - "optional": true, - "peer": true + "optional": true }, "node_modules/cross-spawn": { "version": "7.0.6", @@ -6555,6 +6552,7 @@ "integrity": "sha512-uOOBA3f+kW3o4KpSoMQ6SNpdXU7WtxlJRb9vCZgOvqhTz4b3GjcoWKstdisizNZLsylhTMv8TLHFPFW0Uxsj/g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "app-builder-lib": "26.7.0", "builder-util": "26.4.1", @@ -6968,7 +6966,6 @@ "dev": true, "hasInstallScript": true, "license": "MIT", - "peer": true, "dependencies": { "@electron/asar": "^3.2.1", "debug": "^4.1.1", @@ -6989,7 +6986,6 @@ "integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "graceful-fs": "^4.1.2", "jsonfile": "^4.0.0", @@ -8653,6 +8649,7 @@ "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", "dev": true, "license": "MIT", + "peer": true, "bin": { "jiti": "bin/jiti.js" } @@ -9567,7 +9564,6 @@ "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">= 0.4" }, @@ -10115,6 +10111,7 @@ "integrity": "sha512-ituDiEBb1Oqx56RYwTtC6MjPUhPfF/i15fpUv5oEqmzC/ce3SaSumulJcOjKG7+y0J0Ekl9Rl4XTxaUw+MVFZw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@pixi/colord": "^2.9.6", "@types/css-font-loading-module": "^0.0.12", @@ -10185,6 +10182,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -10335,7 +10333,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "commander": "^9.4.0" }, @@ -10353,7 +10350,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "engines": { "node": "^12.20.0 || >=14" } @@ -10496,7 +10492,6 @@ "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", "dev": true, "license": "BSD-3-Clause", - "peer": true, "dependencies": { "side-channel": "^1.1.0" }, @@ -10557,6 +10552,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -10569,6 +10565,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -11365,7 +11362,6 @@ "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", @@ -11386,7 +11382,6 @@ "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3" @@ -11404,7 +11399,6 @@ "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", @@ -11424,7 +11418,6 @@ "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", @@ -12036,6 +12029,7 @@ "integrity": "sha512-6A2rnmW5xZMdw11LYjhcI5846rt9pbLSabY5XPxo+XWdxwZaFEn47Go4NzFiHu9sNNmr/kXivP1vStfvMaK1GQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", @@ -12084,7 +12078,6 @@ "integrity": "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "mkdirp": "^0.5.1", "rimraf": "~2.6.2" @@ -12148,7 +12141,6 @@ "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "minimist": "^1.2.6" }, @@ -12163,7 +12155,6 @@ "deprecated": "Rimraf versions prior to v4 are no longer supported", "dev": true, "license": "ISC", - "peer": true, "dependencies": { "glob": "^7.1.3" }, @@ -12177,6 +12168,7 @@ "integrity": "sha512-t/R3R/n0MSwnnazuPpPNVO60LX0SKL45pyl9YlvxIdkH0Of7D5qM2EVe+yASRIlY5pZ73nclYJfNANGWPwFDZw==", "dev": true, "license": "BSD-2-Clause", + "peer": true, "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.15.0", @@ -12338,6 +12330,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -12561,7 +12554,6 @@ "integrity": "sha512-oCwdVC7mTuWiPyjLUz/COz5TLk6wgp0RCsN+wHZ2Ekneac9w8uuV0njcbbie2ME+Vs+d6duwmYuR3HgQXs1fOg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "punycode": "^1.4.1", "qs": "^6.12.3" @@ -12575,8 +12567,7 @@ "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.4.1.tgz", "integrity": "sha512-jmYNElW7yvO7TV33CjSmvSiE2yco3bV2czu/OzDKdMNVZQWfxCblURLhf+47syQRBntjfLdd/H0egrzIG+oaFQ==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/use-callback-ref": { "version": "1.3.3", @@ -12693,6 +12684,7 @@ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", @@ -12767,7 +12759,8 @@ "resolved": "https://registry.npmjs.org/vite-plugin-electron-renderer/-/vite-plugin-electron-renderer-0.14.6.tgz", "integrity": "sha512-oqkWFa7kQIkvHXG7+Mnl1RTroA4sP0yesKatmAy0gjZC4VwUqlvF9IvOpHd1fpLWsqYX/eZlVxlhULNtaQ78Jw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/vitest": { "version": "4.0.16", @@ -13331,6 +13324,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -13344,6 +13338,7 @@ "integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", diff --git a/package.json b/package.json index 6357c6667..b590c5768 100644 --- a/package.json +++ b/package.json @@ -1,17 +1,18 @@ { - "name": "recordly", - "productName": "Recordly", - "description": "A free, creator-focused screen recorder with auto-zoom, cursor effects, backgrounds, annotations, and more - built for polished videos out of the box.", - "author": "webadderall", - "homepage": "https://github.com/webadderallorg/Recordly", + "name": "vybeclip", + "productName": "VybeClip", + "description": "A creator-focused screen recorder and demo video studio for polished walkthroughs, product videos, and branded clips.", + "author": "Box Creative Studio", + "homepage": "https://github.com/Alfredoalv13/Recordly", "repository": { "type": "git", - "url": "https://github.com/webadderallorg/Recordly.git" + "url": "https://github.com/Alfredoalv13/Recordly.git" }, "bugs": { - "url": "https://github.com/webadderallorg/Recordly/issues" + "url": "https://github.com/Alfredoalv13/Recordly/issues" }, "private": true, + "license": "AGPL-3.0-or-later", "version": "1.3.4", "type": "module", "scripts": { @@ -31,6 +32,7 @@ "build:windows-capture": "node scripts/build-windows-capture.mjs", "build:cursor-monitor": "node scripts/build-cursor-monitor.mjs", "build:mac": "npm run build:platform-native-helpers && tsc && vite build --config vite.config.ts && npm run normalize:electron-main-cjs && npm run smoke:electron-main-cjs && electron-builder --mac", + "sign:mac": "node scripts/sign-macos-app.mjs", "build:win": "npm run build:platform-native-helpers && tsc && vite build --config vite.config.ts && npm run normalize:electron-main-cjs && npm run smoke:electron-main-cjs && electron-builder --win", "build:linux": "npm run build:platform-native-helpers && tsc && vite build --config vite.config.ts && npm run normalize:electron-main-cjs && npm run smoke:electron-main-cjs && electron-builder --linux", "i18n:check": "node scripts/i18n-check.mjs", diff --git a/public/app-icons/vybeclip-1024.png b/public/app-icons/vybeclip-1024.png new file mode 100644 index 000000000..749357135 Binary files /dev/null and b/public/app-icons/vybeclip-1024.png differ diff --git a/public/app-icons/vybeclip-128.png b/public/app-icons/vybeclip-128.png new file mode 100644 index 000000000..0090e1e96 Binary files /dev/null and b/public/app-icons/vybeclip-128.png differ diff --git a/public/app-icons/vybeclip-16.png b/public/app-icons/vybeclip-16.png new file mode 100644 index 000000000..bb7dccec3 Binary files /dev/null and b/public/app-icons/vybeclip-16.png differ diff --git a/public/app-icons/vybeclip-256.png b/public/app-icons/vybeclip-256.png new file mode 100644 index 000000000..67afcbf2a Binary files /dev/null and b/public/app-icons/vybeclip-256.png differ diff --git a/public/app-icons/vybeclip-32.png b/public/app-icons/vybeclip-32.png new file mode 100644 index 000000000..f36b8f306 Binary files /dev/null and b/public/app-icons/vybeclip-32.png differ diff --git a/public/app-icons/vybeclip-512.png b/public/app-icons/vybeclip-512.png new file mode 100644 index 000000000..f3448e490 Binary files /dev/null and b/public/app-icons/vybeclip-512.png differ diff --git a/public/app-icons/vybeclip-64.png b/public/app-icons/vybeclip-64.png new file mode 100644 index 000000000..5c6218847 Binary files /dev/null and b/public/app-icons/vybeclip-64.png differ diff --git a/scripts/build-cursor-monitor.mjs b/scripts/build-cursor-monitor.mjs index 36917ddf1..e811d5676 100644 --- a/scripts/build-cursor-monitor.mjs +++ b/scripts/build-cursor-monitor.mjs @@ -3,6 +3,7 @@ import { copyFileSync, existsSync, mkdirSync, rmSync } from "node:fs"; import path from "node:path"; import { + formatNativeHelperManifestError, formatNativeHelperManifestWarning, updateNativeHelperManifest, verifyNativeHelperManifest, @@ -94,6 +95,10 @@ if (!cmake) { binaryName: "cursor-monitor.exe", }); if (!verification.ok) { + if (verification.hasRecordedEntry) { + console.error(formatNativeHelperManifestError("build-cursor-monitor", verification)); + process.exit(1); + } console.warn(formatNativeHelperManifestWarning("build-cursor-monitor", verification)); } console.log(`[build-cursor-monitor] Using bundled helper: ${bundledExePath}`); diff --git a/scripts/build-nvidia-cuda-compositor.mjs b/scripts/build-nvidia-cuda-compositor.mjs index e2959022c..379353e10 100644 --- a/scripts/build-nvidia-cuda-compositor.mjs +++ b/scripts/build-nvidia-cuda-compositor.mjs @@ -3,6 +3,7 @@ import { copyFileSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSyn import path from "node:path"; import { + formatNativeHelperManifestError, formatNativeHelperManifestWarning, updateNativeHelperManifest, verifyNativeHelperManifest, @@ -45,6 +46,12 @@ function fallbackToBundledHelperOrExit(reason) { binaryName: "recordly-nvidia-cuda-compositor.exe", }); if (!verification.ok) { + if (verification.hasRecordedEntry) { + console.error( + formatNativeHelperManifestError("build-nvidia-cuda-compositor", verification), + ); + process.exit(1); + } console.warn( formatNativeHelperManifestWarning("build-nvidia-cuda-compositor", verification), ); diff --git a/scripts/build-whisper-runtime.mjs b/scripts/build-whisper-runtime.mjs index 193e5bc2f..063a63877 100644 --- a/scripts/build-whisper-runtime.mjs +++ b/scripts/build-whisper-runtime.mjs @@ -1,4 +1,5 @@ import { execFileSync, execSync } from "node:child_process"; +import { createHash } from "node:crypto"; import { createWriteStream, existsSync } from "node:fs"; import { chmod, cp, mkdir, readdir, readFile, rm, stat, writeFile } from "node:fs/promises"; import { get as httpsGet } from "node:https"; @@ -6,6 +7,14 @@ import path from "node:path"; const projectRoot = process.cwd(); const whisperVersion = "v1.8.4"; +// SHA-256 of the source tarball at +// https://github.com/ggml-org/whisper.cpp/archive/refs/tags/v1.8.4.tar.gz +// Verified by downloading the archive twice from GitHub on 2026-07-01 and +// confirming both downloads produced byte-identical files with this digest. +// If `whisperVersion` above is ever bumped, this hash MUST be recomputed and +// pinned for the new tag before cutting a release build, e.g.: +// curl -sL https://github.com/ggml-org/whisper.cpp/archive/refs/tags/.tar.gz | shasum -a 256 +const WHISPER_SOURCE_SHA256 = "b26f30e52c095ccb75da40b168437736605eb280de57381887bf9e2b65f31e66"; const nativeRoot = path.join(projectRoot, "electron", "native"); const cacheRoot = path.join(projectRoot, ".tmp", "whisper-runtime"); const archivePath = path.join(cacheRoot, `${whisperVersion}.tar.gz`); @@ -223,6 +232,44 @@ async function downloadFile(url, destinationPath) { }); } +async function computeFileSha256(filePath) { + const fileBuffer = await readFile(filePath); + return createHash("sha256").update(fileBuffer).digest("hex"); +} + +async function verifyArchiveChecksum(downloadedArchivePath) { + if ( + !WHISPER_SOURCE_SHA256 || + WHISPER_SOURCE_SHA256 === "REPLACE_WITH_PINNED_SHA256_BEFORE_RELEASE" + ) { + throw new Error( + "[build-whisper-runtime] WHISPER_SOURCE_SHA256 is still a placeholder. " + + "A maintainer must compute the real SHA-256 for the whisper.cpp " + + `${whisperVersion} source tarball (e.g. via ` + + `\`curl -sL ${getSourceArchiveUrl()} | shasum -a 256\`) and pin it in ` + + "scripts/build-whisper-runtime.mjs before this build can proceed.", + ); + } + + const actualSha256 = await computeFileSha256(downloadedArchivePath); + if (actualSha256 !== WHISPER_SOURCE_SHA256.toLowerCase()) { + // Remove the untrusted archive so a stale/tampered file can't be reused + // by a subsequent run's `existsSync(archivePath)` cache check. + await rm(downloadedArchivePath, { force: true }); + throw new Error( + "[build-whisper-runtime] SHA-256 checksum mismatch for downloaded whisper.cpp " + + `${whisperVersion} source tarball. Expected ${WHISPER_SOURCE_SHA256} but got ` + + `${actualSha256}. Refusing to extract/build a source archive that does not match ` + + "the pinned checksum -- this could indicate a compromised CDN, a MITM, or a " + + "moved/re-tagged release. Aborting build.", + ); + } + + console.log( + `[build-whisper-runtime] Verified whisper.cpp ${whisperVersion} source checksum (sha256:${actualSha256}).`, + ); +} + async function ensureSourceTree() { const extractedSourceDir = path.join( extractRoot, @@ -240,6 +287,8 @@ async function ensureSourceTree() { await downloadFile(getSourceArchiveUrl(), archivePath); } + await verifyArchiveChecksum(archivePath); + ensureTarAvailable(); execFileSync("tar", ["-xzf", archivePath, "-C", extractRoot], { stdio: "inherit" }); diff --git a/scripts/build-windows-capture.mjs b/scripts/build-windows-capture.mjs index 76f7e4778..9bcd3f525 100644 --- a/scripts/build-windows-capture.mjs +++ b/scripts/build-windows-capture.mjs @@ -3,6 +3,7 @@ import { copyFileSync, existsSync, mkdirSync, rmSync } from "node:fs"; import path from "node:path"; import { + formatNativeHelperManifestError, formatNativeHelperManifestWarning, updateNativeHelperManifest, verifyNativeHelperManifest, @@ -97,6 +98,10 @@ if (!cmake) { binaryName: "wgc-capture.exe", }); if (!verification.ok) { + if (verification.hasRecordedEntry) { + console.error(formatNativeHelperManifestError("build-windows-capture", verification)); + process.exit(1); + } console.warn(formatNativeHelperManifestWarning("build-windows-capture", verification)); } console.log(`[build-windows-capture] Using bundled helper: ${bundledExePath}`); diff --git a/scripts/native-helper-manifest.mjs b/scripts/native-helper-manifest.mjs index 0cf96e0f3..1823026a5 100644 --- a/scripts/native-helper-manifest.mjs +++ b/scripts/native-helper-manifest.mjs @@ -134,6 +134,7 @@ export function verifyNativeHelperManifest({ return { ok: false, manifestPath, + hasRecordedEntry: false, reasons: ["manifest missing"], }; } @@ -142,6 +143,7 @@ export function verifyNativeHelperManifest({ return { ok: false, manifestPath, + hasRecordedEntry: false, reasons: ["bundled helper missing"], }; } @@ -152,6 +154,7 @@ export function verifyNativeHelperManifest({ return { ok: false, manifestPath, + hasRecordedEntry: false, reasons: [`${helperId} entry missing`], }; } @@ -178,6 +181,7 @@ export function verifyNativeHelperManifest({ return { ok: reasons.length === 0, manifestPath, + hasRecordedEntry: true, reasons, }; } @@ -186,3 +190,8 @@ export function formatNativeHelperManifestWarning(helperLabel, verificationResul const reasonText = verificationResult.reasons.join(", "); return `[${helperLabel}] Bundled helper provenance check failed (${reasonText}). Rebuild the helper to refresh ${path.basename(verificationResult.manifestPath)}.`; } + +export function formatNativeHelperManifestError(helperLabel, verificationResult) { + const reasonText = verificationResult.reasons.join(", "); + return `[${helperLabel}] Bundled helper provenance check FAILED against recorded manifest (${reasonText}). The binary at "${verificationResult.manifestPath}" does not match its recorded hash — refusing to package a mismatched/tampered native helper. Rebuild the helper to refresh ${path.basename(verificationResult.manifestPath)} if this change is legitimate.`; +} diff --git a/scripts/sign-macos-app.mjs b/scripts/sign-macos-app.mjs new file mode 100644 index 000000000..95b86217b --- /dev/null +++ b/scripts/sign-macos-app.mjs @@ -0,0 +1,35 @@ +import path from "node:path"; +import process from "node:process"; +import { signAsync } from "@electron/osx-sign"; + +const appPath = process.argv[2] ? path.resolve(process.argv[2]) : null; +const identity = process.env.VYBECLIP_CODESIGN_IDENTITY?.trim(); + +if (!appPath || !appPath.endsWith(".app")) { + throw new Error( + "Usage: VYBECLIP_CODESIGN_IDENTITY= npm run sign:mac -- ", + ); +} + +if (!identity) { + throw new Error("VYBECLIP_CODESIGN_IDENTITY is required"); +} + +const mainEntitlements = path.resolve("build/entitlements.mac.plist"); +const inheritedEntitlements = path.resolve("build/entitlements.mac.inherit.plist"); + +await signAsync({ + app: appPath, + identity, + identityValidation: false, + platform: "darwin", + type: "distribution", + preAutoEntitlements: false, + preEmbedProvisioningProfile: false, + optionsForFile: (filePath) => ({ + entitlements: filePath === appPath ? mainEntitlements : inheritedEntitlements, + hardenedRuntime: true, + }), +}); + +console.log(`Signed ${appPath}`); diff --git a/src/App.tsx b/src/App.tsx index 9e1f4e4c5..24bafdbeb 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -6,6 +6,7 @@ import { UpdateToastWindow } from "./components/launch/UpdateToastWindow"; import { Toaster } from "./components/ui/sonner"; import { ShortcutsConfigDialog } from "./components/video-editor/ShortcutsConfigDialog"; import VideoEditor from "./components/video-editor/VideoEditor"; +import { VybeStudioPreview } from "./components/vybe/VybeStudioPreview"; import { useI18n } from "./contexts/I18nContext"; import { ShortcutsProvider } from "./contexts/ShortcutsContext"; import { loadAllCustomFonts } from "./lib/customFonts"; @@ -14,7 +15,6 @@ export default function App() { const [windowType, setWindowType] = useState(""); const { t } = useI18n(); const isMacOS = /mac/i.test(navigator.platform); - const appIconSrc = "/app-icons/recordly-128.png"; useEffect(() => { const params = new URLSearchParams(window.location.search); @@ -47,13 +47,13 @@ export default function App() { loadAllCustomFonts().catch((error) => { console.error("Failed to load custom fonts:", error); }); - }, []); + }, [isMacOS]); useEffect(() => { document.title = windowType === "editor" - ? t("app.editorTitle", "Recordly Editor") - : t("app.name", "Recordly"); + ? t("app.editorTitle", "VybeClip Editor") + : t("app.name", "VybeClip"); }, [windowType, t]); switch (windowType) { @@ -70,6 +70,13 @@ export default function App() { return ; case "update-toast": return ; + case "studio": + return ( + <> + + + + ); case "editor": return ( @@ -79,23 +86,10 @@ export default function App() { ); default: return ( -
-
- {t("app.name", -
-

- {t("app.name", "Recordly")} -

-

- {t("app.subtitle", "Screen recording and editing")} -

-
-
-
+ <> + + + ); } } diff --git a/src/components/brand/VybeClipLogo.tsx b/src/components/brand/VybeClipLogo.tsx new file mode 100644 index 000000000..ea7de3189 --- /dev/null +++ b/src/components/brand/VybeClipLogo.tsx @@ -0,0 +1,33 @@ +interface VybeClipLogoProps { + className?: string; + title?: string; +} + +export function VybeClipLogo({ className = "h-9 w-9", title }: VybeClipLogoProps) { + return ( + + + + + + + ); +} diff --git a/src/components/launch/LaunchWindow.module.css b/src/components/launch/LaunchWindow.module.css index 3638e7563..6fac762b6 100644 --- a/src/components/launch/LaunchWindow.module.css +++ b/src/components/launch/LaunchWindow.module.css @@ -72,19 +72,19 @@ } .ibActive { - color: #3d8bff; + color: var(--launch-accent); } .ibActive:hover { - color: #62a4ff; + color: #d59d80; } .ibRed { - color: #f43f5e; + color: #b6410f; } .ibRed:hover { - color: #ff5a75; + color: #d59d80; } .ibGreen { @@ -184,7 +184,7 @@ } .finalizingSpin { - color: #3d8bff; + color: var(--launch-accent); animation: finalizingSpin 0.9s linear infinite; } @@ -218,7 +218,7 @@ height: 46px; border-radius: 50%; border: none; - background: #f43f5e; + background: #b6410f; color: #fff; cursor: pointer; display: inline-flex; @@ -226,12 +226,12 @@ justify-content: center; transition: all 0.2s ease; flex-shrink: 0; - box-shadow: 0 0 0 0 rgba(244, 63, 94, 0.3); + box-shadow: 0 0 0 0 rgba(182, 65, 15, 0.3); } .recBtn:hover { - background: #ff5a75; - box-shadow: 0 0 0 6px rgba(244, 63, 94, 0.15); + background: #c0754d; + box-shadow: 0 0 0 6px rgba(182, 65, 15, 0.15); } .recBtn:disabled { @@ -240,7 +240,7 @@ } .recBtn:disabled:hover { - background: #f43f5e; + background: #b6410f; box-shadow: none; } diff --git a/src/components/launch/RecordingControls.tsx b/src/components/launch/RecordingControls.tsx index 4e24f943a..a68c1ead2 100644 --- a/src/components/launch/RecordingControls.tsx +++ b/src/components/launch/RecordingControls.tsx @@ -36,12 +36,12 @@ export const RecordingControls = ({
{paused ? t("recording.paused") : t("recording.rec")} @@ -50,7 +50,7 @@ export const RecordingControls = ({ {formatTime(elapsed)} diff --git a/src/components/launch/SourceSelector.module.css b/src/components/launch/SourceSelector.module.css index 79cf78532..0b56282c9 100644 --- a/src/components/launch/SourceSelector.module.css +++ b/src/components/launch/SourceSelector.module.css @@ -21,9 +21,9 @@ } .selected { - border: 2px solid #2563eb; - background: linear-gradient(120deg, rgba(91, 33, 182, 0.18) 0%, rgba(38, 38, 48, 0.98) 100%); - box-shadow: 0 0 0 2px #2563eb33; + border: 2px solid #b6410f; + background: linear-gradient(120deg, rgba(16, 76, 100, 0.24) 0%, rgba(38, 38, 48, 0.98) 100%); + box-shadow: 0 0 0 2px rgba(182, 65, 15, 0.2); } .icon { @@ -47,7 +47,7 @@ /* scrollbar */ .sourceGridScroll { scrollbar-width: thin; - scrollbar-color: rgba(37, 99, 235, 0.5) rgba(40, 40, 50, 0.6); + scrollbar-color: rgba(192, 117, 77, 0.5) rgba(40, 40, 50, 0.6); } .sourceGridScroll::-webkit-scrollbar { @@ -66,9 +66,9 @@ } .sourceGridScroll::-webkit-scrollbar-thumb:hover { - background: rgba(37, 99, 235, 0.6); + background: rgba(192, 117, 77, 0.6); } .sourceGridScroll::-webkit-scrollbar-thumb:active { - background: rgba(37, 99, 235, 0.8); + background: rgba(182, 65, 15, 0.8); } diff --git a/src/components/launch/UpdateToastWindow.tsx b/src/components/launch/UpdateToastWindow.tsx index e872a25cc..89d5a83bf 100644 --- a/src/components/launch/UpdateToastWindow.tsx +++ b/src/components/launch/UpdateToastWindow.tsx @@ -48,15 +48,15 @@ function getToastTitle(payload: UpdateToastPayload) { switch (payload.phase) { case "available": - return `Recordly ${payload.version} is available`; + return `VybeClip ${payload.version} is available`; case "downloading": - return `Installing Recordly ${payload.version}`; + return `Installing VybeClip ${payload.version}`; case "ready": - return `Recordly ${payload.version} is ready`; + return `VybeClip ${payload.version} is ready`; case "error": return payload.primaryAction === "retry-check" ? "Could not check for updates" - : `Recordly ${payload.version} needs attention`; + : `VybeClip ${payload.version} needs attention`; } } diff --git a/src/components/launch/hooks/useLaunchWindowActions.ts b/src/components/launch/hooks/useLaunchWindowActions.ts index 90acfe577..cb981d481 100644 --- a/src/components/launch/hooks/useLaunchWindowActions.ts +++ b/src/components/launch/hooks/useLaunchWindowActions.ts @@ -1,4 +1,5 @@ import { useCallback, useState } from "react"; +import { toast } from "sonner"; import type { ProjectLibraryEntry } from "@/components/video-editor/ProjectBrowserDialog"; import type { DesktopSource } from "../popovers/launchPopoverTypes"; @@ -24,6 +25,10 @@ export function useLaunchWindowActions() { if (result.success && result.path) { await window.electronAPI.setCurrentVideoPath(result.path); await window.electronAPI.switchToEditor(); + return; + } + if (!result.success) { + toast.error("Failed to open video file. Please try again."); } }, []); diff --git a/src/components/launch/launchTheme.css b/src/components/launch/launchTheme.css index 4adca96dd..20381cb05 100644 --- a/src/components/launch/launchTheme.css +++ b/src/components/launch/launchTheme.css @@ -4,13 +4,13 @@ --launch-panel: #f8fafc; --launch-border: rgba(0, 0, 0, 0.08); --launch-border-strong: rgba(0, 0, 0, 0.16); - --launch-text: #020617; /* Deepest Navy/Black */ - --launch-text-muted: #475569; /* Slate 600 */ - --launch-label: #64748b; /* Slate 500 - increased contrast */ + --launch-text: #0d1d25; + --launch-text-muted: #4b5f64; + --launch-label: #6b756f; --launch-hover: rgba(0, 0, 0, 0.045); - --launch-selected: rgba(37, 99, 235, 0.12); /* Blue 600 at 12% */ - --launch-accent: #2563eb; /* Blue 600 */ - --launch-accent-soft: #f1f5f9; /* Slate 100 */ + --launch-selected: rgba(16, 76, 100, 0.13); + --launch-accent: #b6410f; + --launch-accent-soft: #f6e3d8; --launch-popover-blur: 40px; --launch-popover-saturate: 200%; --launch-bar-bg: rgba(255, 255, 255, 0.98); @@ -23,18 +23,18 @@ .dark .launch-theme, .launch-theme.dark { /* Dark Mode values */ - --launch-surface: rgba(18, 18, 24, 0.97); - --launch-panel: rgba(18, 18, 24, 0.82); + --launch-surface: rgba(10, 17, 21, 0.97); + --launch-panel: rgba(13, 29, 37, 0.82); --launch-border: rgba(255, 255, 255, 0.07); --launch-border-strong: rgba(255, 255, 255, 0.12); - --launch-text: #f3f6ff; - --launch-text-muted: #aab3c7; - --launch-label: #8f99b1; + --launch-text: #f3e7de; + --launch-text-muted: #c6c6d0; + --launch-label: rgba(198, 198, 208, 0.62); --launch-hover: rgba(255, 255, 255, 0.1); - --launch-selected: rgba(92, 153, 255, 0.22); - --launch-accent: #3d8bff; - --launch-accent-soft: #dbeafe; - --launch-bar-bg: rgba(18, 18, 24, 0.97); + --launch-selected: rgba(16, 76, 100, 0.34); + --launch-accent: #d59d80; + --launch-accent-soft: #0d1d25; + --launch-bar-bg: rgba(10, 17, 21, 0.97); --launch-bar-border: rgba(255, 255, 255, 0.07); --launch-bar-shadow: 0 10px 30px rgba(0, 0, 0, 0.24), 0 2px 10px rgba(0, 0, 0, 0.12); } diff --git a/src/components/video-editor/ExtensionManager.tsx b/src/components/video-editor/ExtensionManager.tsx index ef06fb8e5..4e1a4d841 100644 --- a/src/components/video-editor/ExtensionManager.tsx +++ b/src/components/video-editor/ExtensionManager.tsx @@ -11,6 +11,7 @@ import { Check, CaretLeft as ChevronLeft, CaretRight as ChevronRight, + Clock, DownloadSimple as Download, ArrowSquareOut as ExternalLink, FolderOpen, @@ -45,6 +46,14 @@ const TAB_OPTIONS: { value: ExtensionTab; labelKey: string }[] = [ const EXTENSIONS_DOCS_URL = "https://marketplace.recordly.dev/extensions"; const EXTENSIONS_SUBMIT_URL = "https://marketplace.recordly.dev/extensions/submit"; +// Remote marketplace installation is temporarily disabled during the beta/ +// testing phase (see electron/extensions/extensionMarketplace.ts — +// isRemoteMarketplaceInstallEnabled). Browsing/search still works; installing +// is gated in the UI so users get a clear "coming soon" state instead of a +// dead-end error after picking "Install". Locally bundled/first-party +// extensions and "install from folder" are unaffected. +const MARKETPLACE_INSTALL_DISABLED = true; + function toSafeHttpUrl(value?: string): string | null { if (!value) return null; @@ -261,6 +270,14 @@ function MarketplaceCard({ {t("status.installed")} + ) : MARKETPLACE_INSTALL_DISABLED ? ( + + + {t("status.comingSoon")} + ) : (
)} - {detail.source === "marketplace" && !detail.ext.installed && onInstall && ( - - )} + {detail.source === "marketplace" && + !detail.ext.installed && + onInstall && + (MARKETPLACE_INSTALL_DISABLED ? ( + + + {t("status.comingSoonFull")} + + ) : ( + + ))} {detail.source === "marketplace" && detail.ext.installed && ( @@ -1043,6 +1068,16 @@ function BrowseTab({ const t = useScopedT("extensions"); return (
+ {/* Beta notice: remote installs are disabled during the testing phase */} + {MARKETPLACE_INSTALL_DISABLED && ( +
+ +

+ {t("browse.installDisabledNotice")} +

+
+ )} + {/* Search */}
diff --git a/src/components/video-editor/TutorialHelp.tsx b/src/components/video-editor/TutorialHelp.tsx index a09e5c888..fd52e45d0 100644 --- a/src/components/video-editor/TutorialHelp.tsx +++ b/src/components/video-editor/TutorialHelp.tsx @@ -1,5 +1,15 @@ -import { ArrowRight, ArrowSquareOut as ExternalLink, Question as HelpCircle, Keyboard, ChatDots as MessageSquareMore, Scissors, GearSix as Settings2, XLogo as Twitter } from "@phosphor-icons/react"; +import { + ArrowRight, + ArrowSquareOut as ExternalLink, + Question as HelpCircle, + Info, + Keyboard, + ChatDots as MessageSquareMore, + Scissors, + GearSix as Settings2, +} from "@phosphor-icons/react"; import { useEffect, useState } from "react"; +import { toast } from "sonner"; import { Button } from "@/components/ui/button"; import { Dialog, @@ -13,28 +23,18 @@ import { useScopedT } from "@/contexts/I18nContext"; import { useShortcuts } from "@/contexts/ShortcutsContext"; import { formatBinding, SHORTCUT_ACTIONS, SHORTCUT_LABELS } from "@/lib/shortcuts"; import { formatShortcut } from "@/utils/platformUtils"; -import { toast } from "sonner"; -export const RECORDLY_ISSUES_URL = "https://github.com/webadderallorg/Recordly/issues"; -const RECORDLY_DISCORD_URL = "https://discord.gg/sdv2FBVNgE"; -const RECORDLY_X_URL = "https://x.com/webadderall"; -const CONTACT_EMAIL = "youngchen3442@gmail.com"; -export const APP_HEADER_ACTION_BUTTON_CLASS = "h-7 px-2 text-xs text-muted-foreground hover:bg-foreground/10 hover:text-foreground transition-all gap-1.5"; -export const APP_HEADER_ICON_BUTTON_CLASS = "h-7 w-7 p-0 text-muted-foreground hover:bg-foreground/10 hover:text-foreground transition-all"; +export const VYBECLIP_ISSUES_URL = "https://github.com/Alfredoalv13/Recordly/issues"; +export const APP_HEADER_ACTION_BUTTON_CLASS = + "h-7 px-2 text-xs text-muted-foreground hover:bg-foreground/10 hover:text-foreground transition-all gap-1.5"; +export const APP_HEADER_ICON_BUTTON_CLASS = + "h-7 w-7 p-0 text-muted-foreground hover:bg-foreground/10 hover:text-foreground transition-all"; interface KeyboardShortcutsDialogProps { triggerLabel?: string; triggerClassName?: string; } -function DiscordIcon(props: React.SVGProps) { - return ( - - ); -} - export async function openExternalLink(url: string, errorMessage: string) { try { const result = await window.electronAPI.openExternalUrl(url); @@ -46,24 +46,6 @@ export async function openExternalLink(url: string, errorMessage: string) { } } -export function DiscordLinkButton() { - const t = useScopedT("editor"); - - return ( - - ); -} - export function FeedbackDialog() { const t = useScopedT("editor"); @@ -83,51 +65,26 @@ export function FeedbackDialog() { - {t("feedback.title", "Feedback & contact")} + {" "} + {t("feedback.title", "Feedback & contact")} - {t("feedback.description", "Reach out directly or open an issue if something is broken or missing.")} + {t( + "feedback.description", + "Reach out directly or open an issue if something is broken or missing.", + )}
-
-
-
-

- {t("feedback.emailLabel", "Email")} -

-

{CONTACT_EMAIL}

-
- -
-
-
-

- {t("feedback.xLabel", "X")} -

-

@webadderall

-
- -
-
+ + + + + {" "} + {t("about.title", "About VybeClip")} + + + {appVersion + ? t("about.version", "Version {{version}}", { version: appVersion }) + : t("about.by", "By Box Creative Studio")} + + +
+

+ {t("about.developedBy", "Developed by Box Creative Studio.")} +

+
+ {t( + "about.attribution", + "VybeClip's recording and editing engine is built on Recordly (AGPLv3) by webadderall, originally forked from OpenScreen by Siddharth Vaddem (MIT).", + )} +
+ +
+
+ + ); +} + export function KeyboardShortcutsDialog({ triggerLabel, triggerClassName, @@ -154,10 +180,9 @@ export function KeyboardShortcutsDialog({ }); useEffect(() => { - Promise.all([ - formatShortcut(["shift", "Scroll"]), - formatShortcut(["mod", "Scroll"]), - ]).then(([pan, zoom]) => setScrollLabels({ pan, zoom })); + Promise.all([formatShortcut(["shift", "Scroll"]), formatShortcut(["mod", "Scroll"])]).then( + ([pan, zoom]) => setScrollLabels({ pan, zoom }), + ); }, []); return ( @@ -177,17 +202,26 @@ export function KeyboardShortcutsDialog({ - {t("keyboardShortcuts.title")} + {" "} + {t("keyboardShortcuts.title")} - {t("keyboardShortcuts.description", "Quick reference for the timeline and editor controls.")} + {t( + "keyboardShortcuts.description", + "Quick reference for the timeline and editor controls.", + )}
{SHORTCUT_ACTIONS.map((action) => ( -
- {SHORTCUT_LABELS[action]} +
+ + {SHORTCUT_LABELS[action]} + {formatBinding(shortcuts[action], isMac)} @@ -195,19 +229,25 @@ export function KeyboardShortcutsDialog({ ))}
-

{t("keyboardShortcuts.panTimeline")}

+

+ {t("keyboardShortcuts.panTimeline")} +

{scrollLabels.pan}
-

{t("keyboardShortcuts.zoomTimeline")}

+

+ {t("keyboardShortcuts.zoomTimeline")} +

{scrollLabels.zoom}
-

{t("keyboardShortcuts.cycleAnnotations")}

+

+ {t("keyboardShortcuts.cycleAnnotations")} +

{t("keyboardShortcuts.tab")} @@ -237,11 +277,7 @@ export function TutorialHelp() { return ( - @@ -260,8 +296,11 @@ export function TutorialHelp() {

{t("tutorial.descriptionP1")} - {t("tutorial.descriptionRemove")}.{" "} - {t("tutorial.descriptionP3")} + + {" "} + {t("tutorial.descriptionRemove")} + + . {t("tutorial.descriptionP3")}

{/* Visual Illustration */} @@ -340,12 +379,20 @@ export function TutorialHelp() { {/* Steps */}
-
{t("tutorial.addTrimStep")}
-

{t("tutorial.addTrimDesc")}

+
+ {t("tutorial.addTrimStep")} +
+

+ {t("tutorial.addTrimDesc")} +

-
{t("tutorial.adjustStep")}
-

{t("tutorial.adjustDesc")}

+
+ {t("tutorial.adjustStep")} +
+

+ {t("tutorial.adjustDesc")} +

diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index f18b931f7..e2a9c1973 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -155,11 +155,11 @@ import { SettingsPanel } from "./SettingsPanel"; import { getDevOpenRecordingConfig, getSmokeExportConfig } from "./smokeExportConfig"; import { createSmokeExportProgressSampler } from "./smokeExportProgress"; import { + AboutDialog, APP_HEADER_ICON_BUTTON_CLASS, - DiscordLinkButton, FeedbackDialog, openExternalLink, - RECORDLY_ISSUES_URL, + VYBECLIP_ISSUES_URL, } from "./TutorialHelp"; import TimelineEditor, { type TimelineEditorHandle } from "./timeline/TimelineEditor"; import { @@ -1036,16 +1036,37 @@ export default function VideoEditor() { } }, [handleSaveEditorPreset, presetNameDraft]); + const discardExportedTempWithWarning = useCallback((tempFilePath: string) => { + // Best-effort cleanup — main-process also reaps stale temp files on + // before-quit, but we still log failures (both rejected promises and + // resolved { success: false } results) so an orphaned multi-GB temp + // file is discoverable instead of silently consuming disk space. + void window.electronAPI.discardExportedTemp + ?.(tempFilePath) + ?.then((result) => { + if (!result?.success) { + console.warn( + `Failed to discard exported temp file at ${tempFilePath}:`, + result?.error ?? "unknown error", + ); + } + }) + .catch((discardError) => { + console.warn( + `Failed to discard exported temp file at ${tempFilePath}:`, + discardError, + ); + }); + }, []); + const clearPendingExportSave = useCallback(() => { const pending = pendingExportSaveRef.current; pendingExportSaveRef.current = null; setHasPendingExportSave(false); if (pending?.tempFilePath && typeof window !== "undefined") { - // Best-effort cleanup — main-process also reaps stale temp files on - // before-quit, so we ignore failures here. - void window.electronAPI.discardExportedTemp?.(pending.tempFilePath); + discardExportedTempWithWarning(pending.tempFilePath); } - }, []); + }, [discardExportedTempWithWarning]); const refreshProjectLibrary = useCallback(async () => { try { @@ -1411,7 +1432,7 @@ export default function VideoEditor() { const pending = pendingExportSaveRef.current; pendingExportSaveRef.current = null; if (pending?.tempFilePath && typeof window !== "undefined") { - void window.electronAPI.discardExportedTemp?.(pending.tempFilePath); + discardExportedTempWithWarning(pending.tempFilePath); } if (pendingTelemetryRetryTimeoutRef.current !== null) { window.clearTimeout(pendingTelemetryRetryTimeoutRef.current); @@ -1709,9 +1730,15 @@ export default function VideoEditor() { currentProjectPath?.split(/[\\/]/).pop() ?? currentSourcePath?.split(/[\\/]/).pop() ?? ""; - const withoutExtension = fileName.replace(/\.recordly$/i, "").replace(/\.[^.]+$/, ""); + const withoutExtension = fileName + .replace(/\.(vybeclip|recordly|openscreen)$/i, "") + .replace(/\.[^.]+$/, ""); return withoutExtension || t("editor.project.untitled", "Untitled"); }, [currentProjectPath, currentSourcePath, t]); + const projectFileExtension = useMemo(() => { + const legacyExtension = currentProjectPath?.match(/\.(recordly|openscreen)$/i)?.[1]; + return legacyExtension ? `.${legacyExtension.toLowerCase()}` : ".vybeclip"; + }, [currentProjectPath]); useEffect(() => { if (!isEditingProjectName) { @@ -3398,7 +3425,9 @@ export default function VideoEditor() { const handlePreviewSkipBack = useCallback(() => { const currentMs = timelinePlayheadTime * 1000; const keyframes = timelineRef.current?.keyframes ?? []; - const previous = [...keyframes].reverse().find((keyframe) => keyframe.time < currentMs - 50); + const previous = [...keyframes] + .reverse() + .find((keyframe) => keyframe.time < currentMs - 50); handleSeek(previous ? previous.time / 1000 : Math.max(0, timelinePlayheadTime - 5)); }, [handleSeek, timelinePlayheadTime]); @@ -3406,9 +3435,7 @@ export default function VideoEditor() { const currentMs = timelinePlayheadTime * 1000; const keyframes = timelineRef.current?.keyframes ?? []; const next = keyframes.find((keyframe) => keyframe.time > currentMs + 50); - handleSeek( - next ? next.time / 1000 : Math.min(timelineDuration, timelinePlayheadTime + 5), - ); + handleSeek(next ? next.time / 1000 : Math.min(timelineDuration, timelinePlayheadTime + 5)); }, [handleSeek, timelineDuration, timelinePlayheadTime]); const handleSelectZoom = useCallback((id: string | null) => { @@ -5065,7 +5092,7 @@ export default function VideoEditor() { const openLightningIssues = useCallback(async () => { await openExternalLink( - RECORDLY_ISSUES_URL, + VYBECLIP_ISSUES_URL, t("editor.feedback.openFailed", "Failed to open link."), ); }, [t]); @@ -5215,10 +5242,7 @@ export default function VideoEditor() { volume={ audio.shouldMutePreviewVideo || audio.isCurrentClipMuted ? 0 - : Math.max( - 0, - Math.min(1, previewVolume * audio.embeddedSourcePreviewGain), - ) + : Math.max(0, Math.min(1, previewVolume * audio.embeddedSourcePreviewGain)) } suspendRendering={suspendRendering} /> @@ -5251,7 +5275,7 @@ export default function VideoEditor() { {t( "editor.nativeCaptureUnavailable.description", - "Your device does not support native capture. This could be for a variety of reasons we haven’t figured out yet. This doesn’t break Recordly, but it does make cursor smoothing impossible.", + "Your device does not support native capture. This could be for a variety of reasons we haven’t figured out yet. This doesn’t break VybeClip, but it does make cursor smoothing impossible.", )} @@ -5317,8 +5341,8 @@ export default function VideoEditor() { > - +
)} @@ -5736,7 +5760,9 @@ export default function VideoEditor() { onGifLoopChange={setGifLoop} gifSizePreset={gifSizePreset} onGifSizePresetChange={setGifSizePreset} - showCaptionSidecarOption={hasCaptionsForSidecar && exportFormat === "mp4"} + showCaptionSidecarOption={ + hasCaptionsForSidecar && exportFormat === "mp4" + } includeCaptionSidecar={includeCaptionSidecar} onIncludeCaptionSidecarChange={setIncludeCaptionSidecar} mp4OutputDimensions={mp4OutputDimensions} diff --git a/src/components/video-editor/VideoPlayback.tsx b/src/components/video-editor/VideoPlayback.tsx index 488bc3a9d..a0397f834 100644 --- a/src/components/video-editor/VideoPlayback.tsx +++ b/src/components/video-editor/VideoPlayback.tsx @@ -525,6 +525,8 @@ const VideoPlayback = forwardRef( height: number; } | null>(null); const captionBoxRef = useRef(null); + const captionClipPathFrameRef = useRef(null); + const captionClipPathSignatureRef = useRef(null); const currentTimeRef = useRef(0); const zoomRegionsRef = useRef([]); const selectedZoomIdRef = useRef(null); @@ -736,10 +738,20 @@ const VideoPlayback = forwardRef( captionBox.style.clipPath = ""; captionBox.style.removeProperty("-webkit-clip-path"); } + captionClipPathSignatureRef.current = null; + return; + } + + // Coalesce recomputation triggers that land within the same animation + // frame (e.g. rapid caption-layout updates during active editing) by + // only ever scheduling one pending RAF at a time. + if (captionClipPathFrameRef.current !== null) { return; } const frame = requestAnimationFrame(() => { + captionClipPathFrameRef.current = null; + const width = captionBox.offsetWidth; const height = captionBox.offsetHeight; if (width <= 0 || height <= 0) { @@ -751,19 +763,36 @@ const VideoPlayback = forwardRef( overlayRef.current?.clientWidth || 960, autoCaptionSettings.maxWidth, ); + const radius = getCaptionScaledRadius(autoCaptionSettings.boxRadius, fontSize); + + // Skip recomputing (and rewriting) the clip-path when the geometry + // that actually determines the squircle path hasn't changed since + // the last computation, avoiding redundant path string generation + // and style writes on every caption-layout change. + const signature = `${width}x${height}x${radius}`; + if (captionClipPathSignatureRef.current === signature) { + return; + } + captionClipPathSignatureRef.current = signature; const squirclePath = getSquircleSvgPath({ x: 0, y: 0, width, height, - radius: getCaptionScaledRadius(autoCaptionSettings.boxRadius, fontSize), + radius, }); captionBox.style.clipPath = `path('${squirclePath}')`; captionBox.style.setProperty("-webkit-clip-path", `path('${squirclePath}')`); }); + captionClipPathFrameRef.current = frame; - return () => cancelAnimationFrame(frame); + return () => { + if (captionClipPathFrameRef.current === frame) { + cancelAnimationFrame(frame); + captionClipPathFrameRef.current = null; + } + }; }, [activeCaptionLayout, autoCaptionSettings]); const motionBlurStateRef = useRef(createMotionBlurState()); const webcamEnabled = webcam?.enabled ?? false; diff --git a/src/components/video-editor/videoPlayback/zoomRegionUtils.ts b/src/components/video-editor/videoPlayback/zoomRegionUtils.ts index 8e1cc6bf5..b167fc66b 100644 --- a/src/components/video-editor/videoPlayback/zoomRegionUtils.ts +++ b/src/components/video-editor/videoPlayback/zoomRegionUtils.ts @@ -203,7 +203,7 @@ function getConnectedRegionTransition(connectedPairs: ConnectedRegionPair[], tim for (const pair of connectedPairs) { const { currentRegion, nextRegion, transitionStart, transitionEnd } = pair; - if (timeMs < transitionStart || timeMs > transitionEnd) { + if (timeMs <= transitionStart || timeMs >= transitionEnd) { continue; } @@ -250,6 +250,11 @@ export function findDominantRegion( const connectedPairs = options.connectZooms ? getConnectedRegionPairs(regions) : []; if (options.connectZooms) { + const connectedTransition = getConnectedRegionTransition(connectedPairs, timeMs); + if (connectedTransition) { + return connectedTransition; + } + const connectedHold = getConnectedRegionHold(timeMs, connectedPairs); if (connectedHold) { return { ...connectedHold, transition: null }; diff --git a/src/components/vybe/VybeStudioPreview.tsx b/src/components/vybe/VybeStudioPreview.tsx new file mode 100644 index 000000000..5bd459916 --- /dev/null +++ b/src/components/vybe/VybeStudioPreview.tsx @@ -0,0 +1,510 @@ +import { + ArrowClockwiseIcon, + ArrowSquareOutIcon, + CameraIcon, + CaretDownIcon, + CircleNotchIcon, + ExportIcon, + FilmSlateIcon, + FolderOpenIcon, + MicrophoneIcon, + MonitorIcon, + PlusIcon, + RecordIcon, + SlidersHorizontalIcon, + SparkleIcon, + UploadSimpleIcon, + WarningCircleIcon, + WaveformIcon, +} from "@phosphor-icons/react"; +import { useCallback, useEffect, useState } from "react"; +import { toast } from "sonner"; +import { VybeClipLogo } from "../brand/VybeClipLogo"; +import type { ProjectLibraryEntry } from "../video-editor/ProjectBrowserDialog"; +import { toFileUrl } from "../video-editor/projectPersistence"; +import { + getStudioPermissionState, + importStudioVideo, + openRecentStudioProject, + openStudioProject, + type StudioPermissionState, + startStudioRecording, +} from "./studioActions"; + +const inspectorRows = [ + ["Format", "16:9 Desktop"], + ["Canvas Style", "Clean Demo"], + ["Pointer", "Soft Highlight"], + ["Captions", "Smart Draft"], +]; + +function formatProjectDate(updatedAt: number) { + return new Intl.DateTimeFormat(undefined, { + month: "short", + day: "numeric", + year: + new Date(updatedAt).getFullYear() === new Date().getFullYear() ? undefined : "numeric", + }).format(updatedAt); +} + +export function VybeStudioPreview() { + const isDesktop = Boolean(window.electronAPI); + const [projects, setProjects] = useState([]); + const [projectsLoading, setProjectsLoading] = useState(isDesktop); + const [pendingAction, setPendingAction] = useState(null); + const [permissions, setPermissions] = useState(null); + const [permissionsLoading, setPermissionsLoading] = useState(isDesktop); + + const refreshProjects = useCallback(async () => { + if (!window.electronAPI) return; + + setProjectsLoading(true); + try { + const result = await window.electronAPI.listProjectFiles(); + if (!result.success) { + throw new Error(result.error || "Unable to load projects"); + } + setProjects(result.entries); + } catch (error) { + console.error("Failed to load VybeClip projects:", error); + toast.error("Could not load your projects"); + } finally { + setProjectsLoading(false); + } + }, []); + + useEffect(() => { + void refreshProjects(); + }, [refreshProjects]); + + const refreshPermissions = useCallback(async () => { + if (!window.electronAPI) return; + + setPermissionsLoading(true); + try { + setPermissions(await getStudioPermissionState(window.electronAPI)); + } catch (error) { + console.error("Failed to load VybeClip permission status:", error); + } finally { + setPermissionsLoading(false); + } + }, []); + + useEffect(() => { + void refreshPermissions(); + }, [refreshPermissions]); + + const runAction = useCallback( + async (actionName: string, action: () => Promise) => { + if (!window.electronAPI || pendingAction) return; + setPendingAction(actionName); + try { + await action(); + } catch (error) { + console.error(`VybeClip ${actionName} action failed:`, error); + toast.error( + error instanceof Error ? error.message : "The action could not be completed", + ); + } finally { + setPendingAction(null); + } + }, + [pendingAction], + ); + + const handleRecord = useCallback(() => { + void runAction("record", async () => { + try { + await startStudioRecording(window.electronAPI); + } finally { + await refreshPermissions(); + } + }); + }, [refreshPermissions, runAction]); + + const handleImport = useCallback(() => { + void runAction("import", async () => { + await importStudioVideo(window.electronAPI); + }); + }, [runAction]); + + const handleOpenProject = useCallback(() => { + void runAction("open-project", async () => { + await openStudioProject(window.electronAPI); + }); + }, [runAction]); + + const handleOpenRecentProject = useCallback( + (projectPath: string) => { + void runAction("open-project", async () => { + await openRecentStudioProject(window.electronAPI, projectPath); + }); + }, + [runAction], + ); + + const handleOpenProjectsFolder = useCallback(() => { + void runAction("projects-folder", async () => { + const result = await window.electronAPI.openProjectsDirectory(); + if (!result.success) { + throw new Error(result.message || "Could not open the projects folder"); + } + }); + }, [runAction]); + + const recentProjects = projects.slice(0, 5); + const actionsDisabled = !isDesktop || pendingAction !== null; + const permissionIssue = + permissions?.platform === "darwin" && !permissions.screenRecordingGranted + ? "screen" + : permissions?.platform === "darwin" && !permissions.accessibilityGranted + ? "accessibility" + : null; + + const handleOpenPermissionSettings = useCallback(() => { + if (!permissionIssue) return; + void runAction("permissions", async () => { + if (permissionIssue === "screen") { + await window.electronAPI.openScreenRecordingPreferences(); + } else { + await window.electronAPI.openAccessibilityPreferences(); + } + }); + }, [permissionIssue, runAction]); + + return ( +
+
+
+ +
+

+ VybeClip Studio +

+

+ Capture, polish, publish +

+
+
+ +
+ + + +
+
+ {permissionIssue && ( +
+
+ +
+

+ {permissionIssue === "screen" + ? "Screen Recording permission is required" + : "Accessibility permission is required for cursor tracking"} +

+

+ Enable VybeClip in macOS Privacy & Security, then reopen the app. +

+
+
+
+ + +
+
+ )} + +
+ + +
+
+
+
+
+
+ +
+

+ New Capture +

+

+ Record a screen, window, camera and audio. +

+
+
+
+
+
+ +
+
+
+ + Timeline +
+
+ 00:00 + + New project +
+
+
+
+
+ + Video +
+
+
+
+
+
+
+ + Audio +
+
+
+
+
+
+
+
+ + +
+
+ ); +} diff --git a/src/components/vybe/studioActions.test.ts b/src/components/vybe/studioActions.test.ts new file mode 100644 index 000000000..a58e7626e --- /dev/null +++ b/src/components/vybe/studioActions.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it, vi } from "vitest"; +import { + importStudioVideo, + openRecentStudioProject, + openStudioProject, + startStudioRecording, +} from "./studioActions"; + +function createApi() { + return { + showRecordingControls: vi.fn().mockResolvedValue({ success: true }), + openSourceSelector: vi.fn().mockResolvedValue(undefined), + getPlatform: vi.fn().mockResolvedValue("darwin"), + getScreenRecordingPermissionStatus: vi + .fn() + .mockResolvedValue({ success: true, status: "granted" }), + getAccessibilityPermissionStatus: vi + .fn() + .mockResolvedValue({ success: true, trusted: true, prompted: false }), + openScreenRecordingPreferences: vi.fn().mockResolvedValue({ success: true }), + openAccessibilityPreferences: vi.fn().mockResolvedValue({ success: true }), + openVideoFilePicker: vi.fn().mockResolvedValue({ success: true, path: "/tmp/capture.mp4" }), + setCurrentVideoPath: vi.fn().mockResolvedValue({ success: true }), + switchToEditor: vi.fn().mockResolvedValue(undefined), + loadProjectFile: vi.fn().mockResolvedValue({ success: true, path: "/tmp/demo.vybeclip" }), + openProjectFileAtPath: vi + .fn() + .mockResolvedValue({ success: true, path: "/tmp/demo.vybeclip" }), + }; +} + +describe("VybeClip Studio actions", () => { + it("opens recording controls before the source selector", async () => { + const api = createApi(); + + await startStudioRecording(api); + + expect(api.showRecordingControls).toHaveBeenCalledOnce(); + expect(api.openSourceSelector).toHaveBeenCalledOnce(); + expect(api.showRecordingControls.mock.invocationCallOrder[0]).toBeLessThan( + api.openSourceSelector.mock.invocationCallOrder[0], + ); + }); + + it("opens Screen Recording settings instead of the HUD when permission is missing", async () => { + const api = createApi(); + api.getScreenRecordingPermissionStatus.mockResolvedValue({ + success: true, + status: "denied", + }); + + await expect(startStudioRecording(api)).rejects.toThrow("Enable Screen Recording"); + expect(api.openScreenRecordingPreferences).toHaveBeenCalledOnce(); + expect(api.showRecordingControls).not.toHaveBeenCalled(); + }); + + it("opens Accessibility settings instead of the HUD when permission is missing", async () => { + const api = createApi(); + api.getAccessibilityPermissionStatus.mockResolvedValue({ + success: true, + trusted: false, + prompted: false, + }); + + await expect(startStudioRecording(api)).rejects.toThrow("Enable Accessibility"); + expect(api.openAccessibilityPreferences).toHaveBeenCalledOnce(); + expect(api.showRecordingControls).not.toHaveBeenCalled(); + }); + + it("does not require macOS permissions on other platforms", async () => { + const api = createApi(); + api.getPlatform.mockResolvedValue("win32"); + + await startStudioRecording(api); + expect(api.getScreenRecordingPermissionStatus).not.toHaveBeenCalled(); + expect(api.showRecordingControls).toHaveBeenCalledOnce(); + }); + + it("imports a selected video before opening the editor", async () => { + const api = createApi(); + + await expect(importStudioVideo(api)).resolves.toBe(true); + expect(api.setCurrentVideoPath).toHaveBeenCalledWith("/tmp/capture.mp4"); + expect(api.switchToEditor).toHaveBeenCalledOnce(); + }); + + it("does not open the editor when video import is cancelled", async () => { + const api = createApi(); + api.openVideoFilePicker.mockResolvedValue({ success: false, canceled: true }); + + await expect(importStudioVideo(api)).resolves.toBe(false); + expect(api.setCurrentVideoPath).not.toHaveBeenCalled(); + expect(api.switchToEditor).not.toHaveBeenCalled(); + }); + + it("opens a project selected by the file picker", async () => { + const api = createApi(); + + await expect(openStudioProject(api)).resolves.toBe(true); + expect(api.loadProjectFile).toHaveBeenCalledOnce(); + expect(api.switchToEditor).toHaveBeenCalledOnce(); + }); + + it("opens a recent project by path", async () => { + const api = createApi(); + + await expect(openRecentStudioProject(api, "/tmp/recent.vybeclip")).resolves.toBe(true); + expect(api.openProjectFileAtPath).toHaveBeenCalledWith("/tmp/recent.vybeclip"); + expect(api.switchToEditor).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/components/vybe/studioActions.ts b/src/components/vybe/studioActions.ts new file mode 100644 index 000000000..6554514f4 --- /dev/null +++ b/src/components/vybe/studioActions.ts @@ -0,0 +1,98 @@ +type StudioElectronApi = Pick< + Window["electronAPI"], + | "loadProjectFile" + | "getAccessibilityPermissionStatus" + | "getPlatform" + | "getScreenRecordingPermissionStatus" + | "openAccessibilityPreferences" + | "openProjectFileAtPath" + | "openScreenRecordingPreferences" + | "openSourceSelector" + | "openVideoFilePicker" + | "setCurrentVideoPath" + | "showRecordingControls" + | "switchToEditor" +>; + +export type StudioPermissionState = { + platform: string; + screenRecordingGranted: boolean; + accessibilityGranted: boolean; +}; + +export async function getStudioPermissionState( + api: StudioElectronApi, +): Promise { + const platform = await api.getPlatform(); + if (platform !== "darwin") { + return { + platform, + screenRecordingGranted: true, + accessibilityGranted: true, + }; + } + + const [screenRecording, accessibility] = await Promise.all([ + api.getScreenRecordingPermissionStatus(), + api.getAccessibilityPermissionStatus(), + ]); + + return { + platform, + screenRecordingGranted: screenRecording.success && screenRecording.status === "granted", + accessibilityGranted: accessibility.success && accessibility.trusted, + }; +} + +export async function startStudioRecording(api: StudioElectronApi) { + const permissions = await getStudioPermissionState(api); + if (!permissions.screenRecordingGranted) { + await api.openScreenRecordingPreferences(); + throw new Error("Enable Screen Recording in System Settings, then reopen VybeClip"); + } + if (!permissions.accessibilityGranted) { + await api.openAccessibilityPreferences(); + throw new Error("Enable Accessibility in System Settings, then reopen VybeClip"); + } + + const result = await api.showRecordingControls(); + if (!result.success) { + throw new Error("Could not open recording controls"); + } + + await api.openSourceSelector(); +} + +export async function importStudioVideo(api: StudioElectronApi) { + const result = await api.openVideoFilePicker(); + if (result.canceled) return false; + if (!result.success || !result.path) { + throw new Error("Could not open that video"); + } + + await api.setCurrentVideoPath(result.path); + await api.switchToEditor(); + return true; +} + +export async function openStudioProject(api: StudioElectronApi) { + const result = await api.loadProjectFile(); + if (result.canceled) return false; + if (!result.success) { + throw new Error(result.message || "Could not open that project"); + } + + await api.switchToEditor(); + return true; +} + +export async function openRecentStudioProject(api: StudioElectronApi, projectPath: string) { + const result = await api.openProjectFileAtPath(projectPath); + if (result.canceled) return false; + if (!result.success) { + throw new Error(result.message || "Could not open that project"); + } + + await api.switchToEditor(); + return true; +} diff --git a/src/hooks/useScreenRecorder.ts b/src/hooks/useScreenRecorder.ts index c5cd70056..fcaedb440 100644 --- a/src/hooks/useScreenRecorder.ts +++ b/src/hooks/useScreenRecorder.ts @@ -499,8 +499,8 @@ export function useScreenRecorder(): UseScreenRecorderReturn { await window.electronAPI.openScreenRecordingPreferences(); alert( options.startup - ? "Recordly needs Screen Recording permission before you start. System Settings has been opened. After enabling it, quit and reopen Recordly." - : "Screen Recording permission is still missing. System Settings has been opened again. Enable it, then quit and reopen Recordly before recording.", + ? "VybeClip needs Screen Recording permission before you start. System Settings has been opened. After enabling it, quit and reopen VybeClip." + : "Screen Recording permission is still missing. System Settings has been opened again. Enable it, then quit and reopen VybeClip before recording.", ); return false; } @@ -522,8 +522,8 @@ export function useScreenRecorder(): UseScreenRecorderReturn { await window.electronAPI.openAccessibilityPreferences(); alert( options.startup - ? "Recordly also needs Accessibility permission for cursor tracking. System Settings has been opened. After enabling it, quit and reopen Recordly." - : "Accessibility permission is still missing. System Settings has been opened again. Enable it, then quit and reopen Recordly before recording.", + ? "VybeClip also needs Accessibility permission for cursor tracking. System Settings has been opened. After enabling it, quit and reopen VybeClip." + : "Accessibility permission is still missing. System Settings has been opened again. Enable it, then quit and reopen VybeClip before recording.", ); return false; diff --git a/src/i18n/locales/en/common.json b/src/i18n/locales/en/common.json index 98bb2c7aa..125afee1f 100644 --- a/src/i18n/locales/en/common.json +++ b/src/i18n/locales/en/common.json @@ -1,7 +1,7 @@ { "app": { - "name": "Recordly", - "editorTitle": "Recordly Editor", + "name": "VybeClip", + "editorTitle": "VybeClip Editor", "subtitle": "Screen recording and editing", "language": "Language", "manageRecordings": "Open recordings folder" diff --git a/src/i18n/locales/en/editor.json b/src/i18n/locales/en/editor.json index 902522306..79c0067cb 100644 --- a/src/i18n/locales/en/editor.json +++ b/src/i18n/locales/en/editor.json @@ -115,7 +115,7 @@ }, "nativeCaptureUnavailable": { "title": "Nothing’s broken, but we won’t be able to render an animated cursor overlay.", - "description": "Your device does not support native capture. This could be for a variety of reasons we haven’t figured out yet. This doesn’t break Recordly, but it does make cursor smoothing impossible.", + "description": "Your device does not support native capture. This could be for a variety of reasons we haven’t figured out yet. This doesn’t break VybeClip, but it does make cursor smoothing impossible.", "confirm": "Okay" }, "exportStatus": { diff --git a/src/i18n/locales/en/extensions.json b/src/i18n/locales/en/extensions.json index b1be4045e..90b798ebe 100644 --- a/src/i18n/locales/en/extensions.json +++ b/src/i18n/locales/en/extensions.json @@ -20,7 +20,10 @@ "status": { "enabled": "Enabled", "disabled": "Disabled", - "installed": "Installed" + "installed": "Installed", + "comingSoon": "Coming soon", + "comingSoonFull": "Installing is coming soon", + "installDisabledHint": "Marketplace installs are disabled during the beta" }, "detail": { "by": "By {{author}}", @@ -46,6 +49,9 @@ "count": "{{count}} extension", "countPlural": "{{count}} extensions" }, + "browse": { + "installDisabledNotice": "Installing extensions from the marketplace is temporarily disabled during the testing phase. You can still browse what's available — installing is coming soon. Locally bundled extensions keep working normally." + }, "toast": { "installedAndEnabled": "Extension installed and enabled", "uninstalled": "Uninstalled {{name}}", diff --git a/src/i18n/locales/en/launch.json b/src/i18n/locales/en/launch.json index 9dfcbe038..27468c7ed 100644 --- a/src/i18n/locales/en/launch.json +++ b/src/i18n/locales/en/launch.json @@ -50,8 +50,8 @@ "checkingTitle": "Checking for updates...", "downloadingTitle": "Downloading update...", "errorTitle": "Update check failed. Click to retry.", - "upToDateTitle": "Recordly {{version}} is up to date.", - "availableTitle": "Recordly {{version}} is available.", + "upToDateTitle": "VybeClip {{version}} is up to date.", + "availableTitle": "VybeClip {{version}} is available.", "availableGenericTitle": "An update is available." } }, @@ -67,10 +67,10 @@ "share": "Share" }, "permissions": { - "screenRecordingNeeded": "Recordly needs Screen Recording permission before you start. System Settings has been opened. After enabling it, quit and reopen Recordly.", - "screenRecordingMissing": "Screen Recording permission is still missing. System Settings has been opened again. Enable it, then quit and reopen Recordly before recording.", - "accessibilityNeeded": "Recordly also needs Accessibility permission for cursor tracking. System Settings has been opened. After enabling it, quit and reopen Recordly.", - "accessibilityMissing": "Accessibility permission is still missing. System Settings has been opened again. Enable it, then quit and reopen Recordly before recording.", + "screenRecordingNeeded": "VybeClip needs Screen Recording permission before you start. System Settings has been opened. After enabling it, quit and reopen VybeClip.", + "screenRecordingMissing": "Screen Recording permission is still missing. System Settings has been opened again. Enable it, then quit and reopen VybeClip before recording.", + "accessibilityNeeded": "VybeClip also needs Accessibility permission for cursor tracking. System Settings has been opened. After enabling it, quit and reopen VybeClip.", + "accessibilityMissing": "Accessibility permission is still missing. System Settings has been opened again. Enable it, then quit and reopen VybeClip before recording.", "selectSource": "Please select a source to record", "systemAudioUnavailable": "System audio is not available for this source. Recording will continue without system audio.", "microphoneDenied": "Microphone access was denied. Recording will continue without microphone audio.", diff --git a/src/i18n/locales/en/settings.json b/src/i18n/locales/en/settings.json index dc24cd97a..a67253403 100644 --- a/src/i18n/locales/en/settings.json +++ b/src/i18n/locales/en/settings.json @@ -85,7 +85,7 @@ "connectedZoomDuration": "Connected Zoom Duration", "connectedZoomEasing": "Connected Pan Curve", "zoomEasingOptions": { - "recordly": "Recordly", + "recordly": "VybeClip", "glide": "Glide", "smooth": "Smooth", "snappy": "Snappy", diff --git a/src/i18n/locales/es/common.json b/src/i18n/locales/es/common.json index f74c23773..6bcb7d04a 100644 --- a/src/i18n/locales/es/common.json +++ b/src/i18n/locales/es/common.json @@ -1,7 +1,7 @@ { "app": { - "name": "Recordly", - "editorTitle": "Editor de Recordly", + "name": "VybeClip", + "editorTitle": "Editor de VybeClip", "subtitle": "Grabación de pantalla y edición", "language": "Idioma", "manageRecordings": "Abrir carpeta de grabaciones" diff --git a/src/i18n/locales/es/editor.json b/src/i18n/locales/es/editor.json index 40373f1a6..ef204f93f 100644 --- a/src/i18n/locales/es/editor.json +++ b/src/i18n/locales/es/editor.json @@ -115,7 +115,7 @@ }, "nativeCaptureUnavailable": { "title": "Nada está roto, pero no podremos renderizar una superposición de cursor animada.", - "description": "Tu dispositivo no es compatible con la captura nativa. Esto puede deberse a varias razones que todavía no hemos identificado. Recordly seguirá funcionando, pero hará imposible el suavizado del cursor.", + "description": "Tu dispositivo no es compatible con la captura nativa. Esto puede deberse a varias razones que todavía no hemos identificado. VybeClip seguirá funcionando, pero hará imposible el suavizado del cursor.", "confirm": "Entendido" }, "exportStatus": { diff --git a/src/i18n/locales/es/launch.json b/src/i18n/locales/es/launch.json index a458844a2..5502c7433 100644 --- a/src/i18n/locales/es/launch.json +++ b/src/i18n/locales/es/launch.json @@ -50,8 +50,8 @@ "checkingTitle": "Buscando actualizaciones...", "downloadingTitle": "Descargando actualización...", "errorTitle": "La comprobación de actualizaciones falló. Haz clic para reintentar.", - "upToDateTitle": "Recordly {{version}} está actualizado.", - "availableTitle": "Recordly {{version}} está disponible.", + "upToDateTitle": "VybeClip {{version}} está actualizado.", + "availableTitle": "VybeClip {{version}} está disponible.", "availableGenericTitle": "Hay una actualización disponible." } }, @@ -67,10 +67,10 @@ "share": "Compartir" }, "permissions": { - "screenRecordingNeeded": "Recordly necesita permiso de grabación de pantalla antes de comenzar. Se ha abierto Configuración del sistema. Después de habilitarlo, cierra y vuelve a abrir Recordly.", - "screenRecordingMissing": "El permiso de grabación de pantalla aún falta. Se ha abierto Configuración del sistema nuevamente. Habilítalo, luego cierra y vuelve a abrir Recordly antes de grabar.", - "accessibilityNeeded": "Recordly también necesita permiso de accesibilidad para el seguimiento del cursor. Se ha abierto Configuración del sistema. Después de habilitarlo, cierra y vuelve a abrir Recordly.", - "accessibilityMissing": "El permiso de accesibilidad aún falta. Se ha abierto Configuración del sistema nuevamente. Habilítalo, luego cierra y vuelve a abrir Recordly antes de grabar.", + "screenRecordingNeeded": "VybeClip necesita permiso de grabación de pantalla antes de comenzar. Se ha abierto Configuración del sistema. Después de habilitarlo, cierra y vuelve a abrir VybeClip.", + "screenRecordingMissing": "El permiso de grabación de pantalla aún falta. Se ha abierto Configuración del sistema nuevamente. Habilítalo, luego cierra y vuelve a abrir VybeClip antes de grabar.", + "accessibilityNeeded": "VybeClip también necesita permiso de accesibilidad para el seguimiento del cursor. Se ha abierto Configuración del sistema. Después de habilitarlo, cierra y vuelve a abrir VybeClip.", + "accessibilityMissing": "El permiso de accesibilidad aún falta. Se ha abierto Configuración del sistema nuevamente. Habilítalo, luego cierra y vuelve a abrir VybeClip antes de grabar.", "selectSource": "Por favor selecciona una fuente para grabar", "systemAudioUnavailable": "El audio del sistema no está disponible para esta fuente. La grabación continuará sin audio del sistema.", "microphoneDenied": "Se denegó el acceso al micrófono. La grabación continuará sin audio del micrófono.", diff --git a/src/i18n/locales/es/settings.json b/src/i18n/locales/es/settings.json index 66a05e89c..1d3d36d01 100644 --- a/src/i18n/locales/es/settings.json +++ b/src/i18n/locales/es/settings.json @@ -66,7 +66,7 @@ "connectedZoomDuration": "Duración entre zooms conectados", "connectedZoomEasing": "Curva del paneo conectado", "zoomEasingOptions": { - "recordly": "Recordly", + "recordly": "VybeClip", "glide": "Glide", "smooth": "Suave", "snappy": "Rápida", diff --git a/src/i18n/locales/fr/common.json b/src/i18n/locales/fr/common.json index 38daaa826..37af554cf 100644 --- a/src/i18n/locales/fr/common.json +++ b/src/i18n/locales/fr/common.json @@ -1,7 +1,7 @@ { "app": { - "name": "Recordly", - "editorTitle": "Recordly Editor", + "name": "VybeClip", + "editorTitle": "VybeClip Editor", "subtitle": "Enregistrement et édition d’écran", "language": "Langue", "manageRecordings": "Ouvrir le dossier des enregistrements" diff --git a/src/i18n/locales/fr/editor.json b/src/i18n/locales/fr/editor.json index cb4b4d13f..58cb4c746 100644 --- a/src/i18n/locales/fr/editor.json +++ b/src/i18n/locales/fr/editor.json @@ -115,7 +115,7 @@ }, "nativeCaptureUnavailable": { "title": "Rien n'est cassé, mais nous ne pourrons pas afficher une superposition animée du curseur.", - "description": "Votre appareil ne prend pas en charge la capture native. Cela peut arriver pour plusieurs raisons que nous n'avons pas encore identifiées. Recordly continuera de fonctionner, mais le lissage du curseur sera impossible.", + "description": "Votre appareil ne prend pas en charge la capture native. Cela peut arriver pour plusieurs raisons que nous n'avons pas encore identifiées. VybeClip continuera de fonctionner, mais le lissage du curseur sera impossible.", "confirm": "D'accord" }, "exportStatus": { diff --git a/src/i18n/locales/fr/launch.json b/src/i18n/locales/fr/launch.json index df2e43b2b..b20ffe021 100644 --- a/src/i18n/locales/fr/launch.json +++ b/src/i18n/locales/fr/launch.json @@ -50,8 +50,8 @@ "checkingTitle": "Vérification des mises à jour...", "downloadingTitle": "Téléchargement de la mise à jour...", "errorTitle": "Échec de la vérification des mises à jour. Cliquez pour réessayer.", - "upToDateTitle": "Recordly {{version}} est à jour.", - "availableTitle": "Recordly {{version}} est disponible.", + "upToDateTitle": "VybeClip {{version}} est à jour.", + "availableTitle": "VybeClip {{version}} est disponible.", "availableGenericTitle": "Une mise à jour est disponible." } }, @@ -67,10 +67,10 @@ "share": "Partager" }, "permissions": { - "screenRecordingNeeded": "Recordly nécessite l’autorisation d’enregistrement d’écran avant de commencer. Les paramètres système ont été ouverts. Après l’avoir activée, quittez et relancez Recordly.", - "screenRecordingMissing": "L’autorisation d’enregistrement d’écran est toujours manquante. Les paramètres système ont été ouverts à nouveau. Activez-la, puis quittez et relancez Recordly avant d’enregistrer.", - "accessibilityNeeded": "Recordly nécessite également l’autorisation d’accessibilité pour le suivi du curseur. Les paramètres système ont été ouverts. Après l’avoir activée, quittez et relancez Recordly.", - "accessibilityMissing": "L’autorisation d’accessibilité est toujours manquante. Les paramètres système ont été ouverts à nouveau. Activez-la, puis quittez et relancez Recordly avant d’enregistrer.", + "screenRecordingNeeded": "VybeClip nécessite l’autorisation d’enregistrement d’écran avant de commencer. Les paramètres système ont été ouverts. Après l’avoir activée, quittez et relancez VybeClip.", + "screenRecordingMissing": "L’autorisation d’enregistrement d’écran est toujours manquante. Les paramètres système ont été ouverts à nouveau. Activez-la, puis quittez et relancez VybeClip avant d’enregistrer.", + "accessibilityNeeded": "VybeClip nécessite également l’autorisation d’accessibilité pour le suivi du curseur. Les paramètres système ont été ouverts. Après l’avoir activée, quittez et relancez VybeClip.", + "accessibilityMissing": "L’autorisation d’accessibilité est toujours manquante. Les paramètres système ont été ouverts à nouveau. Activez-la, puis quittez et relancez VybeClip avant d’enregistrer.", "selectSource": "Veuillez sélectionner une source à enregistrer", "systemAudioUnavailable": "L’audio système n’est pas disponible pour cette source. L’enregistrement continuera sans audio système.", "microphoneDenied": "L’accès au microphone a été refusé. L’enregistrement continuera sans audio du microphone.", diff --git a/src/i18n/locales/fr/settings.json b/src/i18n/locales/fr/settings.json index 200ecaa02..4f4369106 100644 --- a/src/i18n/locales/fr/settings.json +++ b/src/i18n/locales/fr/settings.json @@ -66,7 +66,7 @@ "connectedZoomDuration": "Durée du zoom connecté", "connectedZoomEasing": "Courbe du pan connecté", "zoomEasingOptions": { - "recordly": "Recordly", + "recordly": "VybeClip", "glide": "Glide", "smooth": "Lisse", "snappy": "Rapide", diff --git a/src/i18n/locales/it/common.json b/src/i18n/locales/it/common.json index 9c270d7e8..a09c6ea01 100644 --- a/src/i18n/locales/it/common.json +++ b/src/i18n/locales/it/common.json @@ -1,7 +1,7 @@ { "app": { - "name": "Recordly", - "editorTitle": "Editor Recordly", + "name": "VybeClip", + "editorTitle": "Editor VybeClip", "subtitle": "Registrazione e modifica dello schermo", "language": "Lingua", "manageRecordings": "Apri cartella registrazioni" diff --git a/src/i18n/locales/it/editor.json b/src/i18n/locales/it/editor.json index b23d247da..44c5a785e 100644 --- a/src/i18n/locales/it/editor.json +++ b/src/i18n/locales/it/editor.json @@ -115,7 +115,7 @@ }, "nativeCaptureUnavailable": { "title": "Niente è rotto, ma non sarà possibile renderizzare un overlay del cursore animato.", - "description": "Il tuo dispositivo non supporta la cattura nativa. Le cause possono essere varie e non ancora identificate. Recordly funziona comunque, ma non è possibile applicare lo smoothing del cursore.", + "description": "Il tuo dispositivo non supporta la cattura nativa. Le cause possono essere varie e non ancora identificate. VybeClip funziona comunque, ma non è possibile applicare lo smoothing del cursore.", "confirm": "Ok" }, "exportStatus": { diff --git a/src/i18n/locales/it/launch.json b/src/i18n/locales/it/launch.json index c27abc3ad..2fe3165f3 100644 --- a/src/i18n/locales/it/launch.json +++ b/src/i18n/locales/it/launch.json @@ -50,8 +50,8 @@ "checkingTitle": "Verifica aggiornamenti in corso...", "downloadingTitle": "Download dell'aggiornamento in corso...", "errorTitle": "Verifica aggiornamenti non riuscita. Clicca per riprovare.", - "upToDateTitle": "Recordly {{version}} è aggiornato.", - "availableTitle": "Recordly {{version}} è disponibile.", + "upToDateTitle": "VybeClip {{version}} è aggiornato.", + "availableTitle": "VybeClip {{version}} è disponibile.", "availableGenericTitle": "È disponibile un aggiornamento." } }, @@ -67,10 +67,10 @@ "share": "Condividi" }, "permissions": { - "screenRecordingNeeded": "Recordly necessita del permesso di Registrazione schermo prima di iniziare. Sono state aperte le Impostazioni di sistema. Dopo averlo abilitato, esci e riapri Recordly.", - "screenRecordingMissing": "Manca ancora il permesso di Registrazione schermo. Le Impostazioni di sistema sono state riaperte. Abilitalo, poi esci e riapri Recordly prima di registrare.", - "accessibilityNeeded": "Recordly necessita anche del permesso di Accessibilità per il tracciamento del cursore. Sono state aperte le Impostazioni di sistema. Dopo averlo abilitato, esci e riapri Recordly.", - "accessibilityMissing": "Manca ancora il permesso di Accessibilità. Le Impostazioni di sistema sono state riaperte. Abilitalo, poi esci e riapri Recordly prima di registrare.", + "screenRecordingNeeded": "VybeClip necessita del permesso di Registrazione schermo prima di iniziare. Sono state aperte le Impostazioni di sistema. Dopo averlo abilitato, esci e riapri VybeClip.", + "screenRecordingMissing": "Manca ancora il permesso di Registrazione schermo. Le Impostazioni di sistema sono state riaperte. Abilitalo, poi esci e riapri VybeClip prima di registrare.", + "accessibilityNeeded": "VybeClip necessita anche del permesso di Accessibilità per il tracciamento del cursore. Sono state aperte le Impostazioni di sistema. Dopo averlo abilitato, esci e riapri VybeClip.", + "accessibilityMissing": "Manca ancora il permesso di Accessibilità. Le Impostazioni di sistema sono state riaperte. Abilitalo, poi esci e riapri VybeClip prima di registrare.", "selectSource": "Seleziona una sorgente da registrare", "systemAudioUnavailable": "L'audio di sistema non è disponibile per questa sorgente. La registrazione continuerà senza audio di sistema.", "microphoneDenied": "L'accesso al microfono è stato negato. La registrazione continuerà senza audio del microfono.", diff --git a/src/i18n/locales/it/settings.json b/src/i18n/locales/it/settings.json index d9b9a1a18..0263a93ce 100644 --- a/src/i18n/locales/it/settings.json +++ b/src/i18n/locales/it/settings.json @@ -85,7 +85,7 @@ "connectedZoomDuration": "Durata zoom connessi", "connectedZoomEasing": "Curva pan connesso", "zoomEasingOptions": { - "recordly": "Recordly", + "recordly": "VybeClip", "glide": "Glide", "smooth": "Fluido", "snappy": "Rapido", diff --git a/src/i18n/locales/ko/common.json b/src/i18n/locales/ko/common.json index f036318e0..f6644e136 100644 --- a/src/i18n/locales/ko/common.json +++ b/src/i18n/locales/ko/common.json @@ -1,7 +1,7 @@ { "app": { - "name": "Recordly", - "editorTitle": "Recordly 편집기", + "name": "VybeClip", + "editorTitle": "VybeClip 편집기", "subtitle": "화면 녹화 및 편집", "language": "언어", "manageRecordings": "녹화 폴더 열기" diff --git a/src/i18n/locales/ko/editor.json b/src/i18n/locales/ko/editor.json index 49678dee1..affcc6e63 100644 --- a/src/i18n/locales/ko/editor.json +++ b/src/i18n/locales/ko/editor.json @@ -116,7 +116,7 @@ }, "nativeCaptureUnavailable": { "title": "문제가 생긴 것은 아니지만, 애니메이션 커서 오버레이를 렌더링할 수 없습니다.", - "description": "이 장치는 네이티브 캡처를 지원하지 않습니다. 아직 확인하지 못한 여러 이유가 있을 수 있습니다. Recordly는 계속 작동하지만 커서 스무딩은 사용할 수 없습니다.", + "description": "이 장치는 네이티브 캡처를 지원하지 않습니다. 아직 확인하지 못한 여러 이유가 있을 수 있습니다. VybeClip는 계속 작동하지만 커서 스무딩은 사용할 수 없습니다.", "confirm": "확인" }, "exportStatus": { diff --git a/src/i18n/locales/ko/launch.json b/src/i18n/locales/ko/launch.json index 345000399..2c47bb0a0 100644 --- a/src/i18n/locales/ko/launch.json +++ b/src/i18n/locales/ko/launch.json @@ -50,8 +50,8 @@ "checkingTitle": "업데이트를 확인하는 중...", "downloadingTitle": "업데이트를 다운로드하는 중...", "errorTitle": "업데이트 확인에 실패했습니다. 클릭하여 다시 시도하세요.", - "upToDateTitle": "Recordly {{version}}은(는) 최신 버전입니다.", - "availableTitle": "Recordly {{version}}을(를) 사용할 수 있습니다.", + "upToDateTitle": "VybeClip {{version}}은(는) 최신 버전입니다.", + "availableTitle": "VybeClip {{version}}을(를) 사용할 수 있습니다.", "availableGenericTitle": "업데이트를 사용할 수 있습니다." } }, @@ -67,10 +67,10 @@ "share": "공유" }, "permissions": { - "screenRecordingNeeded": "녹화를 시작하기 전에 Recordly에 화면 녹화 권한이 필요합니다. 시스템 설정을 열었습니다. 권한을 허용한 뒤 Recordly를 종료하고 다시 실행해 주세요.", - "screenRecordingMissing": "화면 녹화 권한이 아직 없습니다. 시스템 설정을 다시 열었습니다. 권한을 허용한 뒤 Recordly를 종료하고 다시 실행한 후 녹화를 시작해 주세요.", - "accessibilityNeeded": "커서 추적을 위해 Recordly에 손쉬운 사용 권한도 필요합니다. 시스템 설정을 열었습니다. 권한을 허용한 뒤 Recordly를 종료하고 다시 실행해 주세요.", - "accessibilityMissing": "손쉬운 사용 권한이 아직 없습니다. 시스템 설정을 다시 열었습니다. 권한을 허용한 뒤 Recordly를 종료하고 다시 실행한 후 녹화를 시작해 주세요.", + "screenRecordingNeeded": "녹화를 시작하기 전에 VybeClip에 화면 녹화 권한이 필요합니다. 시스템 설정을 열었습니다. 권한을 허용한 뒤 VybeClip를 종료하고 다시 실행해 주세요.", + "screenRecordingMissing": "화면 녹화 권한이 아직 없습니다. 시스템 설정을 다시 열었습니다. 권한을 허용한 뒤 VybeClip를 종료하고 다시 실행한 후 녹화를 시작해 주세요.", + "accessibilityNeeded": "커서 추적을 위해 VybeClip에 손쉬운 사용 권한도 필요합니다. 시스템 설정을 열었습니다. 권한을 허용한 뒤 VybeClip를 종료하고 다시 실행해 주세요.", + "accessibilityMissing": "손쉬운 사용 권한이 아직 없습니다. 시스템 설정을 다시 열었습니다. 권한을 허용한 뒤 VybeClip를 종료하고 다시 실행한 후 녹화를 시작해 주세요.", "selectSource": "녹화할 소스를 선택해 주세요", "systemAudioUnavailable": "이 소스에서는 시스템 오디오를 사용할 수 없습니다. 시스템 오디오 없이 녹화를 계속합니다.", "microphoneDenied": "마이크 접근이 거부되었습니다. 마이크 오디오 없이 녹화를 계속합니다.", diff --git a/src/i18n/locales/ko/settings.json b/src/i18n/locales/ko/settings.json index c528a98fb..326fcba90 100644 --- a/src/i18n/locales/ko/settings.json +++ b/src/i18n/locales/ko/settings.json @@ -66,7 +66,7 @@ "connectedZoomDuration": "연결 확대 시간", "connectedZoomEasing": "연결 이동 커브", "zoomEasingOptions": { - "recordly": "Recordly", + "recordly": "VybeClip", "glide": "Glide", "smooth": "Smooth", "snappy": "Snappy", diff --git a/src/i18n/locales/nl/common.json b/src/i18n/locales/nl/common.json index 0e7c2f33f..2bcb133c5 100644 --- a/src/i18n/locales/nl/common.json +++ b/src/i18n/locales/nl/common.json @@ -1,7 +1,7 @@ { "app": { - "name": "Recordly", - "editorTitle": "Recordly Editor", + "name": "VybeClip", + "editorTitle": "VybeClip Editor", "subtitle": "Schermopname en bewerking", "language": "Taal", "manageRecordings": "Opnamemap openen" diff --git a/src/i18n/locales/nl/editor.json b/src/i18n/locales/nl/editor.json index d58b9e525..8a638dc5f 100644 --- a/src/i18n/locales/nl/editor.json +++ b/src/i18n/locales/nl/editor.json @@ -116,7 +116,7 @@ }, "nativeCaptureUnavailable": { "title": "Er is niets kapot, maar we kunnen geen geanimeerde cursor-overlay renderen.", - "description": "Je apparaat ondersteunt geen native capture. Dit kan verschillende oorzaken hebben die we nog niet hebben achterhaald. Recordly blijft werken, maar cursor smoothing is dan niet mogelijk.", + "description": "Je apparaat ondersteunt geen native capture. Dit kan verschillende oorzaken hebben die we nog niet hebben achterhaald. VybeClip blijft werken, maar cursor smoothing is dan niet mogelijk.", "confirm": "Oké" }, "exportStatus": { diff --git a/src/i18n/locales/nl/launch.json b/src/i18n/locales/nl/launch.json index d3d5870c9..867a46116 100644 --- a/src/i18n/locales/nl/launch.json +++ b/src/i18n/locales/nl/launch.json @@ -50,8 +50,8 @@ "checkingTitle": "Controleren op updates...", "downloadingTitle": "Update downloaden...", "errorTitle": "Updatecontrole mislukt. Klik om opnieuw te proberen.", - "upToDateTitle": "Recordly {{version}} is up-to-date.", - "availableTitle": "Recordly {{version}} is beschikbaar.", + "upToDateTitle": "VybeClip {{version}} is up-to-date.", + "availableTitle": "VybeClip {{version}} is beschikbaar.", "availableGenericTitle": "Er is een update beschikbaar." } }, @@ -67,10 +67,10 @@ "share": "Delen" }, "permissions": { - "screenRecordingNeeded": "Recordly heeft toestemming voor schermopname nodig voordat je begint. Systeeminstellingen zijn geopend. Schakel het in, sluit Recordly en open het opnieuw.", - "screenRecordingMissing": "Toestemming voor schermopname ontbreekt nog. Systeeminstellingen zijn opnieuw geopend. Schakel het in, sluit Recordly en open het opnieuw voordat je opneemt.", - "accessibilityNeeded": "Recordly heeft ook toegankelijkheidstoestemming nodig voor cursortracking. Systeeminstellingen zijn geopend. Schakel het in, sluit Recordly en open het opnieuw.", - "accessibilityMissing": "Toegankelijkheidstoestemming ontbreekt nog. Systeeminstellingen zijn opnieuw geopend. Schakel het in, sluit Recordly en open het opnieuw voordat je opneemt.", + "screenRecordingNeeded": "VybeClip heeft toestemming voor schermopname nodig voordat je begint. Systeeminstellingen zijn geopend. Schakel het in, sluit VybeClip en open het opnieuw.", + "screenRecordingMissing": "Toestemming voor schermopname ontbreekt nog. Systeeminstellingen zijn opnieuw geopend. Schakel het in, sluit VybeClip en open het opnieuw voordat je opneemt.", + "accessibilityNeeded": "VybeClip heeft ook toegankelijkheidstoestemming nodig voor cursortracking. Systeeminstellingen zijn geopend. Schakel het in, sluit VybeClip en open het opnieuw.", + "accessibilityMissing": "Toegankelijkheidstoestemming ontbreekt nog. Systeeminstellingen zijn opnieuw geopend. Schakel het in, sluit VybeClip en open het opnieuw voordat je opneemt.", "selectSource": "Selecteer een bron om op te nemen", "systemAudioUnavailable": "Systeemaudio is niet beschikbaar voor deze bron. De opname gaat verder zonder systeemaudio.", "microphoneDenied": "Microfoontoegang is geweigerd. De opname gaat verder zonder microfoonaudio.", diff --git a/src/i18n/locales/nl/settings.json b/src/i18n/locales/nl/settings.json index d88e4278c..977c73ce8 100644 --- a/src/i18n/locales/nl/settings.json +++ b/src/i18n/locales/nl/settings.json @@ -66,7 +66,7 @@ "connectedZoomDuration": "Verbonden zoomduur", "connectedZoomEasing": "Verbonden pancurve", "zoomEasingOptions": { - "recordly": "Recordly", + "recordly": "VybeClip", "glide": "Glijden", "smooth": "Vloeiend", "snappy": "Pittig", diff --git a/src/i18n/locales/pt-BR/common.json b/src/i18n/locales/pt-BR/common.json index 97a2ea1d9..40e2862dd 100644 --- a/src/i18n/locales/pt-BR/common.json +++ b/src/i18n/locales/pt-BR/common.json @@ -1,7 +1,7 @@ { "app": { - "name": "Recordly", - "editorTitle": "Editor do Recordly", + "name": "VybeClip", + "editorTitle": "Editor do VybeClip", "subtitle": "Gravação e edição de tela", "language": "Idioma", "manageRecordings": "Abrir pasta de gravações" diff --git a/src/i18n/locales/pt-BR/editor.json b/src/i18n/locales/pt-BR/editor.json index 702939fd1..9eb2c1b23 100644 --- a/src/i18n/locales/pt-BR/editor.json +++ b/src/i18n/locales/pt-BR/editor.json @@ -115,7 +115,7 @@ }, "nativeCaptureUnavailable": { "title": "Nada está quebrado, mas não poderemos renderizar uma sobreposição animada do cursor.", - "description": "Seu dispositivo não oferece suporte à captura nativa. Isso pode acontecer por vários motivos que ainda não identificamos. O Recordly continuará funcionando, mas a suavização do cursor ficará indisponível.", + "description": "Seu dispositivo não oferece suporte à captura nativa. Isso pode acontecer por vários motivos que ainda não identificamos. O VybeClip continuará funcionando, mas a suavização do cursor ficará indisponível.", "confirm": "Entendi" }, "exportStatus": { diff --git a/src/i18n/locales/pt-BR/launch.json b/src/i18n/locales/pt-BR/launch.json index 8d19ac7db..0734dd83b 100644 --- a/src/i18n/locales/pt-BR/launch.json +++ b/src/i18n/locales/pt-BR/launch.json @@ -50,8 +50,8 @@ "checkingTitle": "Verificando atualizações...", "downloadingTitle": "Baixando atualização...", "errorTitle": "Falha ao verificar atualizações. Clique para tentar novamente.", - "upToDateTitle": "Recordly {{version}} está atualizado.", - "availableTitle": "Recordly {{version}} está disponível.", + "upToDateTitle": "VybeClip {{version}} está atualizado.", + "availableTitle": "VybeClip {{version}} está disponível.", "availableGenericTitle": "Há uma atualização disponível." } }, @@ -67,10 +67,10 @@ "share": "Compartilhar" }, "permissions": { - "screenRecordingNeeded": "O Recordly precisa da permissão de Gravação de Tela antes de iniciar. Ajustes do Sistema foram abertos. Depois de ativar, feche e reabra o Recordly.", - "screenRecordingMissing": "A permissão de Gravação de Tela ainda está ausente. Ajustes do Sistema foram abertos novamente. Ative e depois feche e reabra o Recordly antes de gravar.", - "accessibilityNeeded": "O Recordly também precisa da permissão de Acessibilidade para rastrear o cursor. Ajustes do Sistema foram abertos. Depois de ativar, feche e reabra o Recordly.", - "accessibilityMissing": "A permissão de Acessibilidade ainda está ausente. Ajustes do Sistema foram abertos novamente. Ative e depois feche e reabra o Recordly antes de gravar.", + "screenRecordingNeeded": "O VybeClip precisa da permissão de Gravação de Tela antes de iniciar. Ajustes do Sistema foram abertos. Depois de ativar, feche e reabra o VybeClip.", + "screenRecordingMissing": "A permissão de Gravação de Tela ainda está ausente. Ajustes do Sistema foram abertos novamente. Ative e depois feche e reabra o VybeClip antes de gravar.", + "accessibilityNeeded": "O VybeClip também precisa da permissão de Acessibilidade para rastrear o cursor. Ajustes do Sistema foram abertos. Depois de ativar, feche e reabra o VybeClip.", + "accessibilityMissing": "A permissão de Acessibilidade ainda está ausente. Ajustes do Sistema foram abertos novamente. Ative e depois feche e reabra o VybeClip antes de gravar.", "selectSource": "Selecione uma fonte para gravar", "systemAudioUnavailable": "O áudio do sistema não está disponível para esta fonte. A gravação continuará sem áudio do sistema.", "microphoneDenied": "O acesso ao microfone foi negado. A gravação continuará sem áudio do microfone.", diff --git a/src/i18n/locales/pt-BR/settings.json b/src/i18n/locales/pt-BR/settings.json index a34e6e4d2..594ae0e9c 100644 --- a/src/i18n/locales/pt-BR/settings.json +++ b/src/i18n/locales/pt-BR/settings.json @@ -66,7 +66,7 @@ "connectedZoomDuration": "Duração do zoom conectado", "connectedZoomEasing": "Curva de pan conectado", "zoomEasingOptions": { - "recordly": "Recordly", + "recordly": "VybeClip", "glide": "Glide", "smooth": "Smooth", "snappy": "Snappy", diff --git a/src/i18n/locales/ru/common.json b/src/i18n/locales/ru/common.json index 3946d72b9..aa24faf84 100644 --- a/src/i18n/locales/ru/common.json +++ b/src/i18n/locales/ru/common.json @@ -1,7 +1,7 @@ { "app": { - "name": "Recordly", - "editorTitle": "Recordly Editor", + "name": "VybeClip", + "editorTitle": "VybeClip Editor", "subtitle": "Запись экрана и редактирование видео", "language": "Язык", "manageRecordings": "Открыть папку с записями" diff --git a/src/i18n/locales/ru/launch.json b/src/i18n/locales/ru/launch.json index 65bd57b8c..452ada2f9 100644 --- a/src/i18n/locales/ru/launch.json +++ b/src/i18n/locales/ru/launch.json @@ -50,8 +50,8 @@ "checkingTitle": "Поиск обновлений...", "downloadingTitle": "Загрузка обновления...", "errorTitle": "Ошибка при проверке обновления. Нажмите, чтобы повторить", - "upToDateTitle": "У вас актуальная версия Recordly {{version}}.", - "availableTitle": "Доступна новая версия Recordly {{version}}.", + "upToDateTitle": "У вас актуальная версия VybeClip {{version}}.", + "availableTitle": "Доступна новая версия VybeClip {{version}}.", "availableGenericTitle": "Доступно обновление." } }, @@ -67,10 +67,10 @@ "share": "Поделиться" }, "permissions": { - "screenRecordingNeeded": "Recordly нужно разрешение на запись экрана. Мы открыли настройки – включите доступ и перезапустите приложение.", - "screenRecordingMissing": "Доступа к записи экрана всё ещё нет. Мы снова открыли настройки. Разрешите доступ и перезапустите Recordly.", - "accessibilityNeeded": "Разрешите Recordly использовать универсальный доступ, чтобы отслеживать курсор. Мы открыли настройки – включите его и перезапустите приложение.", - "accessibilityMissing": "Разрешения для универсального доступа всё ещё нет. Включите его в настройках и перезапустите Recordly.", + "screenRecordingNeeded": "VybeClip нужно разрешение на запись экрана. Мы открыли настройки – включите доступ и перезапустите приложение.", + "screenRecordingMissing": "Доступа к записи экрана всё ещё нет. Мы снова открыли настройки. Разрешите доступ и перезапустите VybeClip.", + "accessibilityNeeded": "Разрешите VybeClip использовать универсальный доступ, чтобы отслеживать курсор. Мы открыли настройки – включите его и перезапустите приложение.", + "accessibilityMissing": "Разрешения для универсального доступа всё ещё нет. Включите его в настройках и перезапустите VybeClip.", "selectSource": "Выберите источник записи", "systemAudioUnavailable": "Системные звуки недоступны для этого источника. Запись продолжится без их захвата.", "microphoneDenied": "Нет доступа к микрофону. Запись продолжится без вашего голоса.", diff --git a/src/i18n/locales/ru/settings.json b/src/i18n/locales/ru/settings.json index 687f8383b..76579ec3b 100644 --- a/src/i18n/locales/ru/settings.json +++ b/src/i18n/locales/ru/settings.json @@ -85,7 +85,7 @@ "connectedZoomDuration": "Длительность интервала", "connectedZoomEasing": "Кривая панорамирования", "zoomEasingOptions": { - "recordly": "Recordly", + "recordly": "VybeClip", "glide": "Плавно", "smooth": "Мягко", "snappy": "Резко", @@ -216,4 +216,4 @@ "mixedLabel": "Источник", "deleteRegion": "Удалить аудио" } -} \ No newline at end of file +} diff --git a/src/i18n/locales/zh-CN/common.json b/src/i18n/locales/zh-CN/common.json index b35ca3d8c..9cec83f09 100644 --- a/src/i18n/locales/zh-CN/common.json +++ b/src/i18n/locales/zh-CN/common.json @@ -1,7 +1,7 @@ { "app": { - "name": "Recordly", - "editorTitle": "Recordly 编辑器", + "name": "VybeClip", + "editorTitle": "VybeClip 编辑器", "subtitle": "屏幕录制与编辑", "language": "语言", "manageRecordings": "打开录制文件夹" diff --git a/src/i18n/locales/zh-CN/editor.json b/src/i18n/locales/zh-CN/editor.json index 1bf3a6084..309ee044b 100644 --- a/src/i18n/locales/zh-CN/editor.json +++ b/src/i18n/locales/zh-CN/editor.json @@ -115,7 +115,7 @@ }, "nativeCaptureUnavailable": { "title": "没有出错,但我们无法渲染动画光标叠加层。", - "description": "你的设备不支持原生捕获。这可能是由我们尚未确定的多种原因造成的。Recordly 仍可继续运行,但无法进行光标平滑处理。", + "description": "你的设备不支持原生捕获。这可能是由我们尚未确定的多种原因造成的。VybeClip 仍可继续运行,但无法进行光标平滑处理。", "confirm": "好的" }, "exportStatus": { diff --git a/src/i18n/locales/zh-CN/launch.json b/src/i18n/locales/zh-CN/launch.json index 164c02afe..7ebc5a2f8 100644 --- a/src/i18n/locales/zh-CN/launch.json +++ b/src/i18n/locales/zh-CN/launch.json @@ -50,8 +50,8 @@ "checkingTitle": "正在检查更新...", "downloadingTitle": "正在下载更新...", "errorTitle": "检查更新失败。点击重试。", - "upToDateTitle": "Recordly {{version}} 已是最新版本。", - "availableTitle": "Recordly {{version}} 可更新。", + "upToDateTitle": "VybeClip {{version}} 已是最新版本。", + "availableTitle": "VybeClip {{version}} 可更新。", "availableGenericTitle": "有可用更新。" } }, @@ -67,10 +67,10 @@ "share": "共享" }, "permissions": { - "screenRecordingNeeded": "Recordly 需要屏幕录制权限才能开始。系统设置已打开。启用后请退出并重新打开 Recordly。", - "screenRecordingMissing": "屏幕录制权限仍然缺失。系统设置已再次打开。请启用权限,然后退出并重新打开 Recordly。", - "accessibilityNeeded": "Recordly 还需要辅助功能权限以跟踪光标。系统设置已打开。启用后请退出并重新打开 Recordly。", - "accessibilityMissing": "辅助功能权限仍然缺失。系统设置已再次打开。请启用权限,然后退出并重新打开 Recordly。", + "screenRecordingNeeded": "VybeClip 需要屏幕录制权限才能开始。系统设置已打开。启用后请退出并重新打开 VybeClip。", + "screenRecordingMissing": "屏幕录制权限仍然缺失。系统设置已再次打开。请启用权限,然后退出并重新打开 VybeClip。", + "accessibilityNeeded": "VybeClip 还需要辅助功能权限以跟踪光标。系统设置已打开。启用后请退出并重新打开 VybeClip。", + "accessibilityMissing": "辅助功能权限仍然缺失。系统设置已再次打开。请启用权限,然后退出并重新打开 VybeClip。", "selectSource": "请选择要录制的源", "systemAudioUnavailable": "此源不支持系统音频。将继续录制但不包含系统音频。", "microphoneDenied": "麦克风访问被拒绝。将继续录制但不包含麦克风音频。", diff --git a/src/i18n/locales/zh-CN/settings.json b/src/i18n/locales/zh-CN/settings.json index a68d2a04f..0cc5d3ab6 100644 --- a/src/i18n/locales/zh-CN/settings.json +++ b/src/i18n/locales/zh-CN/settings.json @@ -80,7 +80,7 @@ "connectedZoomDuration": "连接缩放时长", "connectedZoomEasing": "连接平移曲线", "zoomEasingOptions": { - "recordly": "Recordly", + "recordly": "VybeClip", "glide": "滑行", "smooth": "平滑", "snappy": "利落", diff --git a/src/i18n/locales/zh-TW/common.json b/src/i18n/locales/zh-TW/common.json index c5fa86d10..db5080bc8 100644 --- a/src/i18n/locales/zh-TW/common.json +++ b/src/i18n/locales/zh-TW/common.json @@ -1,7 +1,7 @@ { "app": { - "name": "Recordly", - "editorTitle": "Recordly 編輯器", + "name": "VybeClip", + "editorTitle": "VybeClip 編輯器", "subtitle": "螢幕錄製與編輯", "language": "語言", "manageRecordings": "打開錄製影像文件夾" diff --git a/src/i18n/locales/zh-TW/editor.json b/src/i18n/locales/zh-TW/editor.json index 6f2b0f823..049768c5a 100644 --- a/src/i18n/locales/zh-TW/editor.json +++ b/src/i18n/locales/zh-TW/editor.json @@ -115,7 +115,7 @@ }, "nativeCaptureUnavailable": { "title": "沒有出錯,但我們無法轉譯動畫游標覆蓋層。", - "description": "你的裝置不支援原生擷取。這可能是由我們尚未釐清的多種原因造成的。Recordly 仍可繼續運作,但無法進行游標平滑處理。", + "description": "你的裝置不支援原生擷取。這可能是由我們尚未釐清的多種原因造成的。VybeClip 仍可繼續運作,但無法進行游標平滑處理。", "confirm": "好的" }, "exportStatus": { diff --git a/src/i18n/locales/zh-TW/launch.json b/src/i18n/locales/zh-TW/launch.json index 12ec494d0..4baaff259 100644 --- a/src/i18n/locales/zh-TW/launch.json +++ b/src/i18n/locales/zh-TW/launch.json @@ -50,8 +50,8 @@ "checkingTitle": "正在檢查更新...", "downloadingTitle": "正在下載更新...", "errorTitle": "檢查更新失敗,點一下重試。", - "upToDateTitle": "Recordly {{version}} 已是最新版本。", - "availableTitle": "Recordly {{version}} 已推出新版本。", + "upToDateTitle": "VybeClip {{version}} 已是最新版本。", + "availableTitle": "VybeClip {{version}} 已推出新版本。", "availableGenericTitle": "有可用更新。" } }, @@ -67,10 +67,10 @@ "share": "分享" }, "permissions": { - "screenRecordingNeeded": "Recordly 需要「螢幕錄製」權限才能開始。已開啟系統設定。啟用後,請結束並重新開啟 Recordly。", - "screenRecordingMissing": "仍未取得「螢幕錄製」權限。已再次開啟系統設定。請先啟用,再結束並重新開啟 Recordly 後再錄製。", - "accessibilityNeeded": "Recordly 也需要「輔助使用」權限以追蹤游標。已開啟系統設定。啟用後,請結束並重新開啟 Recordly。", - "accessibilityMissing": "仍未取得「輔助使用」權限。已再次開啟系統設定。請先啟用,再結束並重新開啟 Recordly 後再錄製。", + "screenRecordingNeeded": "VybeClip 需要「螢幕錄製」權限才能開始。已開啟系統設定。啟用後,請結束並重新開啟 VybeClip。", + "screenRecordingMissing": "仍未取得「螢幕錄製」權限。已再次開啟系統設定。請先啟用,再結束並重新開啟 VybeClip 後再錄製。", + "accessibilityNeeded": "VybeClip 也需要「輔助使用」權限以追蹤游標。已開啟系統設定。啟用後,請結束並重新開啟 VybeClip。", + "accessibilityMissing": "仍未取得「輔助使用」權限。已再次開啟系統設定。請先啟用,再結束並重新開啟 VybeClip 後再錄製。", "selectSource": "請選擇要錄製的來源", "systemAudioUnavailable": "此來源不支援系統音訊,將在沒有系統音訊的情況下繼續錄製。", "microphoneDenied": "麥克風存取遭拒,將在沒有麥克風音訊的情況下繼續錄製。", diff --git a/src/i18n/locales/zh-TW/settings.json b/src/i18n/locales/zh-TW/settings.json index db0ea7e04..c156b3178 100644 --- a/src/i18n/locales/zh-TW/settings.json +++ b/src/i18n/locales/zh-TW/settings.json @@ -66,7 +66,7 @@ "connectedZoomDuration": "連接縮放時間", "connectedZoomEasing": "連接平移曲線", "zoomEasingOptions": { - "recordly": "Recordly", + "recordly": "VybeClip", "glide": "滑行", "smooth": "平滑", "snappy": "俐落", diff --git a/src/index.css b/src/index.css index a5ae2368b..650135316 100644 --- a/src/index.css +++ b/src/index.css @@ -30,27 +30,27 @@ :root { --app-font-sans: "SF Pro Display", "SF Pro Text", Helvetica, sans-serif; - --brand-accent: #2563eb; - --brand-accent-rgb: 37, 99, 235; + --brand-accent: #b6410f; + --brand-accent-rgb: 182, 65, 15; --background: 0 0% 100%; --foreground: 20 14.3% 4.1%; --card: 0 0% 100%; --card-foreground: 20 14.3% 4.1%; --popover: 0 0% 100%; --popover-foreground: 20 14.3% 4.1%; - --primary: 221 83% 53%; + --primary: 19 85% 39%; --primary-foreground: 210 40% 98%; --secondary: 60 4.8% 95.9%; --secondary-foreground: 24 9.8% 10%; --muted: 60 4.8% 95.9%; --muted-foreground: 25 5.3% 44.7%; - --accent: 214 100% 97%; - --accent-foreground: 221 83% 40%; + --accent: 21 51% 91%; + --accent-foreground: 19 85% 31%; --destructive: 0 84.2% 60.2%; --destructive-foreground: 60 9.1% 97.8%; --border: 20 5.9% 90%; --input: 20 5.9% 90%; - --ring: 221 83% 53%; + --ring: 19 85% 39%; --chart-1: 12 76% 61%; --chart-2: 173 58% 39%; --chart-3: 197 37% 24%; @@ -59,11 +59,11 @@ --radius: 0.5rem; /* Editor surface colors — light mode */ - --editor-bg: 0 0% 100%; - --editor-header: 0 0% 97%; - --editor-panel: 0 0% 98%; - --editor-surface: 0 0% 97%; - --editor-surface-alt: 0 0% 95%; + --editor-bg: 32 28% 97%; + --editor-header: 21 39% 94%; + --editor-panel: 21 34% 96%; + --editor-surface: 21 28% 94%; + --editor-surface-alt: 21 22% 91%; --editor-dialog: 0 0% 100%; --editor-dialog-alt: 0 0% 98%; --editor-timeline: 220 13% 91%; @@ -72,53 +72,53 @@ /* Slider track colors — light mode */ --slider-track: 0 0% 0% / 0.12; - --slider-thumb: 0 0% 10%; + --slider-thumb: 19 85% 39%; --slider-thumb-shadow: 0 0% 0% / 0.22; --slider-glow: 0 0% 0% / 0.35; } .dark { - --background: 20 14.3% 4.1%; - --foreground: 60 9.1% 97.8%; - --card: 20 14.3% 4.1%; - --card-foreground: 60 9.1% 97.8%; - --popover: 20 14.3% 4.1%; - --popover-foreground: 60 9.1% 97.8%; - --primary: 221 83% 53%; + --background: 200 33% 3%; + --foreground: 240 10% 80%; + --card: 202 35% 6%; + --card-foreground: 240 10% 80%; + --popover: 202 35% 6%; + --popover-foreground: 240 10% 80%; + --primary: 19 85% 39%; --primary-foreground: 210 40% 98%; - --secondary: 12 6.5% 15.1%; - --secondary-foreground: 60 9.1% 97.8%; - --muted: 12 6.5% 15.1%; - --muted-foreground: 24 5.4% 63.9%; - --accent: 217 33% 17%; - --accent-foreground: 210 40% 98%; + --secondary: 202 48% 10%; + --secondary-foreground: 240 10% 80%; + --muted: 202 35% 12%; + --muted-foreground: 240 7% 62%; + --accent: 197 72% 23%; + --accent-foreground: 21 51% 88%; --destructive: 0 62.8% 30.6%; --destructive-foreground: 60 9.1% 97.8%; - --border: 12 6.5% 15.1%; - --input: 12 6.5% 15.1%; - --ring: 221 83% 53%; - --chart-1: 220 70% 50%; - --chart-2: 160 60% 45%; - --chart-3: 30 80% 55%; - --chart-4: 280 65% 60%; - --chart-5: 340 75% 55%; + --border: 202 35% 16%; + --input: 202 35% 16%; + --ring: 19 85% 39%; + --chart-1: 19 85% 39%; + --chart-2: 197 72% 23%; + --chart-3: 21 51% 67%; + --chart-4: 21 48% 53%; + --chart-5: 240 10% 80%; /* Editor surface colors — dark mode */ - --editor-bg: 240 6% 7%; - --editor-header: 240 5% 8.5%; - --editor-panel: 240 5% 9%; - --editor-surface: 240 4% 10%; - --editor-surface-alt: 240 5% 11%; - --editor-dialog: 240 10% 3%; - --editor-dialog-alt: 237 9% 5%; - --editor-timeline: 240 6% 7%; - --editor-row: 240 4% 10%; - --editor-subrow: 240 7% 15%; + --editor-bg: 200 33% 3%; + --editor-header: 202 35% 6%; + --editor-panel: 202 35% 6%; + --editor-surface: 202 48% 10%; + --editor-surface-alt: 197 55% 13%; + --editor-dialog: 200 33% 3%; + --editor-dialog-alt: 202 35% 6%; + --editor-timeline: 202 35% 6%; + --editor-row: 202 48% 10%; + --editor-subrow: 197 45% 16%; /* Slider track colors — dark mode */ --slider-track: 0 0% 100% / 0.12; - --slider-thumb: 0 0% 100%; - --slider-thumb-shadow: 0 0% 100% / 0.32; - --slider-glow: 0 0% 100% / 0.5; + --slider-thumb: 21 51% 67%; + --slider-thumb-shadow: 21 51% 67% / 0.32; + --slider-glow: 19 85% 39% / 0.5; } } @@ -141,6 +141,18 @@ font: inherit; } + .electron-drag { + -webkit-app-region: drag; + } + + .electron-no-drag { + -webkit-app-region: no-drag; + } + + html[data-platform="macos"] .vybe-studio-header { + padding-left: 5.25rem; + } + body .font-mono, body kbd { font-family: var(--app-font-sans); @@ -171,11 +183,11 @@ border-radius: inherit; background: linear-gradient( 90deg, - rgba(37, 99, 235, 0) 0%, - rgba(37, 99, 235, 0.35) 18%, - rgba(96, 165, 250, 0.92) 52%, - rgba(37, 99, 235, 0.3) 82%, - rgba(37, 99, 235, 0) 100% + rgba(182, 65, 15, 0) 0%, + rgba(192, 117, 77, 0.28) 18%, + rgba(213, 157, 128, 0.92) 52%, + rgba(182, 65, 15, 0.3) 82%, + rgba(182, 65, 15, 0) 100% ); animation: indeterminate-progress 1.15s cubic-bezier(0.4, 0, 0.2, 1) infinite; } @@ -268,6 +280,3 @@ transform: translateX(270%); } } - - - diff --git a/src/lib/exporter/exportSavePolicy.ts b/src/lib/exporter/exportSavePolicy.ts index c48516ed3..9c9a7ce45 100644 --- a/src/lib/exporter/exportSavePolicy.ts +++ b/src/lib/exporter/exportSavePolicy.ts @@ -39,8 +39,8 @@ export function describeBlockedInMemoryExportSave({ }): string { const normalizedExtension = normalizeExportExtension(extension) || "export"; if (isExportTooLargeForInMemorySave(blobSize)) { - return `The ${normalizedExtension.toUpperCase()} export is too large to save through the legacy in-memory path. Please retry the export so Recordly can save it through the temp-file streaming path.`; + return `The ${normalizedExtension.toUpperCase()} export is too large to save through the legacy in-memory path. Please retry the export so VybeClip can save it through the temp-file streaming path.`; } - return `The ${normalizedExtension.toUpperCase()} export could not be saved through the temp-file streaming path, and Recordly will not fall back to the legacy in-memory path for MP4 exports. Please retry the export.`; + return `The ${normalizedExtension.toUpperCase()} export could not be saved through the temp-file streaming path, and VybeClip will not fall back to the legacy in-memory path for MP4 exports. Please retry the export.`; } diff --git a/src/lib/exporter/gifExporter.ts b/src/lib/exporter/gifExporter.ts index 2630e5926..ee931f0d0 100644 --- a/src/lib/exporter/gifExporter.ts +++ b/src/lib/exporter/gifExporter.ts @@ -316,7 +316,7 @@ export class GifExporter { } // Render the GIF - const blob = await new Promise((resolve, _reject) => { + const blob = await new Promise((resolve, reject) => { this.gif!.on("finished", (blob: Blob) => { resolve(blob); }); @@ -336,7 +336,24 @@ export class GifExporter { } }); - // gif.js doesn't have a typed 'error' event, but we can catch errors in the try/catch + // gif.js's TypeScript definitions don't declare an 'error' event, but the + // underlying EventEmitter can still emit one (e.g. worker errors), and + // gif.js can also emit 'abort'. Wire both up so failures reject the + // promise instead of leaving the export hanging forever. + this.gif!.on("abort", () => { + reject(new Error("GIF rendering was aborted")); + }); + (this.gif as unknown as { on(event: "error", listener: (err: unknown) => void): void }).on( + "error", + (err: unknown) => { + reject( + new Error( + `GIF rendering failed: ${err instanceof Error ? err.message : String(err)}`, + ), + ); + }, + ); + this.gif!.render(); }); diff --git a/src/lib/exporter/modernVideoExporter.ts b/src/lib/exporter/modernVideoExporter.ts index 5b65882b8..b335d8b23 100644 --- a/src/lib/exporter/modernVideoExporter.ts +++ b/src/lib/exporter/modernVideoExporter.ts @@ -1,3 +1,4 @@ +import { toast } from "sonner"; import type { AnnotationRegion, AudioRegion, @@ -287,6 +288,7 @@ type NativeStaticLayoutZoomSample = { }; const NATIVE_EXPORT_ENGINE_NAME = "Breeze"; +const NATIVE_STATIC_LAYOUT_FALLBACK_TOAST_ID = "native-static-layout-export-fallback"; const READABLE_SOURCE_RETRY_ERROR_TOKENS = [ "readavpacket", "get_media_info", @@ -2603,6 +2605,10 @@ export class ModernVideoExporter { console.warn("[VideoExporter] Native static layout export failed; falling back", error); this.nativeStaticLayoutSkipReason = "native-static-runtime-failed"; this.nativeStaticLayoutSkipReasons = [this.nativeStaticLayoutSkipReason]; + toast.warning( + "Hardware-accelerated export isn't available right now, so this export will use the standard (slower) path instead.", + { id: NATIVE_STATIC_LAYOUT_FALLBACK_TOAST_ID, duration: 8000 }, + ); restoreEncoderState(); return null; } finally { diff --git a/src/lib/extensions/extensionHost.ts b/src/lib/extensions/extensionHost.ts index d0683e7e3..af8c16239 100644 --- a/src/lib/extensions/extensionHost.ts +++ b/src/lib/extensions/extensionHost.ts @@ -26,7 +26,28 @@ import type { RenderHookPhase, } from "./types"; -const EXTENSION_SETTINGS_STORAGE_KEY = "recordly.extension-settings.v1"; +// Legacy (pre-namespacing) storage key. All extensions' settings used to be +// stored together, in plaintext, under this single key — which meant any +// extension could read every other extension's settings via raw +// `window.localStorage` access (extensions run via dynamic import() in the +// same main world, so raw localStorage is directly reachable, bypassing the +// host API's per-extension scoping entirely). Kept around only so +// `migrateLegacySharedSettingsStore()` can copy already-installed extensions' +// settings forward into their own namespaced keys. +const LEGACY_SHARED_EXTENSION_SETTINGS_STORAGE_KEY = "recordly.extension-settings.v1"; + +// Each extension now gets its own namespaced localStorage key, e.g. +// "recordly.extension-settings.v1:my-extension-id". This prevents one +// extension from reading another's persisted settings via raw localStorage +// access — the host API already scoped access correctly, but raw +// `window.localStorage` access did not. +const EXTENSION_SETTINGS_STORAGE_KEY_PREFIX = "recordly.extension-settings.v1:"; + +const LEGACY_SETTINGS_MIGRATION_FLAG_KEY = "recordly.extension-settings.v1.migrated"; + +function getNamespacedExtensionSettingsKey(extensionId: string): string { + return `${EXTENSION_SETTINGS_STORAGE_KEY_PREFIX}${extensionId}`; +} // --------------------------------------------------------------------------- // Security: Hide electronAPI from extension code @@ -144,7 +165,6 @@ export class ExtensionHost { Set<(settingId: string, value: unknown) => void> >(); private listeners = new Set<() => void>(); - private fullSettingsStore: Record> | null = null; private persistTimeout: ReturnType | null = null; private iconPathCache = new Map(); @@ -552,13 +572,63 @@ export class ExtensionHost { } } - private readPersistedSettingsStore(): Record> { + /** + * One-time, best-effort migration: copies each extension's settings out of + * the old shared plaintext key into its own namespaced key, so + * already-installed extensions don't silently lose their settings when + * this per-extension scoping was introduced. Safe to call multiple times; + * it no-ops once a flag has been recorded, and it never overwrites a + * namespaced key that already has data. + */ + private migrateLegacySharedSettingsStore(): void { + if (typeof window === "undefined" || !window.localStorage) { + return; + } + + try { + if (window.localStorage.getItem(LEGACY_SETTINGS_MIGRATION_FLAG_KEY)) { + return; + } + + const raw = window.localStorage.getItem(LEGACY_SHARED_EXTENSION_SETTINGS_STORAGE_KEY); + if (raw) { + const parsed = JSON.parse(raw); + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { + for (const [extensionId, settings] of Object.entries( + parsed as Record, + )) { + if (!settings || typeof settings !== "object" || Array.isArray(settings)) { + continue; + } + + const namespacedKey = getNamespacedExtensionSettingsKey(extensionId); + // Don't clobber anything already migrated/written under the new key. + if (window.localStorage.getItem(namespacedKey) != null) { + continue; + } + + window.localStorage.setItem(namespacedKey, JSON.stringify(settings)); + } + } + } + + // Leave the legacy key in place (in case of bugs in this migration) but + // record that migration ran, so we don't redo it on every load. + window.localStorage.setItem(LEGACY_SETTINGS_MIGRATION_FLAG_KEY, "1"); + } catch { + // Best-effort — if anything goes wrong, just skip migration. + } + } + + private readPersistedExtensionSettings(extensionId: string): Record { if (typeof window === "undefined" || !window.localStorage) { return {}; } + this.migrateLegacySharedSettingsStore(); + try { - const raw = window.localStorage.getItem(EXTENSION_SETTINGS_STORAGE_KEY); + const raw = window.localStorage.getItem(getNamespacedExtensionSettingsKey(extensionId)); if (!raw) { return {}; } @@ -568,63 +638,51 @@ export class ExtensionHost { return {}; } - return parsed as Record>; + return parsed as Record; } catch { return {}; } } - private writePersistedSettingsStore(store: Record>): void { + private writePersistedExtensionSettings( + extensionId: string, + settings: Record, + ): void { if (typeof window === "undefined" || !window.localStorage) { return; } try { - window.localStorage.setItem(EXTENSION_SETTINGS_STORAGE_KEY, JSON.stringify(store)); + const key = getNamespacedExtensionSettingsKey(extensionId); + if (Object.keys(settings).length === 0) { + window.localStorage.removeItem(key); + } else { + window.localStorage.setItem(key, JSON.stringify(settings)); + } } catch { // Ignore storage quota / privacy mode failures. } } - private getFullSettingsStore(): Record> { - if (this.fullSettingsStore) { - return this.fullSettingsStore; - } - this.fullSettingsStore = this.readPersistedSettingsStore(); - return this.fullSettingsStore; - } - private ensureExtensionSettingsLoaded(extensionId: string): void { if (this.extensionSettings.has(extensionId)) { return; } - const store = this.getFullSettingsStore(); - const persisted = store[extensionId]; - const normalized = - persisted && typeof persisted === "object" && !Array.isArray(persisted) - ? { ...persisted } - : {}; - - this.extensionSettings.set(extensionId, normalized); + const persisted = this.readPersistedExtensionSettings(extensionId); + this.extensionSettings.set(extensionId, { ...persisted }); } private persistExtensionSettings(extensionId: string): void { - const store = this.getFullSettingsStore(); - const settings = this.extensionSettings.get(extensionId) ?? {}; - - if (Object.keys(settings).length === 0) { - delete store[extensionId]; - } else { - store[extensionId] = { ...settings }; - } - // Debounce the actual write to localStorage to avoid blocking the UI thread during rapid changes if (this.persistTimeout) { clearTimeout(this.persistTimeout); } this.persistTimeout = setTimeout(() => { - this.writePersistedSettingsStore(store); + this.writePersistedExtensionSettings( + extensionId, + this.extensionSettings.get(extensionId) ?? {}, + ); this.persistTimeout = null; }, 500); } @@ -634,7 +692,9 @@ export class ExtensionHost { clearTimeout(this.persistTimeout); this.persistTimeout = null; } - this.writePersistedSettingsStore(this.getFullSettingsStore()); + for (const [extensionId, settings] of this.extensionSettings.entries()) { + this.writePersistedExtensionSettings(extensionId, settings); + } } /**