import { Ref, computed, ref, watchEffect } from 'vue'; import { defineStore } from 'pinia'; import { listen } from '@tauri-apps/api/event'; import * as path from '@tauri-apps/api/path'; import { PhysicalSize, getCurrentWindow } from '@tauri-apps/api/window'; import { readTextFile, writeTextFile } from '@tauri-apps/plugin-fs'; import { invoke, invoke_nopopup } from './invoke'; import { Dirs, Feature, Game, Package, Profile, ProfileMeta } from './types'; import { changePrimaryColor, hasFeature, pkgKey, shouldPreferDark, } from './util'; type InstallStatus = { pkg: string; }; export const useGeneralStore = defineStore('general', () => { const dirs: Ref = ref(null); const cfgCategories = ref(new Set()); const _cfgSearchTerm = ref(''); const cfgSearchTerm = computed({ set(value: string) { cfgCategories.value.clear(); _cfgSearchTerm.value = value; }, get() { return _cfgSearchTerm.value; }, }); const configDir = computed(() => { if (dirs.value === null) { throw new Error('Invalid directory access'); } return dirs.value.config_dir; }); const dataDir = computed(() => { if (dirs.value === null) { throw new Error('Invalid directory access'); } return dirs.value.data_dir; }); const cacheDir = computed(() => { if (dirs.value === null) { throw new Error('Invalid directory access'); } return dirs.value.cache_dir; }); return { dirs, _cfgSearchTerm, cfgSearchTerm, cfgCategories, configDir, dataDir, cacheDir, }; }); export const usePkgStore = defineStore('pkg', { state: (): { pkg: { [key: string]: Package }; networkStatus: 'connecting' | 'offline' | 'online'; showInstalled: boolean; showDeprecated: boolean; showNSFW: boolean; availableCategories: Set; includeCategories: string[]; excludeCategories: string[]; } => { return { pkg: {}, networkStatus: 'connecting', showInstalled: false, showDeprecated: false, showNSFW: false, availableCategories: new Set(), includeCategories: [], excludeCategories: [], }; }, getters: { fromDepString: (state) => (str: string) => state.pkg[str] ?? null, fromName: (state) => (namespace: string, name: string) => state.pkg[`${namespace}-${name}`] ?? null, all: (state) => Object.values(state), allLocal: (state) => Object.values(state.pkg).filter((p) => p.loc), hasLocal: (state) => (key: string) => state.pkg.hasOwnProperty(key) && state.pkg[key].loc, allRemote: (state) => Object.values(state.pkg).filter( (p) => p.rmt !== null && (state.showInstalled || !p.loc) && (state.showDeprecated || !p.rmt.deprecated) && (state.showNSFW || !p.rmt.nsfw) && (state.includeCategories.length === 0 || p.rmt.categories.some((c) => state.includeCategories.includes(c) )) && (state.excludeCategories.length === 0 || p.rmt.categories.every( (c) => !state.excludeCategories.includes(c) )) ), byFeature: (state) => (feature: Feature) => Object.values(state.pkg).filter((p) => hasFeature(p, feature)), hasAvailableUpdates: (state) => Object.values(state.pkg).some( (p) => p.loc && (p.rmt?.version ?? 0) > p.loc.version ), }, actions: { setupListeners() { listen('install-start', async (ev) => { const key = ev.payload.pkg; await this.reload(key); this.pkg[key].js.downloading = true; }); listen('install-end', async (ev) => { const key = ev.payload.pkg; await this.reload(key); this.pkg[key].js.downloading = false; }); }, async reloadAll() { await invoke('reload_all_packages'); const data = (await invoke('get_all_packages')) as { [key: string]: Package; }; this.availableCategories.clear(); for (const [k, v] of Object.entries(data)) { this.reloadWith(k, v); } }, async reload(pkgOrKey: string | Package) { const key = typeof pkgOrKey === 'string' ? pkgOrKey : pkgKey(pkgOrKey); const pkg: Package = await invoke('get_package', { key, }); this.reloadWith(key, pkg); }, async reloadWith(key: string, pkg: Package) { if (this.pkg[key] === undefined) { this.pkg[key] = { js: { downloading: false } } as Package; } else { this.pkg[key].loc = null; this.pkg[key].rmt = null; } Object.assign(this.pkg[key], pkg); if (!pkg.js) { pkg.js = { downloading: false }; } if (pkg.rmt !== null) { pkg.rmt.categories.forEach((c) => this.availableCategories.add(c) ); pkg.js.downloading = false; } }, async fetch(nopopup: boolean) { this.networkStatus = 'connecting'; try { if (nopopup) { await invoke_nopopup('fetch_listings'); } else { await invoke('fetch_listings'); } this.networkStatus = 'online'; } catch (e) { this.networkStatus = 'offline'; return; } await this.reloadAll(); }, async install(pkg: Package | undefined) { if (pkg === undefined) { return; } try { await invoke('install_package', { key: pkgKey(pkg), force: true, }); } catch (err) { if (pkg !== undefined) { pkg.js.downloading = false; } } }, async installFromKey(key: string) { try { await invoke('install_package', { key, force: true, }); } catch (err) { console.error(err); } }, async updateAll() { const list = []; for (const pkg of this.allLocal) { if (pkg.rmt && pkg.rmt.version > pkg.loc!.version) { list.push(this.install(pkg)); } } await Promise.all(list); }, }, }); export const usePrfStore = defineStore('prf', () => { const current: Ref = ref(null); const list: Ref = ref([]); let timeout: NodeJS.Timeout | null = null; const isPkgEnabled = (pkg: Package | undefined) => computed( () => pkg !== undefined && current.value !== null && current.value?.data.mods.includes(pkgKey(pkg)) ); const isPkgKeyEnabled = (pkg: string) => computed( () => current.value !== null && current.value?.data.mods.includes(pkg) ); const reload = async () => { const p = (await invoke('get_current_profile')) as Profile; current.value = p; if (current.value !== null) { changePrimaryColor(current.value.meta.game); } else { changePrimaryColor(null); } }; const create = async (game: Game) => { try { await invoke('init_profile', { game, name: 'new-profile', }); await reload(); await reloadList(); } catch (e) { current.value = null; } if (current.value !== null) { const pkgs = usePkgStore(); pkgs.reloadAll(); } }; const rename = async (profile: ProfileMeta, name: string) => { await invoke('rename_profile', { profile, name, }); if ( current.value?.meta.game === profile.game && current.value.meta.name === profile.name ) { current.value.meta.name = name; } await reloadList(); }; const switchTo = async (game: Game, name: string) => { await invoke('load_profile', { game, name }); await reload(); if (current.value !== null) { const pkgs = usePkgStore(); pkgs.reloadAll(); } }; const reloadList = async () => { list.value = (await invoke('list_profiles')) as ProfileMeta[]; }; const togglePkg = async ( pkg: Package | string | undefined, enable: boolean ) => { if (pkg === undefined) { return; } await invoke('toggle_package', { key: typeof pkg === 'string' ? pkg : pkgKey(pkg), enable, }); await reload(); }; const generalStore = useGeneralStore(); const configDir = computed(async () => { return await path.join( generalStore.configDir, `profile-${current.value?.meta.game}-${current.value?.meta.name}` ); }); listen('install-end', async () => { await reload(); }); watchEffect(async () => { if (current.value !== null) { await invoke('sync_current_profile', { data: current.value.data, }); if (timeout !== null) { clearTimeout(timeout); } timeout = setTimeout(() => invoke('save_current_profile'), 600); } }); return { current, list, isPkgEnabled, isPkgKeyEnabled, reload, create, rename, switchTo, reloadList, togglePkg, configDir, }; }); export const useClientStore = defineStore('client', () => { type ScaleType = 's' | 'm' | 'l' | 'xl'; const scaleFactor: Ref = ref('s'); const timeout: Ref = ref(null); const offlineMode = ref(false); const enableAutoupdates = ref(true); const verbose = ref(false); const theme: Ref<'light' | 'dark' | 'system'> = ref('system'); const scaleValue = (value: ScaleType) => value === 's' ? 1 : value === 'm' ? 1.25 : value === 'l' ? 1.5 : 2; const setScaleFactor = async (value: ScaleType) => { scaleFactor.value = value; const window = getCurrentWindow(); const w = Math.floor(scaleValue(value) * 900); const h = Math.floor(scaleValue(value) * 480); let size = await window.innerSize(); window.setMinSize(new PhysicalSize(w, h)); window.setSize( new PhysicalSize(Math.max(w, size.width), Math.max(h, size.height)) ); }; const scaleModel = computed({ get() { return scaleFactor; }, async set(value: ScaleType) { await setScaleFactor(value); await save(); }, }); const load = async () => { const generalStore = useGeneralStore(); try { const input = JSON.parse( await readTextFile( await path.join( generalStore.configDir, 'client-options.json' ) ) ); if (input.windowSize) { getCurrentWindow().setSize( new PhysicalSize(input.windowSize.w, input.windowSize.h) ); } if (input.scaleFactor) { await setScaleFactor(input.scaleFactor); } if (input.theme) { theme.value = input.theme; } await setTheme(theme.value); } catch (e) { console.error(`Error reading client options: ${e}`); } offlineMode.value = await invoke('get_global_config', { field: 'offline_mode', }); enableAutoupdates.value = await invoke('get_global_config', { field: 'enable_autoupdates', }); verbose.value = await invoke('get_global_config', { field: 'verbose', }); }; const save = async () => { const generalStore = useGeneralStore(); const w = getCurrentWindow(); const size = await w.innerSize(); await writeTextFile( await path.join(generalStore.configDir, 'client-options.json'), JSON.stringify({ scaleFactor: scaleFactor.value, windowSize: { w: Math.floor(size.width), h: Math.floor(size.height), }, theme: theme.value, }) ); }; const queueSave = async () => { if (timeout.value !== null) { clearTimeout(timeout.value); } timeout.value = setTimeout(async () => { timeout.value = null; await save(); }, 1000); }; const setOfflineMode = async (value: boolean) => { offlineMode.value = value; await invoke('set_global_config', { field: 'offline_mode', value }); }; const setAutoupdates = async (value: boolean) => { enableAutoupdates.value = value; await invoke('set_global_config', { field: 'enable_autoupdates', value, }); }; const setVerbose = async (value: boolean) => { verbose.value = value; await invoke('set_global_config', { field: 'verbose', value }); }; const setTheme = async (value: 'light' | 'dark' | 'system') => { if (value === 'dark') { document.documentElement.classList.add('use-dark-mode'); } else if (value === 'light') { document.documentElement.classList.remove('use-dark-mode'); } else { document.documentElement.classList.toggle( 'use-dark-mode', shouldPreferDark() ); } theme.value = value; await save(); }; getCurrentWindow().onResized(async ({ payload }) => { // For whatever reason this is 0 when minimized if (payload.width > 0) { await queueSave(); } }); return { scaleFactor, offlineMode, enableAutoupdates, verbose, theme, timeout, scaleModel, load, save, queueSave, setOfflineMode, setAutoupdates, setVerbose, setTheme, }; });