= {
align?: MenuAlign;
offset?: number;
scrollable?: boolean;
+ optionStyle?: CSSProperties;
renderTrigger?: (args: SettingMenuRenderTriggerArgs
) => ReactNode;
renderOption?: (args: SettingMenuRenderOptionArgs) => ReactNode;
};
@@ -61,6 +63,7 @@ export function SettingMenuSelector({
align = 'End',
offset = 5,
scrollable = false,
+ optionStyle,
renderTrigger,
renderOption,
}: SettingMenuSelectorProps) {
@@ -100,6 +103,7 @@ export function SettingMenuSelector({
aria-selected={selected}
disabled={option.disabled || isDisabled}
onClick={select}
+ style={optionStyle}
>
{renderOption({ option, selected, select })}
@@ -116,6 +120,7 @@ export function SettingMenuSelector({
disabled={option.disabled || isDisabled}
onClick={select}
before={option.icon}
+ style={optionStyle}
>
diff --git a/src/app/features/settings/cosmetics/AppIconSettings.test.tsx b/src/app/features/settings/cosmetics/AppIconSettings.test.tsx
new file mode 100644
index 0000000000..12d80f10b6
--- /dev/null
+++ b/src/app/features/settings/cosmetics/AppIconSettings.test.tsx
@@ -0,0 +1,138 @@
+import { fireEvent, render, screen, waitFor } from '@testing-library/react';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { AppIconRuntimeFeature, AppIconSettings } from './AppIconSettings';
+
+const { invoke, isAndroidTauri, isMobileOrTablet, isMobileTauri, setAppIconId, settings } =
+ vi.hoisted(() => ({
+ invoke: vi.fn<(command: string, args?: unknown) => Promise>(),
+ isAndroidTauri: vi.fn<() => boolean>(),
+ isMobileOrTablet: vi.fn<() => boolean>(),
+ isMobileTauri: vi.fn<() => boolean>(),
+ setAppIconId: vi.fn<(value: string | undefined) => void>(),
+ settings: { appIconId: undefined as string | undefined },
+ }));
+
+vi.mock('@tauri-apps/api/core', () => ({ invoke }));
+vi.mock('$utils/platform', () => ({ isAndroidTauri, isMobileOrTablet, isMobileTauri }));
+vi.mock('$state/hooks/settings', () => ({ useSetting: () => [settings.appIconId, setAppIconId] }));
+vi.mock('$components/sequence-card', () => ({
+ SequenceCard: ({ children }: { children: React.ReactNode }) => {children}
,
+ SequenceCardStyle: 'card',
+}));
+vi.mock('$components/setting-tile', () => ({
+ SettingTile: ({ title, after }: { title: string; after: React.ReactNode }) => (
+
+ {title}
+ {after}
+
+ ),
+}));
+vi.mock('$components/setting-menu-selector', () => ({
+ SettingMenuSelector: ({
+ options,
+ onSelect,
+ }: {
+ options: { value: string; label: string; icon?: React.ReactNode }[];
+ onSelect: (value: string) => void;
+ }) => (
+ <>
+ {options.map((option) => (
+
+ ))}
+ >
+ ),
+}));
+
+describe('AppIconSettings', () => {
+ beforeEach(() => {
+ invoke.mockReset();
+ isAndroidTauri.mockReturnValue(false);
+ isMobileOrTablet.mockReturnValue(false);
+ isMobileTauri.mockReset();
+ setAppIconId.mockReset();
+ settings.appIconId = undefined;
+ });
+
+ it('is hidden outside mobile Tauri', () => {
+ isMobileTauri.mockReturnValue(false);
+
+ render();
+
+ expect(screen.queryByText('App Icon')).not.toBeInTheDocument();
+ expect(invoke).not.toHaveBeenCalled();
+ });
+
+ it('is hidden when the native bundle has no alternate icons', async () => {
+ isMobileTauri.mockReturnValue(true);
+ invoke.mockResolvedValue([]);
+
+ render();
+
+ await waitFor(() => expect(invoke).toHaveBeenCalled());
+ expect(screen.queryByText('App Icon')).not.toBeInTheDocument();
+ });
+
+ it('persists an icon only after native selection succeeds', async () => {
+ isMobileTauri.mockReturnValue(true);
+ invoke.mockResolvedValueOnce(['propeler']).mockResolvedValueOnce(undefined);
+
+ render();
+
+ await screen.findByText('App Icon');
+ expect(screen.getByTestId('app-icon-preview-primary')).toBeInTheDocument();
+ expect(screen.getByTestId('app-icon-preview-propeler')).toBeInTheDocument();
+ expect(screen.getByTestId('app-icon-preview-primary')).toHaveStyle({ borderRadius: '22.5%' });
+ fireEvent.click(screen.getByRole('button', { name: 'Propeler' }));
+
+ await waitFor(() => {
+ expect(invoke).toHaveBeenLastCalledWith('plugin:app-icon|set_icon', {
+ request: { icon: 'propeler' },
+ });
+ });
+ expect(setAppIconId).toHaveBeenCalledWith('propeler');
+ });
+
+ it('renders circular previews on Android', async () => {
+ isMobileTauri.mockReturnValue(true);
+ isAndroidTauri.mockReturnValue(true);
+ invoke.mockResolvedValue(['propeler']);
+
+ render();
+
+ expect(await screen.findByTestId('app-icon-preview-propeler')).toHaveStyle({
+ borderRadius: '50%',
+ });
+ });
+
+ it('restores the persisted icon when an update resets the native selection', async () => {
+ isMobileTauri.mockReturnValue(true);
+ settings.appIconId = 'propeler';
+ invoke
+ .mockResolvedValueOnce(['propeler'])
+ .mockResolvedValueOnce(null)
+ .mockResolvedValueOnce(undefined);
+
+ render();
+
+ await waitFor(() => {
+ expect(invoke).toHaveBeenLastCalledWith('plugin:app-icon|set_icon', {
+ request: { icon: 'propeler' },
+ });
+ });
+ });
+
+ it('leaves the native selection alone when it already matches', async () => {
+ isMobileTauri.mockReturnValue(true);
+ settings.appIconId = 'propeler';
+ invoke.mockResolvedValueOnce(['propeler']).mockResolvedValueOnce('propeler');
+
+ render();
+
+ await waitFor(() => expect(invoke).toHaveBeenCalledTimes(2));
+ expect(invoke).not.toHaveBeenCalledWith('plugin:app-icon|set_icon', expect.anything());
+ });
+});
diff --git a/src/app/features/settings/cosmetics/AppIconSettings.tsx b/src/app/features/settings/cosmetics/AppIconSettings.tsx
new file mode 100644
index 0000000000..5afa180ff0
--- /dev/null
+++ b/src/app/features/settings/cosmetics/AppIconSettings.tsx
@@ -0,0 +1,129 @@
+import { invoke } from '@tauri-apps/api/core';
+import { useEffect, useState } from 'react';
+
+import { SettingMenuSelector } from '$components/setting-menu-selector';
+import { SequenceCard, SequenceCardStyle } from '$components/sequence-card';
+import { SettingTile } from '$components/setting-tile';
+import { useSetting } from '$state/hooks/settings';
+import { settingsAtom } from '$state/settings';
+import { isAndroidTauri, isMobileTauri } from '$utils/platform';
+import defaultIcon from './app-icons/default.png';
+import propelerIcon from './app-icons/propeler.png';
+
+const PRIMARY_ICON = 'primary';
+const APP_ICON_PREVIEWS: Record = {
+ [PRIMARY_ICON]: defaultIcon,
+ propeler: propelerIcon,
+};
+
+function AppIconPreview({ icon }: { icon: string }) {
+ const src = APP_ICON_PREVIEWS[icon];
+ if (!src) return null;
+
+ return (
+
+ );
+}
+
+export function AppIconRuntimeFeature() {
+ const [appIconId] = useSetting(settingsAtom, 'appIconId');
+
+ useEffect(() => {
+ if (!isMobileTauri()) return;
+
+ let cancelled = false;
+ Promise.all([
+ invoke('plugin:app-icon|get_available_icons'),
+ invoke('plugin:app-icon|get_current_icon'),
+ ])
+ .then(async ([icons, current]) => {
+ const icon = icons.includes(appIconId ?? '') ? appIconId! : null;
+ if (!cancelled && current !== icon) {
+ await invoke('plugin:app-icon|set_icon', { request: { icon } });
+ }
+ })
+ .catch(() => {});
+
+ return () => {
+ cancelled = true;
+ };
+ }, [appIconId]);
+
+ return null;
+}
+
+export function AppIconSettings() {
+ const [appIconId, setAppIconId] = useSetting(settingsAtom, 'appIconId');
+ const [icons, setIcons] = useState();
+ const [changing, setChanging] = useState(false);
+
+ useEffect(() => {
+ if (!isMobileTauri()) return;
+
+ let cancelled = false;
+ invoke('plugin:app-icon|get_available_icons')
+ .then((availableIcons) => {
+ if (!cancelled) setIcons(availableIcons);
+ })
+ .catch(() => {
+ if (!cancelled) setIcons([]);
+ });
+
+ return () => {
+ cancelled = true;
+ };
+ }, []);
+
+ if (!icons?.length) return null;
+
+ const options = [
+ { value: PRIMARY_ICON, label: 'Default', icon: },
+ ...icons.map((icon) => ({
+ value: icon,
+ label: icon === 'propeler' ? 'Propeler' : icon,
+ icon: ,
+ })),
+ ];
+ const selectedIcon = icons.includes(appIconId ?? '') ? appIconId! : PRIMARY_ICON;
+
+ const selectIcon = async (icon: string) => {
+ if (changing || icon === selectedIcon) return;
+
+ setChanging(true);
+ try {
+ await invoke('plugin:app-icon|set_icon', {
+ request: { icon: icon === PRIMARY_ICON ? null : icon },
+ });
+ setAppIconId(icon === PRIMARY_ICON ? undefined : icon);
+ } finally {
+ setChanging(false);
+ }
+ };
+
+ return (
+
+
+ }
+ />
+
+ );
+}
diff --git a/src/app/features/settings/cosmetics/Cosmetics.tsx b/src/app/features/settings/cosmetics/Cosmetics.tsx
index 9e6a69b904..d478333c00 100644
--- a/src/app/features/settings/cosmetics/Cosmetics.tsx
+++ b/src/app/features/settings/cosmetics/Cosmetics.tsx
@@ -14,6 +14,7 @@ import { SettingTile, SettingToggle } from '$components/setting-tile';
import { stopPropagation } from '$utils/keyboard';
import { Appearance } from './Themes';
import { LanguageSpecificPronouns } from './LanguageSpecificPronouns';
+import { AppIconSettings } from './AppIconSettings';
function PronounPillMaxCountInput({ disabled }: { disabled: boolean }) {
const [maxCount, setMaxCount] = useSetting(settingsAtom, 'pronounPillMaxCount');
@@ -518,6 +519,7 @@ export function Cosmetics({ requestBack, requestClose }: CosmeticsProps) {
{!themeBrowserOpen && (
<>
+
diff --git a/src/app/features/settings/cosmetics/app-icons/default.png b/src/app/features/settings/cosmetics/app-icons/default.png
new file mode 100644
index 0000000000..32db07c0d1
Binary files /dev/null and b/src/app/features/settings/cosmetics/app-icons/default.png differ
diff --git a/src/app/features/settings/cosmetics/app-icons/propeler.png b/src/app/features/settings/cosmetics/app-icons/propeler.png
new file mode 100644
index 0000000000..99cee414e5
Binary files /dev/null and b/src/app/features/settings/cosmetics/app-icons/propeler.png differ
diff --git a/src/app/features/settings/settingsLink.ts b/src/app/features/settings/settingsLink.ts
index aed1ab18ac..7db60f8e2a 100644
--- a/src/app/features/settings/settingsLink.ts
+++ b/src/app/features/settings/settingsLink.ts
@@ -115,6 +115,7 @@ export const settingsLinkFocusIdsBySection: Record