Merge pull request #848 from webadderallorg/codex/experimental-update-modal

Fix experimental update channel and toast
This commit is contained in:
webadderall
2026-08-31 19:05:00 +10:00
committed by GitHub
33 changed files with 701 additions and 464 deletions
+30 -10
View File
@@ -608,12 +608,18 @@ jobs:
shell: bash
env:
RELEASE_SCOPE: ${{ needs.prepare-release.outputs.release_scope }}
IS_PRERELEASE: ${{ needs.prepare-release.outputs.prerelease }}
run: |
set -euo pipefail
shopt -s nullglob
mkdir -p release-assets/upload
if [ "$IS_PRERELEASE" = "true" ] && [ "$RELEASE_SCOPE" != "all" ]; then
echo "Prerelease beta updates must include all platforms so every opted-in client can resolve beta metadata."
exit 1
fi
required_metadata=(
release-assets/windows-x64/latest.yml
release-assets/linux-x64/latest-linux.yml
@@ -630,6 +636,29 @@ jobs:
fi
done
if [ "$IS_PRERELEASE" = "true" ]; then
cp release-assets/windows-x64/latest.yml release-assets/windows-x64/beta.yml
cp release-assets/linux-x64/latest-linux.yml release-assets/linux-x64/beta-linux.yml
update_metadata=(
release-assets/windows-x64/beta.yml
release-assets/linux-x64/beta-linux.yml
)
if [ "$RELEASE_SCOPE" = "all" ]; then
cp release-assets/macos-merged/latest-mac.yml release-assets/macos-merged/beta-mac.yml
update_metadata+=(release-assets/macos-merged/beta-mac.yml)
fi
else
update_metadata=(
release-assets/windows-x64/latest.yml
release-assets/linux-x64/latest-linux.yml
)
if [ "$RELEASE_SCOPE" = "all" ]; then
update_metadata+=(release-assets/macos-merged/latest-mac.yml)
fi
fi
assets=(
release-assets/windows-x64/*.exe
release-assets/windows-x64/*.blockmap
@@ -648,16 +677,7 @@ jobs:
)
fi
assets+=(
release-assets/windows-x64/latest.yml
release-assets/linux-x64/latest-linux.yml
)
if [ "$RELEASE_SCOPE" = "all" ]; then
assets+=(
release-assets/macos-merged/latest-mac.yml
)
fi
assets+=("${update_metadata[@]}")
if [ ${#assets[@]} -eq 0 ]; then
echo "No release assets found to checksum."
+1
View File
@@ -49,6 +49,7 @@ interface UpdateToastState {
phase: "available" | "downloading" | "ready" | "error";
delayMs: number;
isPreview?: boolean;
isExperimental?: boolean;
progressPercent?: number;
transferredBytes?: number;
totalBytes?: number;
+23 -118
View File
@@ -9,7 +9,6 @@ import {
webContents as electronWebContents,
ipcMain,
Menu,
Notification,
nativeImage,
session,
shell,
@@ -30,7 +29,6 @@ import { ensureMediaServer } from "./mediaServer";
import { hardenWebContentsNavigation, shouldHardenWebContentsType } from "./navigationPolicy";
import { shouldGrantDisplayCapture, shouldGrantMediaPermission } from "./permissionPolicy";
import { ensurePackagedRendererServer, getPackagedRendererBaseUrl } from "./rendererServer";
import type { UpdateToastPayload } from "./updater";
import {
checkForAppUpdates,
deferUpdateReminder,
@@ -179,8 +177,6 @@ let editorHasUnsavedChanges = false;
let isForceClosing = false;
let isCreatingMainWindow = false;
let isCreatingEditorWindow = false;
let activeUpdateNotification: Notification | null = null;
let activeUpdateNotificationKey: string | null = null;
const shouldEnforceSingleInstanceLock = !IS_DEV;
const hasSingleInstanceLock = shouldEnforceSingleInstanceLock
? app.requestSingleInstanceLock()
@@ -256,6 +252,14 @@ function getRecordingTrayIcon() {
}
function showHudOverlayFromTray() {
const updateToast = getUpdateToastWindow();
if (updateToast?.isVisible()) {
updateToast.show();
updateToast.moveTop();
updateToast.focus();
return true;
}
const hud = getHudOverlayWindow();
if (!hud) {
return false;
@@ -327,6 +331,14 @@ function focusOrCreateMainWindow() {
return;
}
const updateToast = getUpdateToastWindow();
if (updateToast?.isVisible()) {
updateToast.show();
updateToast.moveTop();
updateToast.focus();
return;
}
if (!mainWindow || mainWindow.isDestroyed()) {
const existingHud = getHudOverlayWindow();
if (existingHud && !existingHud.isDestroyed()) {
@@ -559,124 +571,12 @@ function syncDockIcon() {
}
}
function getUpdateNotificationTitle(payload: UpdateToastPayload) {
switch (payload.phase) {
case "available":
return `Recordly ${payload.version} is available`;
case "downloading":
return `Downloading Recordly ${payload.version}`;
case "ready":
return `Recordly ${payload.version} is ready`;
case "error":
return `Recordly ${payload.version} needs attention`;
}
}
function getUpdateNotificationBody(payload: UpdateToastPayload) {
switch (payload.phase) {
case "available":
return "Click to install the update and restart Recordly.";
case "downloading":
return "Recordly is downloading the update and will restart when it is ready.";
case "ready":
return "Click to install the downloaded update and restart.";
case "error":
return payload.primaryAction === "install-and-restart"
? "Click to try the install again."
: "Click to retry checking for updates.";
}
}
function clearActiveUpdateNotification() {
if (activeUpdateNotification) {
activeUpdateNotification.close();
activeUpdateNotification = null;
}
activeUpdateNotificationKey = null;
}
function sendUpdateToastToWindows(channel: "update-toast-state", payload: unknown) {
if (process.platform !== "darwin") {
if (!payload) {
clearActiveUpdateNotification();
return true;
}
const updatePayload = payload as UpdateToastPayload;
if (updatePayload.phase === "downloading") {
return true;
}
if (!Notification.isSupported()) {
return false;
}
const notificationKey = [
updatePayload.phase,
updatePayload.version,
updatePayload.detail,
].join(":");
if (activeUpdateNotificationKey === notificationKey) {
return true;
}
clearActiveUpdateNotification();
const notification = new Notification({
title: getUpdateNotificationTitle(updatePayload),
body: getUpdateNotificationBody(updatePayload),
icon: getAppImage(getPlatformAppIconFilename(128)),
silent: false,
});
notification.on("click", () => {
focusOrCreateMainWindow();
switch (updatePayload.phase) {
case "available":
void downloadAvailableUpdate(sendUpdateToastToWindows, {
installAfterDownload: true,
});
break;
case "ready":
installDownloadedUpdateNow(sendUpdateToastToWindows);
break;
case "error":
if (updatePayload.primaryAction === "install-and-restart") {
void downloadAvailableUpdate(sendUpdateToastToWindows, {
installAfterDownload: true,
});
} else {
void checkForAppUpdates(getUpdateDialogWindow, { manual: true });
}
break;
default:
break;
}
});
notification.on("close", () => {
if (activeUpdateNotification === notification) {
activeUpdateNotification = null;
activeUpdateNotificationKey = null;
}
});
notification.show();
// On Win10, showing a native notification can break setIgnoreMouseEvents
// forwarding on the transparent HUD overlay. Re-assert it after a short
// delay so the renderer's hover detection keeps working.
reassertHudOverlayMouseState();
activeUpdateNotification = notification;
activeUpdateNotificationKey = notificationKey;
return true;
}
if (!payload) {
const existingWindow = getUpdateToastWindow();
if (!existingWindow) {
return false;
if (existingWindow) {
existingWindow.webContents.send(channel, null);
}
existingWindow.webContents.send(channel, null);
hideUpdateToastWindow();
return true;
}
@@ -1095,6 +995,11 @@ app.whenReady().then(async () => {
createWindow();
setupAutoUpdates(getUpdateDialogWindow, sendUpdateToastToWindows);
if (IS_DEV && process.env.RECORDLY_DEV_PREVIEW_UPDATE === "1") {
setTimeout(() => {
previewUpdateToast(sendUpdateToastToWindows);
}, 750);
}
// Register the display media handler so that renderer's getDisplayMedia()
// calls land on the pre-selected source without showing a system picker.
+29
View File
@@ -0,0 +1,29 @@
import { describe, expect, it } from "vitest";
import {
EXPERIMENTAL_UPDATE_DESCRIPTION,
getUpdateChannelConfiguration,
} from "./updateChannel";
describe("getUpdateChannelConfiguration", () => {
it("keeps regular clients on stable metadata", () => {
expect(getUpdateChannelConfiguration(false)).toEqual({
channel: "latest",
allowPrerelease: false,
allowDowngrade: false,
});
});
it("uses beta metadata only after the client opts in", () => {
expect(getUpdateChannelConfiguration(true)).toEqual({
channel: "beta",
allowPrerelease: true,
allowDowngrade: false,
});
});
it("uses the approved experimental update description", () => {
expect(EXPERIMENTAL_UPDATE_DESCRIPTION).toBe(
"You've opted into experimental updates so you have the choice to test the latest update of Recordly before it's widely available.",
);
});
});
+22
View File
@@ -0,0 +1,22 @@
export const STABLE_UPDATE_CHANNEL = "latest";
export const EXPERIMENTAL_UPDATE_CHANNEL = "beta";
export const EXPERIMENTAL_UPDATE_DESCRIPTION =
"You've opted into experimental updates so you have the choice to test the latest update of Recordly before it's widely available.";
export interface UpdateChannelConfiguration {
channel: typeof STABLE_UPDATE_CHANNEL | typeof EXPERIMENTAL_UPDATE_CHANNEL;
allowPrerelease: boolean;
allowDowngrade: false;
}
export function getUpdateChannelConfiguration(
experimentalUpdatesEnabled: boolean,
): UpdateChannelConfiguration {
return {
channel: experimentalUpdatesEnabled
? EXPERIMENTAL_UPDATE_CHANNEL
: STABLE_UPDATE_CHANNEL,
allowPrerelease: experimentalUpdatesEnabled,
allowDowngrade: false,
};
}
+46 -8
View File
@@ -5,6 +5,10 @@ import { app, BrowserWindow, dialog } from "electron";
import { autoUpdater } from "electron-updater";
import { USER_DATA_PATH } from "./appPaths";
import { readAppSetting, writeAppSetting } from "./appSettingsStore";
import {
EXPERIMENTAL_UPDATE_DESCRIPTION,
getUpdateChannelConfiguration,
} from "./updateChannel";
const UPDATE_CHECK_INTERVAL_MS = 6 * 60 * 60 * 1000;
export const UPDATE_REMINDER_DELAY_MS = 3 * 60 * 60 * 1000;
@@ -14,6 +18,8 @@ const UPDATE_FEED_URL_OVERRIDE = process.env.RECORDLY_UPDATE_FEED_URL?.trim() ??
const UPDATER_LOG_PATH =
process.env.RECORDLY_UPDATER_LOG_PATH?.trim() || path.join(USER_DATA_PATH, "updater.log");
const DEV_UPDATE_PREVIEW_VERSION = "9.9.9";
const DEV_UPDATE_PREVIEW_IS_EXPERIMENTAL =
process.env.RECORDLY_DEV_PREVIEW_EXPERIMENTAL_UPDATE === "1";
const DEV_UPDATE_PREVIEW_PROGRESS_STEP_MS = 300;
const DEV_UPDATE_PREVIEW_PROGRESS_INCREMENT = 20;
const ONE_MEGABYTE = 1024 * 1024;
@@ -43,6 +49,7 @@ export interface UpdateToastPayload {
phase: UpdateToastPhase;
delayMs: number;
isPreview?: boolean;
isExperimental?: boolean;
progressPercent?: number;
transferredBytes?: number;
totalBytes?: number;
@@ -132,14 +139,21 @@ export function getExperimentalUpdatesEnabled() {
function applyExperimentalUpdatesPreference() {
const enabled = getExperimentalUpdatesEnabled();
autoUpdater.allowPrerelease = enabled;
writeUpdaterLog(`Update channel configured: ${enabled ? "experimental" : "stable"}.`);
const { channel, allowPrerelease, allowDowngrade } = getUpdateChannelConfiguration(enabled);
autoUpdater.channel = channel;
autoUpdater.allowPrerelease = allowPrerelease;
// Changing channels enables downgrades inside electron-updater. Recordly never
// needs that behaviour: opting out waits for the next stable version instead.
autoUpdater.allowDowngrade = allowDowngrade;
writeUpdaterLog(
`Update channel configured: ${enabled ? "experimental" : "stable"} (${channel}).`,
);
return enabled;
}
export function setExperimentalUpdatesEnabled(enabled: boolean) {
writeAppSetting(EXPERIMENTAL_UPDATES_SETTING_KEY, enabled);
autoUpdater.allowPrerelease = enabled;
applyExperimentalUpdatesPreference();
skippedVersion = null;
writeUpdaterLog(`Experimental updates ${enabled ? "enabled" : "disabled"} by user.`);
return enabled;
@@ -192,12 +206,22 @@ function emitUpdateToastState(
return sendToRenderer("update-toast-state", payload);
}
function createAvailableUpdateToastPayload(version: string): UpdateToastPayload {
function getCurrentToastExperimentalFlag() {
return currentToastPayload?.isExperimental ?? getExperimentalUpdatesEnabled();
}
function createAvailableUpdateToastPayload(
version: string,
isExperimental = getExperimentalUpdatesEnabled(),
): UpdateToastPayload {
return {
version,
phase: "available",
detail: "Install the latest version now, or remind yourself to come back to it later.",
detail: isExperimental
? EXPERIMENTAL_UPDATE_DESCRIPTION
: "Install the latest version now, or remind yourself to come back to it later.",
delayMs: UPDATE_REMINDER_DELAY_MS,
isExperimental,
primaryAction: "install-and-restart",
};
}
@@ -205,6 +229,7 @@ function createAvailableUpdateToastPayload(version: string): UpdateToastPayload
function createDownloadingUpdateToastPayload(
version: string,
progress: DownloadProgressSnapshot = {},
isExperimental = getCurrentToastExperimentalFlag(),
): UpdateToastPayload {
const normalizedProgress = Math.max(
0,
@@ -238,6 +263,7 @@ function createDownloadingUpdateToastPayload(
? `${remainingMb.toFixed(1)} MB left before Recordly restarts.`
: "Downloading the update now. Recordly will restart when it finishes.",
delayMs: UPDATE_REMINDER_DELAY_MS,
isExperimental,
progressPercent: normalizedProgress,
transferredBytes,
totalBytes,
@@ -247,22 +273,31 @@ function createDownloadingUpdateToastPayload(
};
}
function createDownloadedUpdateToastPayload(version: string): UpdateToastPayload {
function createDownloadedUpdateToastPayload(
version: string,
isExperimental = getCurrentToastExperimentalFlag(),
): UpdateToastPayload {
return {
version,
phase: "ready",
detail: "The update is ready. Install and restart now, or remind yourself later.",
delayMs: UPDATE_REMINDER_DELAY_MS,
isExperimental,
primaryAction: "install-and-restart",
};
}
function createUpdateErrorToastPayload(version: string, error: unknown): UpdateToastPayload {
function createUpdateErrorToastPayload(
version: string,
error: unknown,
isExperimental = getCurrentToastExperimentalFlag(),
): UpdateToastPayload {
return {
version,
phase: "error",
detail: `The update could not be downloaded. ${String(error)}`,
delayMs: UPDATE_REMINDER_DELAY_MS,
isExperimental,
primaryAction: "install-and-restart",
};
}
@@ -531,9 +566,12 @@ export function previewUpdateToast(sendToRenderer: UpdateToastSender) {
return emitUpdateToastState(sendToRenderer, {
version: DEV_UPDATE_PREVIEW_VERSION,
phase: "available",
detail: "This is a development preview of the in-app update toast.",
detail: DEV_UPDATE_PREVIEW_IS_EXPERIMENTAL
? EXPERIMENTAL_UPDATE_DESCRIPTION
: "This is a development preview of the in-app update toast.",
delayMs: UPDATE_REMINDER_DELAY_MS,
isPreview: true,
isExperimental: DEV_UPDATE_PREVIEW_IS_EXPERIMENTAL,
});
}
+44 -23
View File
@@ -37,12 +37,12 @@ let hudOverlayRecordingActive = false;
let hudOverlayWebcamPreviewVisible = false;
let countdownWindow: BrowserWindow | null = null;
let updateToastWindow: BrowserWindow | null = null;
let hudWasVisibleBeforeUpdateToast = false;
const HUD_OVERLAY_SETTINGS_FILE = path.join(USER_DATA_PATH, "hud-overlay-settings.json");
const HUD_EDGE_MARGIN_DIP = 16;
const UPDATE_TOAST_WIDTH = 456;
const UPDATE_TOAST_HEIGHT = 252;
const UPDATE_TOAST_GAP_DIP = 18;
const UPDATE_TOAST_WIDTH = 420;
const UPDATE_TOAST_HEIGHT = 172;
function getEditorWindowQuery(): Record<string, string> {
const query: Record<string, string> = {
@@ -207,11 +207,9 @@ function getUpdateToastBounds() {
if (hudWindow) {
const hudBounds = hudWindow.getBounds();
const display = getScreen().getDisplayMatching(hudBounds);
const x = Math.round(hudBounds.x + (hudBounds.width - UPDATE_TOAST_WIDTH) / 2);
const y = Math.max(
display.workArea.y + HUD_EDGE_MARGIN_DIP,
hudBounds.y - UPDATE_TOAST_HEIGHT - UPDATE_TOAST_GAP_DIP,
);
const { workArea } = display;
const x = Math.round(workArea.x + (workArea.width - UPDATE_TOAST_WIDTH) / 2);
const y = Math.round(workArea.y + workArea.height - UPDATE_TOAST_HEIGHT - HUD_EDGE_MARGIN_DIP);
return {
x,
@@ -225,7 +223,7 @@ function getUpdateToastBounds() {
const { workArea } = primaryDisplay;
return {
x: Math.round(workArea.x + (workArea.width - UPDATE_TOAST_WIDTH) / 2),
y: workArea.y + HUD_EDGE_MARGIN_DIP,
y: Math.round(workArea.y + workArea.height - UPDATE_TOAST_HEIGHT - HUD_EDGE_MARGIN_DIP),
width: UPDATE_TOAST_WIDTH,
height: UPDATE_TOAST_HEIGHT,
};
@@ -462,6 +460,10 @@ export function createHudOverlayWindow(): BrowserWindow {
if (hasShownHudWindow || win.isDestroyed()) {
return;
}
if (updateToastWindow && !updateToastWindow.isDestroyed() && updateToastWindow.isVisible()) {
hudWasVisibleBeforeUpdateToast = true;
return;
}
hasShownHudWindow = true;
if (process.platform === "win32") {
// A focusable window is required for a Windows taskbar entry, but the
@@ -646,11 +648,6 @@ export function setHudOverlayRecordingActive(recording: boolean): void {
export function createUpdateToastWindow(): BrowserWindow {
const initialBounds = getUpdateToastBounds();
const parentWindow =
process.platform === "darwin" && hudOverlayWindow && !hudOverlayWindow.isDestroyed()
? hudOverlayWindow
: undefined;
const useTransparentToastWindow = process.platform !== "win32";
const win = new BrowserWindow({
width: initialBounds.width,
@@ -658,15 +655,14 @@ export function createUpdateToastWindow(): BrowserWindow {
x: initialBounds.x,
y: initialBounds.y,
frame: false,
transparent: useTransparentToastWindow,
transparent: true,
resizable: false,
alwaysOnTop: true,
skipTaskbar: true,
hasShadow: false,
show: false,
focusable: true,
...(parentWindow ? { parent: parentWindow } : {}),
backgroundColor: useTransparentToastWindow ? "#00000000" : "#101418",
backgroundColor: "#00000000",
webPreferences: {
preload: path.join(electronWindowsDir, "preload.mjs"),
nodeIntegration: false,
@@ -691,6 +687,7 @@ export function createUpdateToastWindow(): BrowserWindow {
if (updateToastWindow === win) {
updateToastWindow = null;
}
restoreHudAfterUpdateToast();
});
if (VITE_DEV_SERVER_URL) {
@@ -710,27 +707,51 @@ export function getUpdateToastWindow(): BrowserWindow | null {
export function showUpdateToastWindow(): BrowserWindow {
const win = getUpdateToastWindow() ?? createUpdateToastWindow();
const hud = getHudOverlayWindow();
if (!win.isVisible()) {
hudWasVisibleBeforeUpdateToast = Boolean(hud?.isVisible());
}
if (hud?.isVisible()) {
hud.hide();
}
positionUpdateToastWindow();
if (!win.isVisible()) {
if (process.platform === "win32") {
win.show();
win.moveTop();
} else {
win.showInactive();
}
} else {
win.moveTop();
}
win.moveTop();
return win;
}
export function hideUpdateToastWindow(): void {
if (!updateToastWindow || updateToastWindow.isDestroyed()) {
function restoreHudAfterUpdateToast(): void {
if (!hudWasVisibleBeforeUpdateToast) {
return;
}
updateToastWindow.hide();
hudWasVisibleBeforeUpdateToast = false;
const hud = getHudOverlayWindow();
if (!hud) {
return;
}
if (process.platform === "win32") {
hud.showInactive();
} else {
hud.show();
}
hud.moveTop();
setHudOverlayMousePassthrough(hudOverlayIgnoringMouse);
}
export function hideUpdateToastWindow(): void {
if (updateToastWindow && !updateToastWindow.isDestroyed()) {
updateToastWindow.hide();
}
restoreHudAfterUpdateToast();
}
function loadPackagedEditorWindow(win: BrowserWindow) {
+2 -3
View File
@@ -15,7 +15,6 @@ import { loadAllCustomFonts } from "./lib/customFonts";
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(() => {
@@ -28,7 +27,7 @@ export default function App() {
type === "hud-overlay" ||
type === "source-selector" ||
type === "countdown" ||
(type === "update-toast" && isMacOS)
type === "update-toast"
) {
document.body.style.background = "transparent";
document.documentElement.style.background = "transparent";
@@ -49,7 +48,7 @@ export default function App() {
loadAllCustomFonts().catch((error) => {
console.error("Failed to load custom fonts:", error);
});
}, [isMacOS]);
}, []);
useEffect(() => {
document.title =
@@ -0,0 +1,174 @@
.window {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
padding: 8px;
box-sizing: border-box;
background: transparent;
}
.card {
display: flex;
align-items: flex-start;
gap: 12px;
width: 100%;
padding: 14px;
border: 1px solid hsl(var(--border));
border-radius: 8px;
background: hsl(var(--background));
box-shadow: none;
color: hsl(var(--foreground));
}
.icon {
display: flex;
align-items: center;
justify-content: center;
flex: 0 0 32px;
width: 32px;
height: 32px;
border-radius: 7px;
background: hsl(var(--muted));
color: hsl(var(--muted-foreground));
}
.iconError {
background: hsl(var(--destructive) / 0.14);
color: hsl(var(--destructive));
}
.content {
min-width: 0;
flex: 1;
}
.headingRow {
display: flex;
align-items: center;
gap: 6px;
min-height: 19px;
}
.headingRow h1 {
margin: 0;
font-size: 13px;
font-weight: 600;
line-height: 1.35;
letter-spacing: 0;
}
.content > p {
margin: 4px 0 0;
color: hsl(var(--muted-foreground));
font-size: 12px;
line-height: 1.45;
}
.version,
.preview {
padding: 2px 6px;
border-radius: 999px;
background: hsl(var(--muted));
color: hsl(var(--muted-foreground));
font-size: 10px;
font-weight: 600;
line-height: 1;
}
.preview {
background: hsl(var(--primary) / 0.12);
color: hsl(var(--primary));
text-transform: uppercase;
letter-spacing: 0.04em;
}
.actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 12px;
}
.primaryButton,
.secondaryButton {
height: 30px;
padding: 0 12px;
border-radius: 6px;
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition:
background 140ms ease,
border-color 140ms ease,
transform 140ms ease;
}
.primaryButton:active,
.secondaryButton:active {
transform: translateY(1px);
}
.primaryButton {
border: 1px solid hsl(var(--primary));
background: hsl(var(--primary));
color: hsl(var(--primary-foreground));
}
.primaryButton:hover {
background: hsl(var(--primary) / 0.9);
}
.secondaryButton {
border: 1px solid hsl(var(--input));
background: hsl(var(--background));
color: hsl(var(--muted-foreground));
}
.secondaryButton:hover {
background: hsl(var(--accent));
color: hsl(var(--accent-foreground));
}
.progressBlock {
margin-top: 12px;
}
.progressTrack {
height: 5px;
overflow: hidden;
border-radius: 999px;
background: hsl(var(--muted));
}
.progressFill {
height: 100%;
border-radius: inherit;
background: hsl(var(--primary));
transition: width 220ms ease;
}
.progressMeta {
display: flex;
justify-content: space-between;
gap: 12px;
margin-top: 6px;
color: hsl(var(--muted-foreground));
font-size: 10px;
}
.progressMeta strong {
color: hsl(var(--foreground));
font-weight: 600;
}
.spin {
animation: spin 900ms linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
+103 -300
View File
@@ -1,10 +1,12 @@
import {
WarningCircle as AlertCircle,
DownloadSimple as Download,
Spinner as LoaderCircle,
Rocket,
ArrowClockwiseIcon,
CheckCircleIcon,
DownloadSimpleIcon,
WarningCircleIcon,
} from "@phosphor-icons/react";
import { useEffect, useState } from "react";
import { useI18n } from "@/contexts/I18nContext";
import styles from "./UpdateToastWindow.module.css";
type UpdateToastPayload = {
version: string;
@@ -12,385 +14,186 @@ type UpdateToastPayload = {
phase: "available" | "downloading" | "ready" | "error";
delayMs: number;
isPreview?: boolean;
isExperimental?: boolean;
progressPercent?: number;
transferredBytes?: number;
totalBytes?: number;
remainingBytes?: number;
bytesPerSecond?: number;
primaryAction?: "install-and-restart" | "retry-check";
};
const DEFAULT_REMINDER_DELAY_MS = 3 * 60 * 60 * 1000;
const REMINDER_OPTIONS = [
{ label: "1 hour", value: 1 * 60 * 60 * 1000 },
{ label: "3 hours", value: 3 * 60 * 60 * 1000 },
{ label: "Tomorrow", value: 24 * 60 * 60 * 1000 },
{ label: "3 days", value: 3 * 24 * 60 * 60 * 1000 },
];
function formatBytes(value: number | undefined) {
if (value === undefined || !Number.isFinite(value) || value <= 0) {
return null;
}
const megabytes = value / (1024 * 1024);
if (megabytes >= 1024) {
return `${(megabytes / 1024).toFixed(1)} GB`;
}
return `${megabytes.toFixed(megabytes >= 100 ? 0 : 1)} MB`;
return megabytes >= 1024
? `${(megabytes / 1024).toFixed(1)} GB`
: `${megabytes.toFixed(megabytes >= 100 ? 0 : 1)} MB`;
}
function getToastTitle(payload: UpdateToastPayload) {
if (payload.isPreview) {
return "Update Prompt Preview";
}
type Translate = ReturnType<typeof useI18n>["t"];
function getTitle(payload: UpdateToastPayload, t: Translate) {
switch (payload.phase) {
case "available":
return `Recordly ${payload.version} is available`;
return payload.isExperimental
? t(
"launch.updateToast.experimentalAvailableTitle",
"Experimental update available",
)
: t("launch.updateToast.availableTitle", "Update available");
case "downloading":
return `Installing Recordly ${payload.version}`;
return t("launch.updateToast.downloadingTitle", "Downloading your update");
case "ready":
return `Recordly ${payload.version} is ready`;
return t("launch.updateToast.readyTitle", "Ready to restart");
case "error":
return payload.primaryAction === "retry-check"
? "Could not check for updates"
: `Recordly ${payload.version} needs attention`;
? t("launch.updateToast.checkErrorTitle", "Couldn’t check for updates")
: t("launch.updateToast.downloadErrorTitle", "Couldn’t download the update");
}
}
function getPrimaryButtonLabel(payload: UpdateToastPayload) {
return payload.primaryAction === "retry-check" ? "Try Again" : "Install & Restart";
function getDetail(payload: UpdateToastPayload, t: Translate) {
if (payload.phase === "available" && payload.isExperimental) {
return t(
"launch.updateToast.experimentalDescription",
"You've opted into experimental updates so you have the choice to test the latest update of Recordly before it's widely available.",
);
}
return payload.detail;
}
function getPhaseIcon(payload: UpdateToastPayload) {
function getPrimaryLabel(payload: UpdateToastPayload, t: Translate) {
if (payload.primaryAction === "retry-check") {
return t("launch.updateToast.tryAgain", "Try again");
}
return payload.phase === "ready"
? t("launch.updateToast.restartToUpdate", "Restart to update")
: t("launch.updateToast.updateNow", "Update now");
}
function PhaseIcon({ payload }: { payload: UpdateToastPayload }) {
switch (payload.phase) {
case "available":
return <Download size={20} />;
return <DownloadSimpleIcon size={20} weight="bold" />;
case "downloading":
return <LoaderCircle size={20} className="animate-spin" />;
return <ArrowClockwiseIcon size={20} weight="bold" className={styles.spin} />;
case "ready":
return <Rocket size={20} />;
return <CheckCircleIcon size={20} weight="fill" />;
case "error":
return <AlertCircle size={20} />;
return <WarningCircleIcon size={20} weight="fill" />;
}
}
export function UpdateToastWindow() {
const [payload, setPayload] = useState<UpdateToastPayload | null>(null);
const [reminderDelayMs, setReminderDelayMs] = useState(DEFAULT_REMINDER_DELAY_MS);
const { t } = useI18n();
useEffect(() => {
let mounted = true;
let pollTimer: ReturnType<typeof setInterval> | null = null;
void window.electronAPI.getCurrentUpdateToastPayload().then((nextPayload) => {
if (mounted) {
setPayload(nextPayload);
}
});
pollTimer = setInterval(() => {
const refresh = () => {
void window.electronAPI.getCurrentUpdateToastPayload().then((nextPayload) => {
if (mounted) {
setPayload(nextPayload);
}
if (mounted) setPayload(nextPayload);
});
}, 750);
};
const dispose = window.electronAPI.onUpdateToastStateChanged((nextPayload) => {
setPayload(nextPayload);
});
refresh();
const pollTimer = setInterval(refresh, 750);
const dispose = window.electronAPI.onUpdateToastStateChanged(setPayload);
return () => {
mounted = false;
if (pollTimer) {
clearInterval(pollTimer);
}
clearInterval(pollTimer);
dispose();
};
}, []);
useEffect(() => {
if (!payload) {
return;
}
setReminderDelayMs(payload.delayMs || DEFAULT_REMINDER_DELAY_MS);
}, [payload]);
const normalizedProgress = Math.max(
0,
Math.min(100, Math.round(payload?.progressPercent ?? 0)),
);
const downloadedLabel = formatBytes(payload?.transferredBytes);
const totalLabel = formatBytes(payload?.totalBytes);
const remainingLabel = formatBytes(payload?.remainingBytes);
const speedLabel = formatBytes(payload?.bytesPerSecond);
const phaseStats: Array<{ label: string; value: string }> = [];
if (payload?.phase === "downloading") {
if (downloadedLabel && totalLabel) {
phaseStats.push({ label: "Downloaded", value: `${downloadedLabel} / ${totalLabel}` });
} else if (downloadedLabel) {
phaseStats.push({ label: "Downloaded", value: downloadedLabel });
}
if (remainingLabel) {
phaseStats.push({ label: "Left", value: remainingLabel });
}
if (speedLabel) {
phaseStats.push({ label: "Speed", value: `${speedLabel}/s` });
}
if (!payload) {
return <div className={styles.window} />;
}
const isMacOS = /mac/i.test(navigator.platform);
const wrapperStyle = {
display: "flex",
alignItems: "center",
justifyContent: "center",
width: "100%",
height: "100%",
padding: 10,
boxSizing: "border-box",
background: isMacOS ? "transparent" : "#0b1220",
} as const;
const cardStyle = {
width: "100%",
maxWidth: 440,
display: "flex",
gap: 14,
alignItems: "flex-start",
padding: "18px 18px 16px",
borderRadius: 24,
background:
"linear-gradient(180deg, rgba(12, 19, 34, 0.98) 0%, rgba(10, 17, 30, 0.98) 100%)",
border: "1px solid rgba(37, 99, 235, 0.24)",
boxShadow: "0 20px 48px rgba(2, 6, 23, 0.5), inset 0 1px 0 rgba(148, 163, 184, 0.08)",
color: "#ffffff",
fontFamily: "var(--app-font-sans)",
} as const;
const iconBoxStyle = {
width: 42,
height: 42,
minWidth: 42,
borderRadius: 16,
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "rgba(37, 99, 235, 0.16)",
color: "#60a5fa",
boxShadow: "inset 0 0 0 1px rgba(37, 99, 235, 0.18)",
} as const;
const titleStyle = {
fontSize: 15,
fontWeight: 700,
lineHeight: 1.25,
margin: 0,
color: "#f8fafc",
} as const;
const secondaryTextStyle = {
color: "rgba(226, 232, 240, 0.78)",
fontSize: 13,
lineHeight: 1.5,
margin: "6px 0 0 0",
} as const;
const subtleButtonStyle = {
height: 38,
borderRadius: 12,
padding: "0 14px",
border: "1px solid rgba(148, 163, 184, 0.16)",
background: "rgba(15, 23, 42, 0.72)",
color: "#e2e8f0",
fontSize: 13,
fontWeight: 600,
cursor: "pointer",
transition: "all 0.15s ease",
} as const;
const primaryButtonStyle = {
...subtleButtonStyle,
border: "none",
background: "linear-gradient(180deg, #3b82f6 0%, #2563eb 100%)",
color: "#ffffff",
boxShadow: "0 12px 24px rgba(37, 99, 235, 0.26)",
} as const;
const selectStyle = {
height: 38,
borderRadius: 12,
padding: "0 34px 0 12px",
border: "1px solid rgba(37, 99, 235, 0.22)",
background:
"linear-gradient(180deg, rgba(18, 29, 51, 0.96) 0%, rgba(12, 22, 42, 0.96) 100%)",
color: "#dbeafe",
fontSize: 13,
fontWeight: 600,
outline: "none",
boxShadow: "inset 0 0 0 1px rgba(37, 99, 235, 0.06)",
cursor: "pointer",
} as const;
const progress = Math.max(0, Math.min(100, Math.round(payload.progressPercent ?? 0)));
const transferred = formatBytes(payload.transferredBytes);
const total = formatBytes(payload.totalBytes);
const speed = formatBytes(payload.bytesPerSecond);
const progressDetail = [
transferred && total ? `${transferred} of ${total}` : transferred,
speed ? `${speed}/s` : null,
]
.filter(Boolean)
.join(" · ");
const handlePrimaryAction = async () => {
if (!payload || payload.phase === "downloading") {
return;
}
if (payload.phase === "downloading") return;
if (payload.primaryAction === "retry-check") {
await window.electronAPI.checkForAppUpdates();
return;
}
if (payload.phase === "ready") {
await window.electronAPI.installDownloadedUpdate();
return;
}
await window.electronAPI.downloadAvailableUpdate(true);
};
const handleLater = async () => {
if (!payload) {
return;
}
const handleNotNow = async () => {
if (payload.isPreview) {
await window.electronAPI.dismissUpdateToast();
return;
}
await window.electronAPI.deferDownloadedUpdate(reminderDelayMs);
await window.electronAPI.deferDownloadedUpdate(payload.delayMs);
};
if (!payload) {
return <div style={wrapperStyle} />;
}
return (
<div style={wrapperStyle}>
<div style={cardStyle}>
<div style={iconBoxStyle}>{getPhaseIcon(payload)}</div>
<div style={{ minWidth: 0, flex: 1 }}>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<p style={titleStyle}>{getToastTitle(payload)}</p>
<div className={`${styles.window} launch-theme`}>
<section className={styles.card} aria-live="polite" aria-label="Recordly update">
<div className={`${styles.icon} ${payload.phase === "error" ? styles.iconError : ""}`}>
<PhaseIcon payload={payload} />
</div>
<div className={styles.content}>
<div className={styles.headingRow}>
<h1>{getTitle(payload, t)}</h1>
<span className={styles.version}>v{payload.version.replace(/^v/, "")}</span>
{payload.isExperimental ? (
<span className={styles.preview}>
{t("launch.updateToast.experimentalBadge", "Experimental")}
</span>
) : null}
{payload.isPreview ? (
<span
style={{
borderRadius: 999,
padding: "2px 8px",
fontSize: 10,
fontWeight: 700,
letterSpacing: "0.18em",
textTransform: "uppercase",
color: "#93c5fd",
background: "rgba(37, 99, 235, 0.14)",
border: "1px solid rgba(37, 99, 235, 0.18)",
}}
>
Dev
<span className={styles.preview}>
{t("launch.updateToast.previewBadge", "Preview")}
</span>
) : null}
</div>
<p style={secondaryTextStyle}>{payload.detail}</p>
<p>{getDetail(payload, t)}</p>
{payload.phase === "downloading" ? (
<div style={{ marginTop: 14 }}>
<div
style={{
height: 10,
overflow: "hidden",
borderRadius: 999,
background: "rgba(148, 163, 184, 0.14)",
}}
>
<div
style={{
height: "100%",
width: `${normalizedProgress}%`,
borderRadius: 999,
background:
"linear-gradient(90deg, #60a5fa 0%, #2563eb 45%, #1d4ed8 100%)",
boxShadow: "0 0 22px rgba(37, 99, 235, 0.38)",
}}
/>
<div className={styles.progressBlock}>
<div className={styles.progressTrack}>
<div className={styles.progressFill} style={{ width: `${progress}%` }} />
</div>
<div
style={{
display: "flex",
flexWrap: "wrap",
gap: 8,
marginTop: 10,
}}
>
<span
style={{
fontSize: 12,
fontWeight: 700,
color: "#dbeafe",
}}
>
{normalizedProgress}% complete
</span>
{phaseStats.map((stat) => (
<span
key={stat.label}
style={{
fontSize: 11,
fontWeight: 600,
color: "rgba(191, 219, 254, 0.9)",
background: "rgba(37, 99, 235, 0.12)",
borderRadius: 999,
padding: "4px 8px",
border: "1px solid rgba(37, 99, 235, 0.16)",
}}
>
{stat.label}: {stat.value}
</span>
))}
<div className={styles.progressMeta}>
<strong>{progress}%</strong>
{progressDetail ? <span>{progressDetail}</span> : null}
</div>
</div>
) : null}
<div
style={{
display: "flex",
flexWrap: "wrap",
gap: 10,
marginTop: 14,
alignItems: "center",
}}
>
{payload.phase !== "downloading" ? (
<>
<button
type="button"
onClick={handlePrimaryAction}
style={primaryButtonStyle}
>
{getPrimaryButtonLabel(payload)}
</button>
<select
value={String(reminderDelayMs)}
onChange={(event) => {
setReminderDelayMs(Number.parseInt(event.target.value, 10));
}}
style={selectStyle}
>
{REMINDER_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
<button
type="button"
onClick={handleLater}
style={subtleButtonStyle}
>
Later
</button>
</>
) : null}
</div>
) : (
<div className={styles.actions}>
<button type="button" className={styles.secondaryButton} onClick={handleNotNow}>
{t("launch.updateToast.notNow", "Not now")}
</button>
<button type="button" className={styles.primaryButton} onClick={handlePrimaryAction}>
{getPrimaryLabel(payload, t)}
</button>
</div>
)}
</div>
</div>
</section>
</div>
);
}
@@ -2566,7 +2566,7 @@ export function SettingsPanel({
<div className="mt-0.5 text-[10px] text-muted-foreground/70">
{tSettings(
"updates.experimentalDescription",
"Receive first-line test builds published as prereleases. These may be less stable.",
"This is the front line of user testing - highly experimental so expect bugs",
)}
</div>
</div>
+15
View File
@@ -76,5 +76,20 @@
"microphoneDenied": "Der Zugriff auf das Mikrofon wurde verweigert. Die Aufzeichnung wird ohne Mikrofonton fortgesetzt.",
"failedToStart": "Die Aufzeichnung konnte nicht gestartet werden: {{error}}",
"failedToStartGeneric": "Die Aufnahme konnte nicht gestartet werden"
},
"updateToast": {
"availableTitle": "Update verfügbar",
"experimentalAvailableTitle": "Experimentelles Update verfügbar",
"experimentalDescription": "Sie haben experimentelle Updates aktiviert und können daher das neueste Update von Recordly testen, bevor es allgemein verfügbar ist.",
"downloadingTitle": "Update wird heruntergeladen",
"readyTitle": "Bereit zum Neustart",
"checkErrorTitle": "Updates konnten nicht geprüft werden",
"downloadErrorTitle": "Das Update konnte nicht heruntergeladen werden",
"experimentalBadge": "Experimentell",
"previewBadge": "Vorschau",
"notNow": "Nicht jetzt",
"updateNow": "Jetzt aktualisieren",
"restartToUpdate": "Zum Aktualisieren neu starten",
"tryAgain": "Erneut versuchen"
}
}
+6
View File
@@ -254,5 +254,11 @@
"micLabel": "Quellmikrofon",
"mixedLabel": "Quelle",
"deleteRegion": "Audio löschen"
},
"updates": {
"experimentalDescription": "Sie haben experimentelle Updates aktiviert und können daher das neueste Update von Recordly testen, bevor es allgemein verfügbar ist.",
"title": "Updates",
"experimental": "Experimentelle Updates",
"saveFailed": "Update-Kanal konnte nicht geändert werden."
}
}
+15
View File
@@ -76,5 +76,20 @@
"microphoneDenied": "Microphone access was denied. Recording will continue without microphone audio.",
"failedToStart": "Failed to start recording: {{error}}",
"failedToStartGeneric": "Failed to start recording"
},
"updateToast": {
"availableTitle": "Update available",
"experimentalAvailableTitle": "Experimental update available",
"experimentalDescription": "You've opted into experimental updates so you have the choice to test the latest update of Recordly before it's widely available.",
"downloadingTitle": "Downloading your update",
"readyTitle": "Ready to restart",
"checkErrorTitle": "Couldn't check for updates",
"downloadErrorTitle": "Couldn't download the update",
"experimentalBadge": "Experimental",
"previewBadge": "Preview",
"notNow": "Not now",
"updateNow": "Update now",
"restartToUpdate": "Restart to update",
"tryAgain": "Try again"
}
}
+1 -1
View File
@@ -2,7 +2,7 @@
"updates": {
"title": "Updates",
"experimental": "Experimental updates",
"experimentalDescription": "Receive first-line test builds published as prereleases. These may be less stable.",
"experimentalDescription": "You've opted into experimental updates so you have the choice to test the latest update of Recordly before it's widely available.",
"saveFailed": "Failed to change the update channel."
},
"zoom": {
+15
View File
@@ -76,5 +76,20 @@
"microphoneDenied": "Se denegó el acceso al micrófono. La grabación continuará sin audio del micrófono.",
"failedToStart": "Error al iniciar la grabación: {{error}}",
"failedToStartGeneric": "Error al iniciar la grabación"
},
"updateToast": {
"availableTitle": "Actualización disponible",
"experimentalAvailableTitle": "Actualización experimental disponible",
"experimentalDescription": "Has activado las actualizaciones experimentales, así que puedes probar la actualización más reciente de Recordly antes de que esté disponible para todos.",
"downloadingTitle": "Descargando la actualización",
"readyTitle": "Listo para reiniciar",
"checkErrorTitle": "No se pudieron buscar actualizaciones",
"downloadErrorTitle": "No se pudo descargar la actualización",
"experimentalBadge": "Experimental",
"previewBadge": "Vista previa",
"notNow": "Ahora no",
"updateNow": "Actualizar ahora",
"restartToUpdate": "Reiniciar para actualizar",
"tryAgain": "Intentar de nuevo"
}
}
+6
View File
@@ -254,5 +254,11 @@
"micLabel": "Micrófono",
"mixedLabel": "Fuente",
"deleteRegion": "Eliminar audio"
},
"updates": {
"experimentalDescription": "Has activado las actualizaciones experimentales, así que puedes probar la actualización más reciente de Recordly antes de que esté disponible para todos.",
"title": "Actualizaciones",
"experimental": "Actualizaciones experimentales",
"saveFailed": "No se pudo cambiar el canal de actualización."
}
}
+15
View File
@@ -76,5 +76,20 @@
"microphoneDenied": "L’accès au microphone a été refusé. L’enregistrement continuera sans audio du microphone.",
"failedToStart": "Échec du démarrage de l’enregistrement : {{error}}",
"failedToStartGeneric": "Échec du démarrage de l’enregistrement"
},
"updateToast": {
"availableTitle": "Mise à jour disponible",
"experimentalAvailableTitle": "Mise à jour expérimentale disponible",
"experimentalDescription": "Vous avez activé les mises à jour expérimentales, vous pouvez donc tester la dernière mise à jour de Recordly avant sa disponibilité générale.",
"downloadingTitle": "Téléchargement de la mise à jour",
"readyTitle": "Prêt à redémarrer",
"checkErrorTitle": "Impossible de vérifier les mises à jour",
"downloadErrorTitle": "Impossible de télécharger la mise à jour",
"experimentalBadge": "Expérimental",
"previewBadge": "Aperçu",
"notNow": "Plus tard",
"updateNow": "Mettre à jour",
"restartToUpdate": "Redémarrer pour mettre à jour",
"tryAgain": "Réessayer"
}
}
+6
View File
@@ -254,5 +254,11 @@
"micLabel": "Microphone",
"mixedLabel": "Source",
"deleteRegion": "Supprimer la zone audio"
},
"updates": {
"experimentalDescription": "Vous avez activé les mises à jour expérimentales, vous pouvez donc tester la dernière mise à jour de Recordly avant sa disponibilité générale.",
"title": "Mises à jour",
"experimental": "Mises à jour expérimentales",
"saveFailed": "Impossible de changer le canal de mise à jour."
}
}
+15
View File
@@ -76,5 +76,20 @@
"microphoneDenied": "L'accesso al microfono è stato negato. La registrazione continuerà senza audio del microfono.",
"failedToStart": "Avvio della registrazione non riuscito: {{error}}",
"failedToStartGeneric": "Avvio della registrazione non riuscito"
},
"updateToast": {
"availableTitle": "Aggiornamento disponibile",
"experimentalAvailableTitle": "Aggiornamento sperimentale disponibile",
"experimentalDescription": "Hai attivato gli aggiornamenti sperimentali, quindi puoi scegliere di provare l'ultimo aggiornamento di Recordly prima che sia disponibile per tutti.",
"downloadingTitle": "Download dell'aggiornamento",
"readyTitle": "Pronto per il riavvio",
"checkErrorTitle": "Impossibile verificare gli aggiornamenti",
"downloadErrorTitle": "Impossibile scaricare l'aggiornamento",
"experimentalBadge": "Sperimentale",
"previewBadge": "Anteprima",
"notNow": "Non ora",
"updateNow": "Aggiorna ora",
"restartToUpdate": "Riavvia per aggiornare",
"tryAgain": "Riprova"
}
}
+6
View File
@@ -254,5 +254,11 @@
"micLabel": "Sorgente microfono",
"mixedLabel": "Sorgente",
"deleteRegion": "Elimina audio"
},
"updates": {
"experimentalDescription": "Hai attivato gli aggiornamenti sperimentali, quindi puoi scegliere di provare l'ultimo aggiornamento di Recordly prima che sia disponibile per tutti.",
"title": "Aggiornamenti",
"experimental": "Aggiornamenti sperimentali",
"saveFailed": "Impossibile cambiare il canale di aggiornamento."
}
}
+15
View File
@@ -76,5 +76,20 @@
"microphoneDenied": "마이크 접근이 거부되었습니다. 마이크 오디오 없이 녹화를 계속합니다.",
"failedToStart": "녹화 시작에 실패했습니다: {{error}}",
"failedToStartGeneric": "녹화 시작에 실패했습니다"
},
"updateToast": {
"availableTitle": "업데이트 사용 가능",
"experimentalAvailableTitle": "실험적 업데이트 사용 가능",
"experimentalDescription": "실험적 업데이트를 선택했으므로 Recordly의 최신 업데이트가 널리 제공되기 전에 먼저 테스트해 볼 수 있습니다.",
"downloadingTitle": "업데이트 다운로드 중",
"readyTitle": "다시 시작할 준비 완료",
"checkErrorTitle": "업데이트를 확인할 수 없습니다",
"downloadErrorTitle": "업데이트를 다운로드할 수 없습니다",
"experimentalBadge": "실험적",
"previewBadge": "미리보기",
"notNow": "나중에",
"updateNow": "지금 업데이트",
"restartToUpdate": "다시 시작하여 업데이트",
"tryAgain": "다시 시도"
}
}
+6
View File
@@ -254,5 +254,11 @@
"micLabel": "마이크 소스",
"mixedLabel": "소스",
"deleteRegion": "오디오 삭제"
},
"updates": {
"experimentalDescription": "실험적 업데이트를 선택했으므로 Recordly의 최신 업데이트가 널리 제공되기 전에 먼저 테스트해 볼 수 있습니다.",
"title": "업데이트",
"experimental": "실험적 업데이트",
"saveFailed": "업데이트 채널을 변경하지 못했습니다."
}
}
+15
View File
@@ -76,5 +76,20 @@
"microphoneDenied": "Microfoontoegang is geweigerd. De opname gaat verder zonder microfoonaudio.",
"failedToStart": "Opname starten mislukt: {{error}}",
"failedToStartGeneric": "Opname starten mislukt"
},
"updateToast": {
"availableTitle": "Update beschikbaar",
"experimentalAvailableTitle": "Experimentele update beschikbaar",
"experimentalDescription": "Je hebt experimentele updates ingeschakeld, dus je kunt de nieuwste update van Recordly testen voordat die breed beschikbaar is.",
"downloadingTitle": "Update downloaden",
"readyTitle": "Klaar om opnieuw te starten",
"checkErrorTitle": "Kan niet controleren op updates",
"downloadErrorTitle": "Kan de update niet downloaden",
"experimentalBadge": "Experimenteel",
"previewBadge": "Preview",
"notNow": "Niet nu",
"updateNow": "Nu updaten",
"restartToUpdate": "Opnieuw starten om te updaten",
"tryAgain": "Opnieuw proberen"
}
}
+6
View File
@@ -254,5 +254,11 @@
"micLabel": "Microfoon",
"mixedLabel": "Bron",
"deleteRegion": "Audio verwijderen"
},
"updates": {
"experimentalDescription": "Je hebt experimentele updates ingeschakeld, dus je kunt de nieuwste update van Recordly testen voordat die breed beschikbaar is.",
"title": "Updates",
"experimental": "Experimentele updates",
"saveFailed": "Kan het updatekanaal niet wijzigen."
}
}
+15
View File
@@ -76,5 +76,20 @@
"microphoneDenied": "O acesso ao microfone foi negado. A gravação continuará sem áudio do microfone.",
"failedToStart": "Falha ao iniciar gravação: {{error}}",
"failedToStartGeneric": "Falha ao iniciar gravação"
},
"updateToast": {
"availableTitle": "Atualização disponível",
"experimentalAvailableTitle": "Atualização experimental disponível",
"experimentalDescription": "Você ativou as atualizações experimentais, então pode escolher testar a atualização mais recente do Recordly antes que ela fique amplamente disponível.",
"downloadingTitle": "Baixando sua atualização",
"readyTitle": "Pronto para reiniciar",
"checkErrorTitle": "Não foi possível verificar atualizações",
"downloadErrorTitle": "Não foi possível baixar a atualização",
"experimentalBadge": "Experimental",
"previewBadge": "Prévia",
"notNow": "Agora não",
"updateNow": "Atualizar agora",
"restartToUpdate": "Reiniciar para atualizar",
"tryAgain": "Tentar novamente"
}
}
+6
View File
@@ -254,5 +254,11 @@
"micLabel": "Microfone",
"mixedLabel": "Fonte",
"deleteRegion": "Excluir áudio"
},
"updates": {
"experimentalDescription": "Você ativou as atualizações experimentais, então pode escolher testar a atualização mais recente do Recordly antes que ela fique amplamente disponível.",
"title": "Atualizações",
"experimental": "Atualizações experimentais",
"saveFailed": "Falha ao alterar o canal de atualização."
}
}
+15
View File
@@ -76,5 +76,20 @@
"microphoneDenied": "Нет доступа к микрофону. Запись продолжится без вашего голоса.",
"failedToStart": "Не удалось начать запись: {{error}}",
"failedToStartGeneric": "Не удалось начать запись"
},
"updateToast": {
"availableTitle": "Доступно обновление",
"experimentalAvailableTitle": "Доступно экспериментальное обновление",
"experimentalDescription": "Вы включили экспериментальные обновления, поэтому можете протестировать последнее обновление Recordly до его широкого выпуска.",
"downloadingTitle": "Загрузка обновления",
"readyTitle": "Готово к перезапуску",
"checkErrorTitle": "Не удалось проверить обновления",
"downloadErrorTitle": "Не удалось загрузить обновление",
"experimentalBadge": "Экспериментальное",
"previewBadge": "Предпросмотр",
"notNow": "Не сейчас",
"updateNow": "Обновить сейчас",
"restartToUpdate": "Перезапустить для обновления",
"tryAgain": "Повторить"
}
}
+6
View File
@@ -254,5 +254,11 @@
"micLabel": "Микрофон",
"mixedLabel": "Источник",
"deleteRegion": "Удалить аудио"
},
"updates": {
"title": "Обновления",
"experimental": "Экспериментальные обновления",
"saveFailed": "Не удалось изменить канал обновлений.",
"experimentalDescription": "Вы включили экспериментальные обновления, поэтому можете протестировать последнее обновление Recordly до его широкого выпуска."
}
}
+15
View File
@@ -76,5 +76,20 @@
"microphoneDenied": "麦克风访问被拒绝。将继续录制但不包含麦克风音频。",
"failedToStart": "录制启动失败:{{error}}",
"failedToStartGeneric": "录制启动失败"
},
"updateToast": {
"availableTitle": "有可用更新",
"experimentalAvailableTitle": "有可用的实验性更新",
"experimentalDescription": "你已选择接收实验性更新,因此可以在 Recordly 最新更新广泛发布之前选择先行测试。",
"downloadingTitle": "正在下载更新",
"readyTitle": "已准备好重新启动",
"checkErrorTitle": "无法检查更新",
"downloadErrorTitle": "无法下载更新",
"experimentalBadge": "实验性",
"previewBadge": "预览",
"notNow": "暂不",
"updateNow": "立即更新",
"restartToUpdate": "重新启动以更新",
"tryAgain": "重试"
}
}
+6
View File
@@ -254,5 +254,11 @@
"micLabel": "麦克风",
"mixedLabel": "来源",
"deleteRegion": "删除音频"
},
"updates": {
"experimentalDescription": "你已选择接收实验性更新,因此可以在 Recordly 最新更新广泛发布之前选择先行测试。",
"title": "更新",
"experimental": "实验性更新",
"saveFailed": "无法更改更新频道。"
}
}
+15
View File
@@ -76,5 +76,20 @@
"microphoneDenied": "麥克風存取遭拒,將在沒有麥克風音訊的情況下繼續錄製。",
"failedToStart": "無法開始錄製:{{error}}",
"failedToStartGeneric": "無法開始錄製"
},
"updateToast": {
"availableTitle": "有可用更新",
"experimentalAvailableTitle": "有可用的實驗性更新",
"experimentalDescription": "你已選擇接收實驗性更新,因此可以在 Recordly 最新更新廣泛推出之前選擇先行測試。",
"downloadingTitle": "正在下載更新",
"readyTitle": "已準備好重新啟動",
"checkErrorTitle": "無法檢查更新",
"downloadErrorTitle": "無法下載更新",
"experimentalBadge": "實驗性",
"previewBadge": "預覽",
"notNow": "暫不",
"updateNow": "立即更新",
"restartToUpdate": "重新啟動以更新",
"tryAgain": "重試"
}
}
+6
View File
@@ -254,5 +254,11 @@
"micLabel": "麥克風源",
"mixedLabel": "源",
"deleteRegion": "刪除音訊"
},
"updates": {
"experimentalDescription": "你已選擇接收實驗性更新,因此可以在 Recordly 最新更新廣泛推出之前選擇先行測試。",
"title": "更新",
"experimental": "實驗性更新",
"saveFailed": "無法變更更新頻道。"
}
}