// Settings, every part of the app in one place: studios and accounts, app pairing and the ignore list, store reads
// (on launch, every N minutes, how many apps at once, icons), the offline cache, language and theme, notifications,
// local tools, the CLI sidecar, privacy, keyboard shortcuts, updates and reset. A section list on the left jumps.
import { useEffect, useState } from 'react';
import { useScrollSpy } from '../ui/useScrollSpy';
import { api, type CacheInfo, errorText, onUpdateProgress, type Settings as S, type UpdateInfo } from '../api';
import { type Key, t } from '../i18n';
import { ago, bytes, date, num } from '../lib/format';
import { pairCandidates, type UnifiedApp, withPair, withUnpair } from '../lib/identity';
import { useNav } from '../nav';
import { usePrefs } from '../prefs-ctx';
import { useData } from '../store';
import { Icon, type IconName, StoreGlyph } from '../ui/icons';
import { AppIcon, Card, Chip, Kbd, MOD, Notice, Seg, StudioMark } from '../ui/kit';
export const SECTIONS: [string, IconName, Key][] = [
['studios', 'building', 'set.studios'], ['pairing', 'link', 'set.pairing'], ['refresh', 'refresh', 'set.refresh'], ['cache', 'database', 'set.cache'],
['appearance', 'sun', 'settings.appearance'], ['notifications', 'bell', 'set.notifications'], ['dependencies', 'package', 'nav.dependencies'], ['cli', 'terminal', 'set.cli'],
['privacy', 'shield', 'set.privacy'], ['shortcuts', 'keyboard', 'settings.shortcuts'], ['updates', 'download', 'set.updates'], ['reset', 'rotate', 'set.reset'],
];
const SECTION_IDS = SECTIONS.map(([id]) => id);
function Toggle({ id, label, hint, checked, onChange }: { id: string; label: string; hint?: string; checked: boolean; onChange: (v: boolean) => void }) {
return (
);
}
function PairRow({ app, all }: { app: UnifiedApp; all: UnifiedApp[] }) {
const d = useData();
const cands = pairCandidates(app, all);
const r = (app.ios ?? app.android)!;
return (
{app.name}{r.app.bundleId} |
{app.ios && app.android ? {t(`pair.${app.pairing}` as Key)}
: {t('pair.single')}} |
{app.ios && app.android ?
: cands.length ? (
) : {t('set.noCandidates')}}
|
);
}
export function Settings({ onTheme: _onTheme, section }: { onTheme: () => void; section?: string }) {
const d = useData();
const nav = useNav();
const { prefs, update } = usePrefs();
const [error, setError] = useState(null);
const [cache, setCache] = useState(null);
const { active, jump } = useScrollSpy(SECTION_IDS, 'set-', section ?? 'studios', prefs.motion !== 'reduce');
const s = d.settings;
const tools = d.sidecar?.tools;
const set = (patch: (x: S) => S) => d.updateSettings(patch(s));
const loadCache = () => api.cacheInfo().then(setCache, () => {});
useEffect(() => { loadCache(); }, []);
useEffect(() => { if (section) jump(section, { instant: true }); }, [section, jump]);
const clear = () => api.clearCache().then(() => { d.toast({ kind: 'ok', title: t('settings.cacheCleared') }); loadCache(); }, e => setError(errorText(e)));
const reset = async () => {
try {
if (await api.reset()) {
try { localStorage.clear(); } catch { /* private mode */ }
await d.reload();
d.toast({ kind: 'ok', title: t('set.resetDone') });
nav.openWelcome();
}
} catch (e) { setError(errorText(e)); }
};
const storeOnly = d.apps.filter(a => !a.project && !a.ignored);
const paired = storeOnly.filter(a => a.ios && a.android);
const singles = storeOnly.filter(a => !(a.ios && a.android));
return (
{t('nav.settings')}
{t('set.lead')}
{error &&
{error}}
nav.go({ screen: 'accounts' })}>{t('set.manage')}}>
{d.studios.length ? (
{d.studios.map(st => {
const accts = d.accounts.filter(a => a.studio === st.id);
return (
);
})}
) : {t('set.noStudios')}
}
{storeOnly.length ? (
| {t('col.app')} | {t('set.pairedHow')} | {t('col.actions')} |
{[...paired, ...singles].map(a => )}
) : }
{t('set.ignoredLine', { count: d.apps.filter(a => a.ignored).length, accounts: d.overrides.ignored.filter(i => i.startsWith('account:')).length })}
{t('set.autoRefresh')}
set(x => ({ ...x, refresh: { ...x.refresh, autoMinutes: Number(v) as S['refresh']['autoMinutes'] } }))}
options={[['0', t('set.off')], ['15', t('set.min', { n: 15 })], ['30', t('set.min', { n: 30 })], ['60', t('set.hour', { n: 1 })], ['180', t('set.hour', { n: 3 })], ['720', t('set.hour', { n: 12 })]]} />
{t('set.autoHint')}
set(x => ({ ...x, refresh: { ...x.refresh, onLaunch: v } }))} />
set(x => ({ ...x, refresh: { ...x.refresh, icons: v } }))} />
{t('set.parallelHint')}
{d.live ? t('foot.lastRead', { when: ago(d.live.at) }) : t('foot.neverRead')}
- {t('set.cacheSize')}
- {cache ? `${bytes(cache.file_bytes)} · ${t('set.cacheRows', { count: cache.rows })}` : '–'}
- {t('set.cacheOldest')}
- {cache?.oldest ? `${date(cache.oldest)} (${ago(cache.oldest)})` : '–'}
{t('set.keep')}
set(x => ({ ...x, cache: { keepDays: Number(v) as S['cache']['keepDays'] } })).then(loadCache)}
options={[['7', t('set.days', { n: 7 })], ['30', t('set.days', { n: 30 })], ['90', t('set.days', { n: 90 })], ['365', t('set.year')], ['0', t('set.always')]]} />
{t('settings.dataText')}
{t('settings.language')}
update({ ...prefs, lang })} label={t('settings.language')} options={[['system', t('settings.system')], ['en', 'English'], ['tr', 'Türkçe']]} />
{t('settings.theme')}
update({ ...prefs, theme })} label={t('settings.theme')} options={[['system', t('settings.system'), 'settings'], ['light', t('settings.light'), 'sun'], ['dark', t('settings.dark'), 'moon']]} />
{t('settings.motion')}
update({ ...prefs, motion })} label={t('settings.motion')} options={[['system', t('settings.followSystem')], ['reduce', t('settings.reduce')]]} />
set(x => ({ ...x, notify: { ...x.notify, reviewState: v } }))} />
set(x => ({ ...x, notify: { ...x.notify, lowReviews: v } }))} />
set(x => ({ ...x, notify: { ...x.notify, crash: v } }))} />
nav.go({ screen: 'dependencies' })}>{t('set.manage')}}>
{tools ? (
{tools.tools.map(x => {x.name}{x.version ? {x.version} : null}{x.source === 'app' && {t('set.appCopy')}})}
) : –
}
{t('set.depsText')}
- {t('settings.app')}
- {d.info ? `storebound desktop ${d.info.version}${d.info.debug ? ` (${t('settings.debug')})` : ''} · ${d.info.platform}` : '–'}
- CLI
- {d.sidecar ? `storebound ${d.sidecar.version}` : d.sidecarError ?? t('foot.cliStarting')}
- {t('set.protocol')}
- {d.sidecar ? `${d.sidecar.protocol} · ${t('settings.methods', { count: d.sidecar.methods.length })}` : '–'}
- {t('set.state')}
- {d.sidecar?.running ? t('set.running') : d.sidecarError ? t('foot.cliDown') : t('foot.cliStarting')}
- {t('set.writes')}
- {t('set.writesText')}
- {t('set.p1')}
- {t('set.p2')}
- {t('set.p3')}
- {t('set.p4')}
- {t('settings.scPalette')}
- {t('nav.portfolio')}
- {t('settings.scWorkspace')}
- –
- {t('settings.scTabs')}
- {t('settings.scTabArrows')}
- {t('settings.scClose')}
- {t('set.scQuit')}
{t('set.channel')}
set(x => ({ ...x, channel }))} options={[['stable', t('set.stable')], ['beta', t('set.beta')]]} />
{t('set.updatesText')}
{t('set.resetText', { accounts: d.accounts.length, studios: d.studios.length, folders: d.projects.length })}
{t('set.footer', { n: num(d.apps.length) })}
);
}
/** Settings > Updates: checks on click only; installs after the host's native dialog (the signature is checked there). */
function Updates() {
const [info, setInfo] = useState(null);
const [busy, setBusy] = useState<'check' | 'install' | null>(null);
const [error, setError] = useState(null);
const [got, setGot] = useState<{ received: number; total: number | null } | null>(null);
useEffect(() => { const un = onUpdateProgress(setGot); return () => { un.then(f => f()); }; }, []);
const check = async () => {
setBusy('check'); setError(null);
try { setInfo(await api.updateCheck()); } catch (e) { setError(errorText(e)); } finally { setBusy(null); }
};
const install = async (version: string) => {
setBusy('install'); setError(null);
try { await api.updateInstall(version); } catch (e) { setError(errorText(e)); } finally { setBusy(null); setGot(null); }
};
const channel = info ? t(info.channel === 'beta' ? 'set.beta' : 'set.stable') : '';
return (
{error &&
{error}}
{info && !info.available &&
{t('set.updateNone', { version: info.current, channel })}
}
{info?.available &&
{t('set.updateAvailable', { version: info.available })}{info.notes ? ` ${info.notes}` : ''}
}
{busy === 'install' && got &&
{t('set.updateInstalling', { done: got.total ? `${Math.round((got.received / got.total) * 100)}%` : bytes(got.received) })}
}
{info?.available && }
);
}