fix(desktop): restore notification sounds (#2582)

Restore desktop notification sounds by default, add a persisted Play sound setting, and group notification permission and testing in a dedicated Notifications section.
This commit is contained in:
Glen Stampoultzis
2026-08-08 22:28:37 +02:00
committed by GitHub
parent b19db9537c
commit b9b3a24d58
24 changed files with 451 additions and 123 deletions
+4 -3
View File
@@ -31,10 +31,11 @@ export function Alert({
}: AlertProps) { }: AlertProps) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const accentColor = resolveAccentColor(variant, theme); const accentColor = resolveAccentColor(variant, theme);
const borderColor = variant === "success" ? theme.colors.border : accentColor;
const containerStyle = useMemo( const containerStyle = useMemo(
() => [styles.container, accentColor ? { borderColor: accentColor } : null], () => [styles.container, borderColor ? { borderColor } : null],
[accentColor], [borderColor],
); );
const titleStyle = useMemo( const titleStyle = useMemo(
@@ -73,7 +74,7 @@ function resolveAccentColor(
theme: ReturnType<typeof useUnistyles>["theme"], theme: ReturnType<typeof useUnistyles>["theme"],
): string | null { ): string | null {
if (variant === "info") return theme.colors.palette.blue[300]; if (variant === "info") return theme.colors.palette.blue[300];
if (variant === "success") return theme.colors.palette.green[400]; if (variant === "success") return theme.colors.statusSuccess;
if (variant === "warning") return theme.colors.palette.amber[500]; if (variant === "warning") return theme.colors.palette.amber[500];
if (variant === "error") return theme.colors.destructive; if (variant === "error") return theme.colors.destructive;
return null; return null;
@@ -0,0 +1,150 @@
import { useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Text, View } from "react-native";
import { withUnistyles } from "react-native-unistyles";
import { RotateCw } from "lucide-react-native";
import { Alert } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import { DesktopPermissionRow } from "@/desktop/components/desktop-permission-row";
import { useDesktopPermissions } from "@/desktop/permissions/use-desktop-permissions";
import { useDesktopSettings } from "@/desktop/settings/desktop-settings";
import { SettingsSection } from "@/screens/settings/settings-section";
import { settingsStyles } from "@/styles/settings";
const ThemedRotateCw = withUnistyles(RotateCw, (theme) => ({
size: theme.iconSize.md,
color: theme.colors.foregroundMuted,
}));
export function DesktopNotificationsSection() {
const { t } = useTranslation();
const { settings, isSaving, updateSettings } = useDesktopSettings();
const {
isDesktopApp,
snapshot,
isRefreshing,
requestingPermission,
testNotificationState,
refreshPermissions,
requestPermission,
sendTestNotification,
} = useDesktopPermissions();
const handleRefreshPress = useCallback(() => {
void refreshPermissions();
}, [refreshPermissions]);
const handleRequestNotifications = useCallback(() => {
void requestPermission("notifications");
}, [requestPermission]);
const handlePlaySoundChange = useCallback(
(playSound: boolean) => {
void updateSettings({ notifications: { playSound } }).catch(() => {
// useDesktopSettings owns the user-visible IPC error.
});
},
[updateSettings],
);
const handleSendTestNotification = useCallback(() => {
void sendTestNotification();
}, [sendTestNotification]);
const isPermissionBusy = isRefreshing || requestingPermission !== null;
const isSendingTestNotification = testNotificationState.status === "sending";
const refreshIcon = useMemo(() => <ThemedRotateCw />, []);
const refreshButton = useMemo(
() => (
<Button
variant="ghost"
size="sm"
leftIcon={refreshIcon}
onPress={handleRefreshPress}
disabled={isPermissionBusy}
accessibilityLabel={t("settings.notifications.refreshAccessibility")}
>
{isRefreshing ? t("settings.permissions.refreshing") : t("settings.permissions.refresh")}
</Button>
),
[handleRefreshPress, isPermissionBusy, isRefreshing, refreshIcon, t],
);
const permissionLabels = useMemo(
() => ({
granted: t("settings.permissions.actions.granted"),
request: t("settings.permissions.actions.request"),
requesting: t("settings.permissions.actions.requesting"),
}),
[t],
);
if (!isDesktopApp) {
return null;
}
const notificationsGranted = snapshot?.notifications.state === "granted";
return (
<SettingsSection title={t("settings.notifications.title")} trailing={refreshButton}>
<View style={settingsStyles.card}>
<DesktopPermissionRow
title={t("settings.notifications.permission")}
status={snapshot?.notifications ?? null}
isRequesting={requestingPermission === "notifications"}
onRequest={handleRequestNotifications}
labels={permissionLabels}
/>
<View style={[settingsStyles.row, settingsStyles.rowBorder]}>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>{t("settings.notifications.playSound")}</Text>
<Text style={settingsStyles.rowHint}>{t("settings.notifications.playSoundHint")}</Text>
</View>
<Switch
value={settings.notifications.playSound}
onValueChange={handlePlaySoundChange}
disabled={isSaving}
accessibilityLabel={t("settings.notifications.playSound")}
testID="desktop-notifications-play-sound-switch"
/>
</View>
<View style={[settingsStyles.row, settingsStyles.rowBorder]}>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>{t("settings.notifications.test")}</Text>
<Text style={settingsStyles.rowHint}>
{notificationsGranted
? t("settings.notifications.testHint")
: t("settings.notifications.permissionRequired")}
</Text>
</View>
<Button
variant="outline"
size="sm"
onPress={handleSendTestNotification}
disabled={!notificationsGranted || isPermissionBusy || isSendingTestNotification}
>
{isSendingTestNotification
? t("settings.notifications.sending")
: t("settings.notifications.send")}
</Button>
</View>
</View>
{testNotificationState.status === "success" ? (
<Alert
variant="success"
title={t("settings.notifications.sentTitle")}
description={t("settings.notifications.sentDescription")}
testID="desktop-notifications-test-success"
/>
) : null}
{testNotificationState.status === "error" ? (
<Alert
variant="error"
title={t("settings.notifications.sendFailedTitle")}
description={testNotificationState.message}
testID="desktop-notifications-test-error"
/>
) : null}
</SettingsSection>
);
}
@@ -1,6 +1,6 @@
import { useMemo } from "react"; import { useMemo } from "react";
import { View, Text } from "react-native"; import { View, Text } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { Check } from "lucide-react-native"; import { Check } from "lucide-react-native";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { settingsStyles } from "@/styles/settings"; import { settingsStyles } from "@/styles/settings";
@@ -14,16 +14,16 @@ export interface DesktopPermissionRowProps {
granted: string; granted: string;
request: string; request: string;
requesting: string; requesting: string;
busyExtraAction: (label: string) => string;
}; };
showBorder?: boolean; showBorder?: boolean;
onRequest: () => void; onRequest: () => void;
extraActionLabel?: string;
isExtraActionBusy?: boolean;
isExtraActionDisabled?: boolean;
onExtraAction?: () => void;
} }
const ThemedCheck = withUnistyles(Check, (theme) => ({
size: theme.iconSize.sm,
color: theme.colors.foregroundMuted,
}));
export function DesktopPermissionRow({ export function DesktopPermissionRow({
title, title,
status, status,
@@ -31,12 +31,7 @@ export function DesktopPermissionRow({
labels, labels,
showBorder, showBorder,
onRequest, onRequest,
extraActionLabel,
isExtraActionBusy = false,
isExtraActionDisabled = false,
onExtraAction,
}: DesktopPermissionRowProps) { }: DesktopPermissionRowProps) {
const { theme } = useUnistyles();
const state = status?.state ?? "unknown"; const state = status?.state ?? "unknown";
const isGranted = state === "granted"; const isGranted = state === "granted";
const shouldShowDetail = const shouldShowDetail =
@@ -58,21 +53,9 @@ export function DesktopPermissionRow({
</View> </View>
<View style={styles.permissionRowActions}> <View style={styles.permissionRowActions}>
{isGranted ? ( {isGranted ? (
<View style={styles.permissionGrantedActions}> <View style={styles.permissionStatusPill}>
<View style={styles.permissionStatusPill}> <ThemedCheck />
<Check size={theme.iconSize.sm} color={theme.colors.foregroundMuted} /> <Text style={styles.permissionStatusText}>{labels.granted}</Text>
<Text style={styles.permissionStatusText}>{labels.granted}</Text>
</View>
{extraActionLabel && onExtraAction ? (
<Button
variant="outline"
size="sm"
onPress={onExtraAction}
disabled={isExtraActionDisabled || isExtraActionBusy}
>
{isExtraActionBusy ? labels.busyExtraAction(extraActionLabel) : extraActionLabel}
</Button>
) : null}
</View> </View>
) : ( ) : (
<Button variant="outline" size="sm" onPress={onRequest} disabled={isRequesting}> <Button variant="outline" size="sm" onPress={onRequest} disabled={isRequesting}>
@@ -92,11 +75,6 @@ const styles = StyleSheet.create((theme) => ({
alignItems: "flex-end", alignItems: "flex-end",
gap: theme.spacing[1], gap: theme.spacing[1],
}, },
permissionGrantedActions: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
permissionStatusPill: { permissionStatusPill: {
flexDirection: "row", flexDirection: "row",
alignItems: "center", alignItems: "center",
@@ -1,7 +1,7 @@
import { useCallback, useMemo } from "react"; import { useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { View, Text } from "react-native"; import { View } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { withUnistyles } from "react-native-unistyles";
import { RotateCw } from "lucide-react-native"; import { RotateCw } from "lucide-react-native";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { DesktopPermissionRow } from "@/desktop/components/desktop-permission-row"; import { DesktopPermissionRow } from "@/desktop/components/desktop-permission-row";
@@ -9,49 +9,33 @@ import { useDesktopPermissions } from "@/desktop/permissions/use-desktop-permiss
import { settingsStyles } from "@/styles/settings"; import { settingsStyles } from "@/styles/settings";
import { SettingsSection } from "@/screens/settings/settings-section"; import { SettingsSection } from "@/screens/settings/settings-section";
const ThemedRotateCw = withUnistyles(RotateCw, (theme) => ({
size: theme.iconSize.md,
color: theme.colors.foregroundMuted,
}));
export function DesktopPermissionsSection() { export function DesktopPermissionsSection() {
const { t } = useTranslation(); const { t } = useTranslation();
const { theme } = useUnistyles();
const { const {
isDesktopApp, isDesktopApp,
snapshot, snapshot,
isRefreshing, isRefreshing,
requestingPermission, requestingPermission,
isSendingTestNotification,
testNotificationError,
refreshPermissions, refreshPermissions,
requestPermission, requestPermission,
sendTestNotification,
} = useDesktopPermissions(); } = useDesktopPermissions();
const errorTextStyle = useMemo(
() => [styles.errorText, { color: theme.colors.destructive }],
[theme.colors.destructive],
);
const handleRefreshPress = useCallback(() => { const handleRefreshPress = useCallback(() => {
void refreshPermissions(); void refreshPermissions();
}, [refreshPermissions]); }, [refreshPermissions]);
const handleRequestNotifications = useCallback(() => {
void requestPermission("notifications");
}, [requestPermission]);
const handleRequestMicrophone = useCallback(() => { const handleRequestMicrophone = useCallback(() => {
void requestPermission("microphone"); void requestPermission("microphone");
}, [requestPermission]); }, [requestPermission]);
const handleSendTestNotification = useCallback(() => {
void sendTestNotification();
}, [sendTestNotification]);
const isBusy = isRefreshing || requestingPermission !== null; const isBusy = isRefreshing || requestingPermission !== null;
const notificationsGranted = snapshot?.notifications.state === "granted";
const refreshIcon = useMemo( const refreshIcon = useMemo(() => <ThemedRotateCw />, []);
() => <RotateCw size={theme.iconSize.md} color={theme.colors.foregroundMuted} />,
[theme.iconSize.md, theme.colors.foregroundMuted],
);
const refreshButton = useMemo( const refreshButton = useMemo(
() => ( () => (
@@ -74,7 +58,6 @@ export function DesktopPermissionsSection() {
granted: t("settings.permissions.actions.granted"), granted: t("settings.permissions.actions.granted"),
request: t("settings.permissions.actions.request"), request: t("settings.permissions.actions.request"),
requesting: t("settings.permissions.actions.requesting"), requesting: t("settings.permissions.actions.requesting"),
busyExtraAction: (label: string) => t("settings.permissions.actions.busySuffix", { label }),
}), }),
[t], [t],
); );
@@ -86,21 +69,8 @@ export function DesktopPermissionsSection() {
return ( return (
<SettingsSection title={t("settings.permissions.title")} trailing={refreshButton}> <SettingsSection title={t("settings.permissions.title")} trailing={refreshButton}>
<View style={settingsStyles.card}> <View style={settingsStyles.card}>
<DesktopPermissionRow
title={t("settings.permissions.notifications")}
status={snapshot?.notifications ?? null}
isRequesting={requestingPermission === "notifications"}
onRequest={handleRequestNotifications}
labels={permissionLabels}
extraActionLabel={t("settings.permissions.test")}
isExtraActionBusy={isSendingTestNotification}
isExtraActionDisabled={!notificationsGranted || isBusy}
onExtraAction={handleSendTestNotification}
/>
{testNotificationError ? <Text style={errorTextStyle}>{testNotificationError}</Text> : null}
<DesktopPermissionRow <DesktopPermissionRow
title={t("settings.permissions.microphone")} title={t("settings.permissions.microphone")}
showBorder
status={snapshot?.microphone ?? null} status={snapshot?.microphone ?? null}
isRequesting={requestingPermission === "microphone"} isRequesting={requestingPermission === "microphone"}
onRequest={handleRequestMicrophone} onRequest={handleRequestMicrophone}
@@ -110,11 +80,3 @@ export function DesktopPermissionsSection() {
</SettingsSection> </SettingsSection>
); );
} }
const styles = StyleSheet.create((theme) => ({
errorText: {
fontSize: theme.fontSize.xs,
paddingHorizontal: theme.spacing[4],
paddingBottom: theme.spacing[2],
},
}));
@@ -14,13 +14,18 @@ export interface UseDesktopPermissionsReturn {
snapshot: DesktopPermissionSnapshot | null; snapshot: DesktopPermissionSnapshot | null;
isRefreshing: boolean; isRefreshing: boolean;
requestingPermission: DesktopPermissionKind | null; requestingPermission: DesktopPermissionKind | null;
isSendingTestNotification: boolean; testNotificationState: TestNotificationState;
testNotificationError: string | null;
refreshPermissions: () => Promise<void>; refreshPermissions: () => Promise<void>;
requestPermission: (kind: DesktopPermissionKind) => Promise<void>; requestPermission: (kind: DesktopPermissionKind) => Promise<void>;
sendTestNotification: () => Promise<void>; sendTestNotification: () => Promise<void>;
} }
export type TestNotificationState =
| { status: "idle" }
| { status: "sending" }
| { status: "success" }
| { status: "error"; message: string };
export function useDesktopPermissions(): UseDesktopPermissionsReturn { export function useDesktopPermissions(): UseDesktopPermissionsReturn {
const { t } = useTranslation(); const { t } = useTranslation();
const isDesktopApp = shouldShowDesktopPermissionSection(); const isDesktopApp = shouldShowDesktopPermissionSection();
@@ -30,7 +35,9 @@ export function useDesktopPermissions(): UseDesktopPermissionsReturn {
const [requestingPermission, setRequestingPermission] = useState<DesktopPermissionKind | null>( const [requestingPermission, setRequestingPermission] = useState<DesktopPermissionKind | null>(
null, null,
); );
const [isSendingTestNotification, setIsSendingTestNotification] = useState(false); const [testNotificationState, setTestNotificationState] = useState<TestNotificationState>({
status: "idle",
});
useEffect(() => { useEffect(() => {
return () => { return () => {
@@ -111,28 +118,34 @@ export function useDesktopPermissions(): UseDesktopPermissionsReturn {
[isDesktopApp, refreshPermissions, t], [isDesktopApp, refreshPermissions, t],
); );
const [testNotificationError, setTestNotificationError] = useState<string | null>(null);
const sendTestNotification = useCallback(async () => { const sendTestNotification = useCallback(async () => {
if (!isDesktopApp) { if (!isDesktopApp) {
return; return;
} }
setIsSendingTestNotification(true); setTestNotificationState({ status: "sending" });
setTestNotificationError(null);
try { try {
const sent = await sendOsNotification({ const sent = await sendOsNotification({
title: t("desktop.permissions.testNotification.title"), title: t("desktop.permissions.testNotification.title"),
body: t("desktop.permissions.testNotification.body"), body: t("desktop.permissions.testNotification.body"),
}); });
if (!sent) { if (!isMountedRef.current) {
setTestNotificationError(t("desktop.permissions.testNotification.notDelivered")); return;
} }
setTestNotificationState(
sent
? { status: "success" }
: {
status: "error",
message: t("desktop.permissions.testNotification.notDelivered"),
},
);
} catch { } catch {
setTestNotificationError(t("desktop.permissions.testNotification.failed"));
} finally {
if (isMountedRef.current) { if (isMountedRef.current) {
setIsSendingTestNotification(false); setTestNotificationState({
status: "error",
message: t("desktop.permissions.testNotification.failed"),
});
} }
} }
}, [isDesktopApp, t]); }, [isDesktopApp, t]);
@@ -150,8 +163,7 @@ export function useDesktopPermissions(): UseDesktopPermissionsReturn {
snapshot, snapshot,
isRefreshing, isRefreshing,
requestingPermission, requestingPermission,
isSendingTestNotification, testNotificationState,
testNotificationError,
refreshPermissions, refreshPermissions,
requestPermission, requestPermission,
sendTestNotification, sendTestNotification,
@@ -13,6 +13,9 @@ const DESKTOP_SETTINGS_QUERY_KEY = ["desktop-settings"] as const;
export interface DesktopSettings { export interface DesktopSettings {
releaseChannel: ReleaseChannel; releaseChannel: ReleaseChannel;
notifications: {
playSound: boolean;
};
daemon: { daemon: {
manageBuiltInDaemon: boolean; manageBuiltInDaemon: boolean;
keepRunningAfterQuit: boolean; keepRunningAfterQuit: boolean;
@@ -21,11 +24,15 @@ export interface DesktopSettings {
export interface DesktopSettingsPatch { export interface DesktopSettingsPatch {
releaseChannel?: ReleaseChannel; releaseChannel?: ReleaseChannel;
notifications?: Partial<DesktopSettings["notifications"]>;
daemon?: Partial<DesktopSettings["daemon"]>; daemon?: Partial<DesktopSettings["daemon"]>;
} }
export const DEFAULT_DESKTOP_SETTINGS: DesktopSettings = { export const DEFAULT_DESKTOP_SETTINGS: DesktopSettings = {
releaseChannel: "stable", releaseChannel: "stable",
notifications: {
playSound: true,
},
daemon: { daemon: {
manageBuiltInDaemon: true, manageBuiltInDaemon: true,
keepRunningAfterQuit: false, keepRunningAfterQuit: false,
@@ -144,10 +151,17 @@ export async function migrateLegacyDesktopSettings(input: {
function parseDesktopSettings(raw: unknown): DesktopSettings { function parseDesktopSettings(raw: unknown): DesktopSettings {
const record = isRecord(raw) ? raw : {}; const record = isRecord(raw) ? raw : {};
const notifications = isRecord(record.notifications) ? record.notifications : {};
const daemon = isRecord(record.daemon) ? record.daemon : {}; const daemon = isRecord(record.daemon) ? record.daemon : {};
return { return {
releaseChannel: record.releaseChannel === "beta" ? "beta" : "stable", releaseChannel: record.releaseChannel === "beta" ? "beta" : "stable",
notifications: {
playSound:
typeof notifications.playSound === "boolean"
? notifications.playSound
: DEFAULT_DESKTOP_SETTINGS.notifications.playSound,
},
daemon: { daemon: {
manageBuiltInDaemon: manageBuiltInDaemon:
typeof daemon.manageBuiltInDaemon === "boolean" typeof daemon.manageBuiltInDaemon === "boolean"
@@ -167,6 +181,10 @@ function mergeDesktopSettings(
): DesktopSettings { ): DesktopSettings {
return { return {
releaseChannel: updates.releaseChannel ?? current.releaseChannel, releaseChannel: updates.releaseChannel ?? current.releaseChannel,
notifications: {
...current.notifications,
...updates.notifications,
},
daemon: { daemon: {
...current.daemon, ...current.daemon,
...updates.daemon, ...updates.daemon,
@@ -177,6 +195,7 @@ function mergeDesktopSettings(
function normalizePatch(updates: DesktopSettingsPatch): Record<string, unknown> { function normalizePatch(updates: DesktopSettingsPatch): Record<string, unknown> {
return { return {
...(updates.releaseChannel ? { releaseChannel: updates.releaseChannel } : {}), ...(updates.releaseChannel ? { releaseChannel: updates.releaseChannel } : {}),
...(updates.notifications ? { notifications: updates.notifications } : {}),
...(updates.daemon ? { daemon: updates.daemon } : {}), ...(updates.daemon ? { daemon: updates.daemon } : {}),
}; };
} }
@@ -29,6 +29,9 @@ export interface FakeDesktopBridge extends DesktopSettingsBridge {
const DEFAULT_DESKTOP: DesktopSettings = { const DEFAULT_DESKTOP: DesktopSettings = {
releaseChannel: "stable", releaseChannel: "stable",
notifications: {
playSound: true,
},
daemon: { daemon: {
manageBuiltInDaemon: true, manageBuiltInDaemon: true,
keepRunningAfterQuit: false, keepRunningAfterQuit: false,
@@ -255,6 +255,7 @@ describe("loadSettingsFromStorage", () => {
isElectron: true, isElectron: true,
settings: { settings: {
releaseChannel: "beta", releaseChannel: "beta",
notifications: { playSound: true },
daemon: { manageBuiltInDaemon: false, keepRunningAfterQuit: true }, daemon: { manageBuiltInDaemon: false, keepRunningAfterQuit: true },
}, },
}); });
+3 -1
View File
@@ -246,7 +246,9 @@ describe("translation resources", () => {
expect(en.settings.shortcuts.actions.rebind).toBe("Rebind"); expect(en.settings.shortcuts.actions.rebind).toBe("Rebind");
expect(en.settings.integrations.commandLine.title).toBe("Command line"); expect(en.settings.integrations.commandLine.title).toBe("Command line");
expect(en.settings.integrations.skills.updateAvailable).toBe("Update available"); expect(en.settings.integrations.skills.updateAvailable).toBe("Update available");
expect(en.settings.permissions.notifications).toBe("Notifications"); expect(en.settings.notifications.playSound).toBe("Play sound");
expect(en.settings.notifications.permission).toBe("Notification permission");
expect(en.settings.notifications.sentTitle).toBe("Test notification sent");
expect(en.settings.permissions.actions.request).toBe("Request"); expect(en.settings.permissions.actions.request).toBe("Request");
}); });
+16 -3
View File
@@ -1703,6 +1703,7 @@ export const ar: TranslationResources = {
editor: "المحرر", editor: "المحرر",
shortcuts: "الاختصارات", shortcuts: "الاختصارات",
integrations: "التكامل", integrations: "التكامل",
notifications: "الإشعارات",
permissions: "الأذونات", permissions: "الأذونات",
diagnostics: "التشخيص", diagnostics: "التشخيص",
about: "عن", about: "عن",
@@ -1712,6 +1713,21 @@ export const ar: TranslationResources = {
vimKeybindings: "اختصارات Vim", vimKeybindings: "اختصارات Vim",
vimHint: "تنطبق على ملفات المصدر في الويب وسطح المكتب.", vimHint: "تنطبق على ملفات المصدر في الويب وسطح المكتب.",
}, },
notifications: {
title: "الإشعارات",
permission: "إذن الإشعارات",
refreshAccessibility: "تحديث إذن الإشعارات",
playSound: "تشغيل صوت",
playSoundHint: "تشغيل صوت عند وصول إشعار سطح مكتب",
test: "اختبار الإشعار",
testHint: "إرسال إشعار باستخدام هذه الإعدادات",
permissionRequired: "اسمح بالوصول إلى الإشعارات قبل الاختبار",
send: "إرسال",
sending: "جارٍ الإرسال...",
sentTitle: "تم إرسال إشعار الاختبار",
sentDescription: "سلّم Paseo الإشعار إلى نظام التشغيل.",
sendFailedTitle: "تعذر إرسال إشعار الاختبار",
},
hostSections: { hostSections: {
projects: "المشاريع", projects: "المشاريع",
connections: "اتصالات", connections: "اتصالات",
@@ -2006,17 +2022,14 @@ export const ar: TranslationResources = {
}, },
permissions: { permissions: {
title: "الأذونات", title: "الأذونات",
notifications: "إشعارات",
microphone: "ميكروفون", microphone: "ميكروفون",
refresh: "ينعش", refresh: "ينعش",
refreshing: "منعش...", refreshing: "منعش...",
refreshAccessibility: "تحديث أذونات سطح المكتب", refreshAccessibility: "تحديث أذونات سطح المكتب",
test: "امتحان",
actions: { actions: {
granted: "ممنوح", granted: "ممنوح",
request: "طلب", request: "طلب",
requesting: "جارٍ الطلب...", requesting: "جارٍ الطلب...",
busySuffix: "{{label}}...",
}, },
}, },
host: { host: {
+16 -3
View File
@@ -1714,6 +1714,7 @@ export const en = {
editor: "Editor", editor: "Editor",
shortcuts: "Shortcuts", shortcuts: "Shortcuts",
integrations: "Integrations", integrations: "Integrations",
notifications: "Notifications",
permissions: "Permissions", permissions: "Permissions",
diagnostics: "Diagnostics", diagnostics: "Diagnostics",
about: "About", about: "About",
@@ -1723,6 +1724,21 @@ export const en = {
vimKeybindings: "Vim keybindings", vimKeybindings: "Vim keybindings",
vimHint: "Applies to source files on web and desktop.", vimHint: "Applies to source files on web and desktop.",
}, },
notifications: {
title: "Notifications",
permission: "Notification permission",
refreshAccessibility: "Refresh notification permission",
playSound: "Play sound",
playSoundHint: "Play a sound when a desktop notification arrives",
test: "Test notification",
testHint: "Send a notification using these settings",
permissionRequired: "Allow notification access before testing",
send: "Send",
sending: "Sending...",
sentTitle: "Test notification sent",
sentDescription: "Paseo handed the notification to the operating system.",
sendFailedTitle: "Unable to send test notification",
},
hostSections: { hostSections: {
projects: "Projects", projects: "Projects",
connections: "Connections", connections: "Connections",
@@ -2017,17 +2033,14 @@ export const en = {
}, },
permissions: { permissions: {
title: "Permissions", title: "Permissions",
notifications: "Notifications",
microphone: "Microphone", microphone: "Microphone",
refresh: "Refresh", refresh: "Refresh",
refreshing: "Refreshing...", refreshing: "Refreshing...",
refreshAccessibility: "Refresh desktop permissions", refreshAccessibility: "Refresh desktop permissions",
test: "Test",
actions: { actions: {
granted: "Granted", granted: "Granted",
request: "Request", request: "Request",
requesting: "Requesting...", requesting: "Requesting...",
busySuffix: "{{label}}...",
}, },
}, },
host: { host: {
+16 -3
View File
@@ -1746,6 +1746,7 @@ export const es: TranslationResources = {
editor: "Editor", editor: "Editor",
shortcuts: "Atajos", shortcuts: "Atajos",
integrations: "Integraciones", integrations: "Integraciones",
notifications: "Notificaciones",
permissions: "Permisos", permissions: "Permisos",
diagnostics: "Diagnóstico", diagnostics: "Diagnóstico",
about: "Acerca de", about: "Acerca de",
@@ -1755,6 +1756,21 @@ export const es: TranslationResources = {
vimKeybindings: "Atajos de Vim", vimKeybindings: "Atajos de Vim",
vimHint: "Se aplica a archivos fuente en web y escritorio.", vimHint: "Se aplica a archivos fuente en web y escritorio.",
}, },
notifications: {
title: "Notificaciones",
permission: "Permiso de notificaciones",
refreshAccessibility: "Actualizar permiso de notificaciones",
playSound: "Reproducir sonido",
playSoundHint: "Reproduce un sonido cuando llega una notificación de escritorio",
test: "Probar notificación",
testHint: "Envía una notificación con esta configuración",
permissionRequired: "Permite el acceso a las notificaciones antes de probar",
send: "Enviar",
sending: "Enviando...",
sentTitle: "Notificación de prueba enviada",
sentDescription: "Paseo entregó la notificación al sistema operativo.",
sendFailedTitle: "No se pudo enviar la notificación de prueba",
},
hostSections: { hostSections: {
projects: "Proyectos", projects: "Proyectos",
connections: "Conexiones", connections: "Conexiones",
@@ -2056,17 +2072,14 @@ export const es: TranslationResources = {
}, },
permissions: { permissions: {
title: "Permisos", title: "Permisos",
notifications: "Notificaciones",
microphone: "Micrófono", microphone: "Micrófono",
refresh: "Refrescar", refresh: "Refrescar",
refreshing: "Refrescante...", refreshing: "Refrescante...",
refreshAccessibility: "Actualizar permisos de escritorio", refreshAccessibility: "Actualizar permisos de escritorio",
test: "Prueba",
actions: { actions: {
granted: "Otorgada", granted: "Otorgada",
request: "Pedido", request: "Pedido",
requesting: "Solicitando...", requesting: "Solicitando...",
busySuffix: "{{label}}...",
}, },
}, },
host: { host: {
+16 -3
View File
@@ -1750,6 +1750,7 @@ export const fr: TranslationResources = {
editor: "Éditeur", editor: "Éditeur",
shortcuts: "Raccourcis", shortcuts: "Raccourcis",
integrations: "Intégrations", integrations: "Intégrations",
notifications: "Notifications",
permissions: "Autorisations", permissions: "Autorisations",
diagnostics: "Diagnostic", diagnostics: "Diagnostic",
about: "À propos", about: "À propos",
@@ -1759,6 +1760,21 @@ export const fr: TranslationResources = {
vimKeybindings: "Raccourcis Vim", vimKeybindings: "Raccourcis Vim",
vimHint: "S’applique aux fichiers source sur le web et le bureau.", vimHint: "S’applique aux fichiers source sur le web et le bureau.",
}, },
notifications: {
title: "Notifications",
permission: "Autorisation des notifications",
refreshAccessibility: "Actualiser l’autorisation des notifications",
playSound: "Émettre un son",
playSoundHint: "Émet un son lorsqu’une notification de bureau arrive",
test: "Tester la notification",
testHint: "Envoie une notification avec ces réglages",
permissionRequired: "Autorisez l’accès aux notifications avant le test",
send: "Envoyer",
sending: "Envoi...",
sentTitle: "Notification de test envoyée",
sentDescription: "Paseo a transmis la notification au système d’exploitation.",
sendFailedTitle: "Impossible d’envoyer la notification de test",
},
hostSections: { hostSections: {
projects: "Projets", projects: "Projets",
connections: "Relations", connections: "Relations",
@@ -2060,17 +2076,14 @@ export const fr: TranslationResources = {
}, },
permissions: { permissions: {
title: "Autorisations", title: "Autorisations",
notifications: "Notifications",
microphone: "Microphone", microphone: "Microphone",
refresh: "Rafraîchir", refresh: "Rafraîchir",
refreshing: "Rafraîchissant...", refreshing: "Rafraîchissant...",
refreshAccessibility: "Actualiser les autorisations du bureau", refreshAccessibility: "Actualiser les autorisations du bureau",
test: "Test",
actions: { actions: {
granted: "Accordé", granted: "Accordé",
request: "Demande", request: "Demande",
requesting: "Demander...", requesting: "Demander...",
busySuffix: "{{label}}...",
}, },
}, },
host: { host: {
+16 -3
View File
@@ -1719,6 +1719,7 @@ export const ja: TranslationResources = {
editor: "エディター", editor: "エディター",
shortcuts: "ショートカット", shortcuts: "ショートカット",
integrations: "連携", integrations: "連携",
notifications: "通知",
permissions: "権限", permissions: "権限",
diagnostics: "診断", diagnostics: "診断",
about: "アプリ情報", about: "アプリ情報",
@@ -1728,6 +1729,21 @@ export const ja: TranslationResources = {
vimKeybindings: "Vim キーバインド", vimKeybindings: "Vim キーバインド",
vimHint: "Web とデスクトップのソースファイルに適用されます。", vimHint: "Web とデスクトップのソースファイルに適用されます。",
}, },
notifications: {
title: "通知",
permission: "通知の権限",
refreshAccessibility: "通知の権限を更新",
playSound: "サウンドを再生",
playSoundHint: "デスクトップ通知が届いたときにサウンドを再生します",
test: "通知をテスト",
testHint: "現在の設定でテスト通知を送信します",
permissionRequired: "テストする前に通知へのアクセスを許可してください",
send: "送信",
sending: "送信中...",
sentTitle: "テスト通知を送信しました",
sentDescription: "Paseo が通知をオペレーティングシステムに渡しました。",
sendFailedTitle: "テスト通知を送信できません",
},
hostSections: { hostSections: {
projects: "プロジェクト", projects: "プロジェクト",
connections: "接続", connections: "接続",
@@ -2025,17 +2041,14 @@ export const ja: TranslationResources = {
}, },
permissions: { permissions: {
title: "権限", title: "権限",
notifications: "通知",
microphone: "マイク", microphone: "マイク",
refresh: "更新", refresh: "更新",
refreshing: "更新中...", refreshing: "更新中...",
refreshAccessibility: "デスクトップの権限を更新", refreshAccessibility: "デスクトップの権限を更新",
test: "テスト",
actions: { actions: {
granted: "許可済み", granted: "許可済み",
request: "許可を求める", request: "許可を求める",
requesting: "許可を求めています...", requesting: "許可を求めています...",
busySuffix: "{{label}}...",
}, },
}, },
host: { host: {
+16 -3
View File
@@ -1713,6 +1713,7 @@ export const ko: TranslationResources = {
editor: "편집기", editor: "편집기",
shortcuts: "단축키", shortcuts: "단축키",
integrations: "통합", integrations: "통합",
notifications: "알림",
permissions: "권한", permissions: "권한",
diagnostics: "진단", diagnostics: "진단",
about: "정보", about: "정보",
@@ -1722,6 +1723,21 @@ export const ko: TranslationResources = {
vimKeybindings: "Vim 키 바인딩", vimKeybindings: "Vim 키 바인딩",
vimHint: "웹 및 데스크톱의 소스 파일에 적용됩니다.", vimHint: "웹 및 데스크톱의 소스 파일에 적용됩니다.",
}, },
notifications: {
title: "알림",
permission: "알림 권한",
refreshAccessibility: "알림 권한 새로 고침",
playSound: "소리 재생",
playSoundHint: "데스크톱 알림이 도착하면 소리를 재생합니다",
test: "알림 테스트",
testHint: "현재 설정으로 테스트 알림을 보냅니다",
permissionRequired: "테스트하기 전에 알림 접근을 허용하세요",
send: "보내기",
sending: "보내는 중...",
sentTitle: "테스트 알림을 보냈습니다",
sentDescription: "Paseo가 알림을 운영 체제에 전달했습니다.",
sendFailedTitle: "테스트 알림을 보낼 수 없음",
},
hostSections: { hostSections: {
projects: "프로젝트", projects: "프로젝트",
connections: "연결", connections: "연결",
@@ -2018,17 +2034,14 @@ export const ko: TranslationResources = {
}, },
permissions: { permissions: {
title: "권한", title: "권한",
notifications: "알림",
microphone: "마이크", microphone: "마이크",
refresh: "새로고침", refresh: "새로고침",
refreshing: "새로고침 중...", refreshing: "새로고침 중...",
refreshAccessibility: "데스크톱 권한 새로고침", refreshAccessibility: "데스크톱 권한 새로고침",
test: "테스트",
actions: { actions: {
granted: "허용됨", granted: "허용됨",
request: "요청", request: "요청",
requesting: "요청 중...", requesting: "요청 중...",
busySuffix: "{{label}}...",
}, },
}, },
host: { host: {
+16 -3
View File
@@ -1732,6 +1732,7 @@ export const ptBR: TranslationResources = {
editor: "Editor", editor: "Editor",
shortcuts: "Atalhos", shortcuts: "Atalhos",
integrations: "Integrações", integrations: "Integrações",
notifications: "Notificações",
permissions: "Permissões", permissions: "Permissões",
diagnostics: "Diagnósticos", diagnostics: "Diagnósticos",
about: "Sobre", about: "Sobre",
@@ -1741,6 +1742,21 @@ export const ptBR: TranslationResources = {
vimKeybindings: "Atalhos do Vim", vimKeybindings: "Atalhos do Vim",
vimHint: "Aplica-se a arquivos-fonte na web e no desktop.", vimHint: "Aplica-se a arquivos-fonte na web e no desktop.",
}, },
notifications: {
title: "Notificações",
permission: "Permissão de notificações",
refreshAccessibility: "Atualizar permissão de notificações",
playSound: "Reproduzir som",
playSoundHint: "Reproduz um som quando chega uma notificação da área de trabalho",
test: "Testar notificação",
testHint: "Envia uma notificação usando estas configurações",
permissionRequired: "Permita o acesso às notificações antes de testar",
send: "Enviar",
sending: "Enviando...",
sentTitle: "Notificação de teste enviada",
sentDescription: "O Paseo entregou a notificação ao sistema operacional.",
sendFailedTitle: "Não foi possível enviar a notificação de teste",
},
hostSections: { hostSections: {
projects: "Projetos", projects: "Projetos",
connections: "Conexões", connections: "Conexões",
@@ -2040,17 +2056,14 @@ export const ptBR: TranslationResources = {
}, },
permissions: { permissions: {
title: "Permissões", title: "Permissões",
notifications: "Notificações",
microphone: "Microfone", microphone: "Microfone",
refresh: "Atualizar", refresh: "Atualizar",
refreshing: "Atualizando...", refreshing: "Atualizando...",
refreshAccessibility: "Atualizar permissões do desktop", refreshAccessibility: "Atualizar permissões do desktop",
test: "Testar",
actions: { actions: {
granted: "Concedida", granted: "Concedida",
request: "Solicitar", request: "Solicitar",
requesting: "Solicitando...", requesting: "Solicitando...",
busySuffix: "{{label}}...",
}, },
}, },
host: { host: {
+16 -3
View File
@@ -1737,6 +1737,7 @@ export const ru: TranslationResources = {
editor: "Редактор", editor: "Редактор",
shortcuts: "Ярлыки", shortcuts: "Ярлыки",
integrations: "Интеграции", integrations: "Интеграции",
notifications: "Уведомления",
permissions: "Разрешения", permissions: "Разрешения",
diagnostics: "Диагностика", diagnostics: "Диагностика",
about: "О", about: "О",
@@ -1746,6 +1747,21 @@ export const ru: TranslationResources = {
vimKeybindings: "Клавиши Vim", vimKeybindings: "Клавиши Vim",
vimHint: "Применяется к исходным файлам в веб- и настольной версии.", vimHint: "Применяется к исходным файлам в веб- и настольной версии.",
}, },
notifications: {
title: "Уведомления",
permission: "Разрешение на уведомления",
refreshAccessibility: "Обновить разрешение на уведомления",
playSound: "Воспроизводить звук",
playSoundHint: "Воспроизводит звук при получении уведомления на компьютере",
test: "Проверить уведомление",
testHint: "Отправляет уведомление с текущими настройками",
permissionRequired: "Разрешите доступ к уведомлениям перед проверкой",
send: "Отправить",
sending: "Отправка...",
sentTitle: "Тестовое уведомление отправлено",
sentDescription: "Paseo передал уведомление операционной системе.",
sendFailedTitle: "Не удалось отправить тестовое уведомление",
},
hostSections: { hostSections: {
projects: "Проекты", projects: "Проекты",
connections: "Соединения", connections: "Соединения",
@@ -2044,17 +2060,14 @@ export const ru: TranslationResources = {
}, },
permissions: { permissions: {
title: "Разрешения", title: "Разрешения",
notifications: "Уведомления",
microphone: "Микрофон", microphone: "Микрофон",
refresh: "Обновить", refresh: "Обновить",
refreshing: "Освежающий...", refreshing: "Освежающий...",
refreshAccessibility: "Обновить разрешения рабочего стола", refreshAccessibility: "Обновить разрешения рабочего стола",
test: "Тест",
actions: { actions: {
granted: "Предоставленный", granted: "Предоставленный",
request: "Запрос", request: "Запрос",
requesting: "Запрос...", requesting: "Запрос...",
busySuffix: "{{label}}...",
}, },
}, },
host: { host: {
+16 -3
View File
@@ -1683,6 +1683,7 @@ export const zhCN: TranslationResources = {
editor: "编辑器", editor: "编辑器",
shortcuts: "快捷键", shortcuts: "快捷键",
integrations: "集成", integrations: "集成",
notifications: "通知",
permissions: "权限", permissions: "权限",
diagnostics: "诊断", diagnostics: "诊断",
about: "关于", about: "关于",
@@ -1692,6 +1693,21 @@ export const zhCN: TranslationResources = {
vimKeybindings: "Vim 键位", vimKeybindings: "Vim 键位",
vimHint: "适用于网页和桌面端的源文件。", vimHint: "适用于网页和桌面端的源文件。",
}, },
notifications: {
title: "通知",
permission: "通知权限",
refreshAccessibility: "刷新通知权限",
playSound: "播放声音",
playSoundHint: "收到桌面通知时播放声音",
test: "测试通知",
testHint: "使用当前设置发送测试通知",
permissionRequired: "测试前请允许访问通知",
send: "发送",
sending: "正在发送...",
sentTitle: "测试通知已发送",
sentDescription: "Paseo 已将通知交给操作系统。",
sendFailedTitle: "无法发送测试通知",
},
hostSections: { hostSections: {
projects: "项目", projects: "项目",
connections: "连接", connections: "连接",
@@ -1984,17 +2000,14 @@ export const zhCN: TranslationResources = {
}, },
permissions: { permissions: {
title: "权限", title: "权限",
notifications: "通知",
microphone: "麦克风", microphone: "麦克风",
refresh: "刷新", refresh: "刷新",
refreshing: "正在刷新...", refreshing: "正在刷新...",
refreshAccessibility: "刷新桌面端权限", refreshAccessibility: "刷新桌面端权限",
test: "测试",
actions: { actions: {
granted: "已授权", granted: "已授权",
request: "请求", request: "请求",
requesting: "正在请求...", requesting: "正在请求...",
busySuffix: "{{label}}...",
}, },
}, },
host: { host: {
@@ -28,6 +28,7 @@ import {
Keyboard, Keyboard,
Stethoscope, Stethoscope,
Info, Info,
Bell,
Shield, Shield,
Puzzle, Puzzle,
Plus, Plus,
@@ -78,6 +79,7 @@ import { CommunityLinks } from "@/components/community-links";
import { SegmentedControl } from "@/components/ui/segmented-control"; import { SegmentedControl } from "@/components/ui/segmented-control";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem } from "@/components/ui/dropdown-menu"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem } from "@/components/ui/dropdown-menu";
import { DesktopPermissionsSection } from "@/desktop/components/desktop-permissions-section"; import { DesktopPermissionsSection } from "@/desktop/components/desktop-permissions-section";
import { DesktopNotificationsSection } from "@/desktop/components/desktop-notifications-section";
import { BrowserDataSection } from "@/desktop/browser/settings/browser-data-section"; import { BrowserDataSection } from "@/desktop/browser/settings/browser-data-section";
import { IntegrationsSection } from "@/desktop/components/integrations-section"; import { IntegrationsSection } from "@/desktop/components/integrations-section";
import { isElectronRuntime } from "@/desktop/host"; import { isElectronRuntime } from "@/desktop/host";
@@ -154,6 +156,12 @@ const SIDEBAR_SECTION_ITEMS: SidebarSectionItem[] = [
icon: Puzzle, icon: Puzzle,
desktopOnly: true, desktopOnly: true,
}, },
{
id: "notifications",
labelKey: "settings.sections.notifications",
icon: Bell,
desktopOnly: true,
},
{ {
id: "permissions", id: "permissions",
labelKey: "settings.sections.permissions", labelKey: "settings.sections.permissions",
@@ -1427,6 +1435,8 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti
return isDesktopApp ? <KeyboardShortcutsSection /> : null; return isDesktopApp ? <KeyboardShortcutsSection /> : null;
case "integrations": case "integrations":
return isDesktopApp ? <IntegrationsSection /> : null; return isDesktopApp ? <IntegrationsSection /> : null;
case "notifications":
return isDesktopApp ? <DesktopNotificationsSection /> : null;
case "permissions": case "permissions":
return isDesktopApp ? <DesktopPermissionsSection /> : null; return isDesktopApp ? <DesktopPermissionsSection /> : null;
case "diagnostics": case "diagnostics":
@@ -262,6 +262,10 @@ describe("host settings section slugs", () => {
}); });
describe("settings section slugs", () => { describe("settings section slugs", () => {
it("includes desktop notification settings", () => {
expect(isSettingsSectionSlug("notifications")).toBe(true);
});
it("no longer treats daemon as a valid app-level settings section", () => { it("no longer treats daemon as a valid app-level settings section", () => {
expect(isSettingsSectionSlug("daemon")).toBe(false); expect(isSettingsSectionSlug("daemon")).toBe(false);
}); });
+1
View File
@@ -495,6 +495,7 @@ export const SETTINGS_SECTION_SLUGS = [
"editor", "editor",
"shortcuts", "shortcuts",
"integrations", "integrations",
"notifications",
"permissions", "permissions",
"diagnostics", "diagnostics",
"about", "about",
@@ -1,6 +1,7 @@
import path from "node:path"; import path from "node:path";
import { existsSync } from "node:fs"; import { existsSync } from "node:fs";
import { app, BrowserWindow, Notification, ipcMain, nativeImage } from "electron"; import { app, BrowserWindow, Notification, ipcMain, nativeImage } from "electron";
import { getDesktopSettingsStore } from "../settings/desktop-settings-electron.js";
interface NotificationInput { interface NotificationInput {
title?: unknown; title?: unknown;
@@ -95,11 +96,12 @@ export function registerNotificationHandlers(): void {
const body = toTrimmedString(rawInput?.body) ?? undefined; const body = toTrimmedString(rawInput?.body) ?? undefined;
const data = toRecord(rawInput?.data); const data = toRecord(rawInput?.data);
const icon = getNotificationIcon(); const icon = getNotificationIcon();
const settings = await getDesktopSettingsStore().get();
const notification = new Notification({ const notification = new Notification({
title, title,
...(body ? { body } : {}), ...(body ? { body } : {}),
...(icon ? { icon } : {}), ...(icon ? { icon } : {}),
silent: true, silent: !settings.notifications.playSound,
}); });
activeNotifications.add(notification); activeNotifications.add(notification);
@@ -81,6 +81,7 @@ describe("desktop-settings", () => {
expect(settings).toEqual({ expect(settings).toEqual({
releaseChannel: "stable", releaseChannel: "stable",
notifications: { playSound: true },
daemon: { daemon: {
manageBuiltInDaemon: true, manageBuiltInDaemon: true,
keepRunningAfterQuit: false, keepRunningAfterQuit: false,
@@ -102,6 +103,7 @@ describe("desktop-settings", () => {
expect(next).toEqual({ expect(next).toEqual({
releaseChannel: "beta", releaseChannel: "beta",
notifications: { playSound: true },
daemon: { daemon: {
manageBuiltInDaemon: true, manageBuiltInDaemon: true,
keepRunningAfterQuit: false, keepRunningAfterQuit: false,
@@ -110,6 +112,45 @@ describe("desktop-settings", () => {
expect(files).toEqual(["desktop-settings.json"]); expect(files).toEqual(["desktop-settings.json"]);
}); });
it("defaults notification sounds on for existing settings documents", async () => {
const userDataPath = await createTempUserDataDir();
directories.add(userDataPath);
await writeFile(
settingsFilePath(userDataPath),
JSON.stringify({
version: 1,
settings: {
releaseChannel: "stable",
daemon: {
manageBuiltInDaemon: true,
keepRunningAfterQuit: false,
},
},
migrations: {
legacyRendererSettingsImported: true,
daemonStopOnQuitDefaultApplied: true,
},
}),
);
const settings = await createDesktopSettingsStore({ userDataPath }).get();
expect(settings.notifications.playSound).toBe(true);
});
it("keeps an explicit notification sound choice across restarts", async () => {
const userDataPath = await createTempUserDataDir();
directories.add(userDataPath);
await createDesktopSettingsStore({ userDataPath }).patch({
notifications: { playSound: false },
});
const settings = await createDesktopSettingsStore({ userDataPath }).get();
expect(settings.notifications.playSound).toBe(false);
});
it("does not let stale legacy renderer settings override an explicit desktop patch", async () => { it("does not let stale legacy renderer settings override an explicit desktop patch", async () => {
const userDataPath = await createTempUserDataDir(); const userDataPath = await createTempUserDataDir();
directories.add(userDataPath); directories.add(userDataPath);
@@ -229,6 +270,7 @@ describe("desktop-settings", () => {
expect(migrated).toEqual({ expect(migrated).toEqual({
releaseChannel: "beta", releaseChannel: "beta",
notifications: { playSound: true },
daemon: { daemon: {
manageBuiltInDaemon: false, manageBuiltInDaemon: false,
keepRunningAfterQuit: false, keepRunningAfterQuit: false,
@@ -6,6 +6,9 @@ import type { AppReleaseChannel } from "../features/auto-updater.js";
export interface DesktopSettings { export interface DesktopSettings {
releaseChannel: AppReleaseChannel; releaseChannel: AppReleaseChannel;
notifications: {
playSound: boolean;
};
daemon: { daemon: {
manageBuiltInDaemon: boolean; manageBuiltInDaemon: boolean;
keepRunningAfterQuit: boolean; keepRunningAfterQuit: boolean;
@@ -14,6 +17,7 @@ export interface DesktopSettings {
interface DesktopSettingsPatch { interface DesktopSettingsPatch {
releaseChannel?: AppReleaseChannel; releaseChannel?: AppReleaseChannel;
notifications?: Partial<DesktopSettings["notifications"]>;
daemon?: Partial<DesktopSettings["daemon"]>; daemon?: Partial<DesktopSettings["daemon"]>;
} }
@@ -38,6 +42,9 @@ export interface DesktopSettingsStore {
export const DEFAULT_DESKTOP_SETTINGS: DesktopSettings = { export const DEFAULT_DESKTOP_SETTINGS: DesktopSettings = {
releaseChannel: "stable", releaseChannel: "stable",
notifications: {
playSound: true,
},
daemon: { daemon: {
manageBuiltInDaemon: true, manageBuiltInDaemon: true,
keepRunningAfterQuit: false, keepRunningAfterQuit: false,
@@ -73,6 +80,7 @@ function buildDefaultDocument(): PersistedDesktopSettingsDocument {
version: 1, version: 1,
settings: { settings: {
releaseChannel: DEFAULT_DESKTOP_SETTINGS.releaseChannel, releaseChannel: DEFAULT_DESKTOP_SETTINGS.releaseChannel,
notifications: { ...DEFAULT_DESKTOP_SETTINGS.notifications },
daemon: { ...DEFAULT_DESKTOP_SETTINGS.daemon }, daemon: { ...DEFAULT_DESKTOP_SETTINGS.daemon },
}, },
migrations: { migrations: {
@@ -85,6 +93,7 @@ function buildDefaultDocument(): PersistedDesktopSettingsDocument {
function coerceDesktopSettings(input: unknown): DesktopSettings { function coerceDesktopSettings(input: unknown): DesktopSettings {
const result: DesktopSettings = { const result: DesktopSettings = {
releaseChannel: DEFAULT_DESKTOP_SETTINGS.releaseChannel, releaseChannel: DEFAULT_DESKTOP_SETTINGS.releaseChannel,
notifications: { ...DEFAULT_DESKTOP_SETTINGS.notifications },
daemon: { ...DEFAULT_DESKTOP_SETTINGS.daemon }, daemon: { ...DEFAULT_DESKTOP_SETTINGS.daemon },
}; };
@@ -97,6 +106,13 @@ function coerceDesktopSettings(input: unknown): DesktopSettings {
result.releaseChannel = releaseChannel; result.releaseChannel = releaseChannel;
} }
if (isRecord(input.notifications)) {
const playSound = coerceBoolean(input.notifications.playSound);
if (playSound !== null) {
result.notifications.playSound = playSound;
}
}
if (isRecord(input.daemon)) { if (isRecord(input.daemon)) {
const manageBuiltInDaemon = coerceBoolean(input.daemon.manageBuiltInDaemon); const manageBuiltInDaemon = coerceBoolean(input.daemon.manageBuiltInDaemon);
if (manageBuiltInDaemon !== null) { if (manageBuiltInDaemon !== null) {
@@ -124,6 +140,13 @@ function coerceDesktopSettingsPatch(input: unknown): DesktopSettingsPatch {
patch.releaseChannel = releaseChannel; patch.releaseChannel = releaseChannel;
} }
if (isRecord(input.notifications)) {
const playSound = coerceBoolean(input.notifications.playSound);
if (playSound !== null) {
patch.notifications = { playSound };
}
}
if (isRecord(input.daemon)) { if (isRecord(input.daemon)) {
const daemonPatch: Partial<DesktopSettings["daemon"]> = {}; const daemonPatch: Partial<DesktopSettings["daemon"]> = {};
const manageBuiltInDaemon = coerceBoolean(input.daemon.manageBuiltInDaemon); const manageBuiltInDaemon = coerceBoolean(input.daemon.manageBuiltInDaemon);
@@ -169,6 +192,7 @@ function mergeDesktopSettings(
): DesktopSettings { ): DesktopSettings {
return { return {
releaseChannel: patch.releaseChannel ?? current.releaseChannel, releaseChannel: patch.releaseChannel ?? current.releaseChannel,
notifications: { ...current.notifications, ...patch.notifications },
daemon: { ...current.daemon, ...patch.daemon }, daemon: { ...current.daemon, ...patch.daemon },
}; };
} }