// Copyright (C) 2024 Vaughn Nugent // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . import { storage } from "webextension-polyfill" import { defaultTo, defaultsDeep, defer, find, isArray, isEmpty, noop } from 'lodash' import { configureApi, debugLog, useAppDataApi, useSession } from '@vnuge/vnlib.browser' import { computed, type MaybeRef, readonly, Ref, shallowRef, watch } from "vue"; import { JsonObject } from "type-fest"; import { Watchable } from "./types"; import { BgRuntime, FeatureApi, optionsOnly, IFeatureExport, exportForegroundApi } from './framework' import { get, set, watchDebounced, useToggle, watchThrottled, controlledRef } from "@vueuse/core"; import { waitForChangeFn, useStorage } from "./util"; import { ServerApi, useServerApi } from "./server-api"; export interface PluginConfig extends JsonObject { readonly discoveryUrl: string; readonly heartbeat: boolean; readonly maxHistory: number; readonly tagFilter: boolean; readonly authPopup: boolean; } //Default storage config const defaultConfig : PluginConfig = Object.freeze({ discoveryUrl: import.meta.env.VITE_DISCOVERY_URL, heartbeat: import.meta.env.VITE_HEARTBEAT_ENABLED === 'true', maxHistory: 50, tagFilter: true, authPopup: true }); export interface EndpointConfig extends JsonObject { readonly apiBaseUrl: string; readonly accountBasePath: string; readonly nostrBasePath: string; readonly dataSyncPath?: string; } export interface ConfigStatus { readonly epConfig: EndpointConfig; readonly isValid: boolean; } export interface AppSettings{ saveConfig(config: PluginConfig): void; useStorageSlot(slot: string, defaultValue: MaybeRef): Ref; useServerSlot(slot: string, silent: boolean, defaultValue: MaybeRef): { state: Ref, sync: () => void } useServerApi(): ServerApi, readonly status: Readonly>; readonly currentConfig: Readonly>; readonly serverEndpoints: Readonly>; } export interface SettingsApi extends FeatureApi, Watchable { getSiteConfig: () => Promise; setSiteConfig: (config: PluginConfig) => Promise; getStatus: () => Promise; testServerAddress: (address: string) => Promise; } interface ServerDiscoveryResult{ readonly endpoints: { readonly name: string; readonly path: string; }[] } const discoverNvaultServer = async (discoveryUrl: string): Promise => { const res = await fetch(discoveryUrl) return await res.json() as ServerDiscoveryResult; } export const useAppSettings = (): AppSettings => { const _storageBackend = storage.local; const store = useStorage(_storageBackend, 'siteConfig', defaultConfig); const endpointConfig = shallowRef({nostrBasePath: '', accountBasePath: '', apiBaseUrl: ''}) const syncEndpoint = computed(() => get(endpointConfig).dataSyncPath || '/app-data') const serverSyncApi = useAppDataApi(syncEndpoint); const status = computed(() => { //get current endpoint config const { nostrBasePath, accountBasePath } = get(endpointConfig); return { epConfig: get(endpointConfig), isValid: !isEmpty(nostrBasePath) && !isEmpty(accountBasePath) } }) const discoverAndSetEndpoints = async (discoveryUrl: string, epConfig: Ref) => { const { endpoints } = await discoverNvaultServer(discoveryUrl); const urls: EndpointConfig = { apiBaseUrl: new URL(discoveryUrl).origin, accountBasePath: find(endpoints, p => p.name == "account")?.path || "/account", nostrBasePath: find(endpoints, p => p.name == "nostr")?.path || "/nostr", dataSyncPath: find(endpoints, p => p.name == "sync")?.path }; //Set once the urls are discovered set(epConfig, urls); } //Merge the default config for nullables with the current config on startyup defaultsDeep(store.value, defaultConfig); //Watch for changes to the discovery url, then cause a discovery watch([store], ([{ discoveryUrl }]) => { defer(async () => { await discoverAndSetEndpoints(discoveryUrl, endpointConfig) const { accountBasePath, apiBaseUrl } = get(endpointConfig); if(!isEmpty(accountBasePath) && !isEmpty(apiBaseUrl)){ configureApi({ session: { cookiesEnabled: false, browserIdSize: 32, }, user: { accountBasePath }, axios: { baseURL: apiBaseUrl, tokenHeader: import.meta.env.VITE_WEB_TOKEN_HEADER, }, storage: localStorage }) } }) }) //Save the config and update the current config const saveConfig = (config: PluginConfig) => set(store, config); //Local reactive server api const serverApi = useServerApi(endpointConfig) return { saveConfig, status, currentConfig: readonly(store), useStorageSlot: (slot: string, defaultValue: MaybeRef) => { return useStorage(_storageBackend, slot, defaultValue) }, useServerSlot: (slot: string, silent: boolean, defaultValue: MaybeRef) => { const session = useSession(); const [onManualTrigger, sync] = useToggle() const state = controlledRef(get(defaultValue)); const syncFromServer = async () => { if (session.loggedIn.value === false) return get(defaultValue); const data = await serverSyncApi.get(slot, false); delete (data as any).getResultOrThrow; return defaultsDeep(data, get(defaultValue)) } const syncToServer = async (value: T) => { if (session.loggedIn.value === false) return; await serverSyncApi.set(slot, value, false); } const syncStateForward = () => defer(syncToServer, state.value) const syncState = async () => silent ? state.silentSet(await syncFromServer()) : state.set(await syncFromServer()) watchThrottled([endpointConfig, session.loggedIn, onManualTrigger], syncState, { throttle: 500 }) watchDebounced(state, syncStateForward, { debounce: 500 }) return { sync, state } }, useServerApi: () => serverApi, serverEndpoints: readonly(endpointConfig) } } export const useSettingsApi = () : IFeatureExport =>{ return{ background: ({ state }: BgRuntime) => { return { waitForChange: waitForChangeFn([state.currentConfig, state.status]), getSiteConfig: () => Promise.resolve(state.currentConfig.value), setSiteConfig: optionsOnly(async (config: PluginConfig): Promise => { //Save the config state.saveConfig(config); debugLog('Config settings saved!'); //Return the config return get(state.currentConfig) }), getStatus: () => { //Since value is computed it needs to be manually unwrapped const { isValid, epConfig } = get(state.status); return Promise.resolve({ isValid, epConfig }) }, testServerAddress: optionsOnly(async (url: string) => { const data = await discoverNvaultServer(url) return isArray(data?.endpoints) && !isEmpty(data.endpoints); }) } }, foreground: exportForegroundApi([ 'getSiteConfig', 'setSiteConfig', 'waitForChange', 'getStatus', 'testServerAddress' ]) } }