import { atom } from 'recoil'; /* import { ProxyItem, ProxiesMapping, DelayMapping } from '~/store/types'; */ import { DispatchFn, FormattedProxyProvider, GetStateFn, ProxiesMapping, ProxyItem, ProxyProvider, State, StateProxies, SwitchProxyCtxItem, } from '~/store/types'; import { ClashAPIConfig } from '~/types'; import * as connAPI from '../api/connections'; import * as proxiesAPI from '../api/proxies'; import { getAutoCloseOldConns, getLatencyTestTimeout, getLatencyTestUrl } from './app'; export const initialState: StateProxies = { proxies: {}, delay: {}, groupNames: [], showModalClosePrevConns: false, }; const noop = () => null; // see all types: // https://github.com/Dreamacro/clash/blob/master/constant/adapters.go // const ProxyTypeBuiltin = ['DIRECT', 'GLOBAL', 'REJECT']; // const ProxyGroupTypes = ['Fallback', 'URLTest', 'Selector', 'LoadBalance']; // const ProxyTypes = ['Shadowsocks', 'Snell', 'Socks5', 'Http', 'Vmess']; export const NonProxyTypes = [ 'Direct', 'Fallback', 'Reject', 'Pass', 'Selector', 'URLTest', 'LoadBalance', 'Unknown', ]; export const getProxies = (s: State) => s.proxies.proxies; export const getDelay = (s: State) => s.proxies.delay; export const getProxyGroupNames = (s: State) => s.proxies.groupNames; export const getProxyProviders = (s: State) => s.proxies.proxyProviders || []; export const getDangleProxyNames = (s: State) => s.proxies.dangleProxyNames; export const getShowModalClosePrevConns = (s: State) => s.proxies.showModalClosePrevConns; export function fetchProxies(apiConfig: ClashAPIConfig) { return async (dispatch: any, getState: any) => { const [proxiesData, providersData] = await Promise.all([ proxiesAPI.fetchProxies(apiConfig), proxiesAPI.fetchProviderProxies(apiConfig), ]); const { providers: proxyProviders, proxies: providerProxies } = formatProxyProviders( providersData.providers, ); const proxies = { ...providerProxies, ...proxiesData.proxies }; // providerProxies has providerName set, but proxiesData.proxies overwrites those entries, // losing providerName. Restore it for all proxies that came from a provider. for (const name of Object.keys(providerProxies)) { if (proxies[name]) { proxies[name] = { ...proxies[name], providerName: providerProxies[name].providerName }; } } const [groupNames, proxyNames] = retrieveGroupNamesFrom(proxies); const delayPrev = getDelay(getState()); const delayNext = { ...delayPrev }; for (let i = 0; i < proxyNames.length; i++) { const name = proxyNames[i]; const { history } = proxies[name] || { history: [] }; const h = history[history.length - 1]; if (h && typeof h.delay === 'number') { delayNext[name] = { number: h.delay }; } } // proxies that are not from a provider const dangleProxyNames = []; for (const v of proxyNames) { if (!providerProxies[v]) dangleProxyNames.push(v); } dispatch('store/proxies#fetchProxies', (s: State) => { s.proxies.proxies = proxies; s.proxies.groupNames = groupNames; s.proxies.delay = delayNext; s.proxies.proxyProviders = proxyProviders; s.proxies.dangleProxyNames = dangleProxyNames; }); }; } export function updateProviderByName(apiConfig: ClashAPIConfig, name: string) { return async (dispatch: DispatchFn) => { try { await proxiesAPI.updateProviderByName(apiConfig, name); } catch (x) { // ignore } // should be optimized // but ¯\_(ツ)_/¯ dispatch(fetchProxies(apiConfig)); }; } export function updateProviders(apiConfig: ClashAPIConfig, names: string[]) { return async (dispatch: DispatchFn) => { for (let i = 0; i < names.length; i++) { try { await proxiesAPI.updateProviderByName(apiConfig, names[i]); } catch (x) { // ignore } } // should be optimized // but ¯\_(ツ)_/¯ dispatch(fetchProxies(apiConfig)); }; } async function healthcheckProviderByNameInternal(apiConfig: ClashAPIConfig, name: string) { try { await proxiesAPI.healthcheckProviderByName(apiConfig, name); } catch (x) { // ignore } } export function healthcheckProviderByName(apiConfig: ClashAPIConfig, name: string) { return async (dispatch: DispatchFn) => { await healthcheckProviderByNameInternal(apiConfig, name); // should be optimized // but ¯\_(ツ)_/¯ await dispatch(fetchProxies(apiConfig)); }; } function updateDelayEntry( dispatch: DispatchFn, getState: GetStateFn, name: string, patch: { number?: number; error?: string; testing?: boolean; updatedAt?: number }, ) { const delayPrev = getDelay(getState()); const prev = delayPrev[name] || {}; dispatch('store/proxies#delay', (s: State) => { s.proxies.delay = { ...delayPrev, [name]: { ...prev, ...patch, }, }; }); } async function closeGroupConns( apiConfig: ClashAPIConfig, groupName: string, exceptionItemName: string, ) { const res = await connAPI.fetchConns(apiConfig); if (!res.ok) { console.log('unable to fetch all connections', res.statusText); /* throw new Error(); */ } const json = await res.json(); const connections = json.connections; const idsToClose = []; for (const conn of connections) { if ( // include the groupName conn.chains.indexOf(groupName) > -1 && // but not include the itemName conn.chains.indexOf(exceptionItemName) < 0 ) { idsToClose.push(conn.id); } } await Promise.all(idsToClose.map((id) => connAPI.closeConnById(apiConfig, id).catch(noop))); } function resolveChain(proxies: ProxiesMapping, groupName: string, itemName: string) { const chain = [itemName, groupName]; let child: ProxyItem; let childKey = itemName; while ((child = proxies[childKey]) && child.now) { chain.unshift(child.now); childKey = child.now; } return chain; } async function switchProxyImpl( dispatch: DispatchFn, getState: GetStateFn, apiConfig: ClashAPIConfig, groupName: string, itemName: string, ) { try { const res = await proxiesAPI.requestToSwitchProxy(apiConfig, groupName, itemName); if (res.ok === false) { throw new Error(`failed to switch proxy: res.statusText`); } } catch (err) { // eslint-disable-next-line no-console console.log(err, 'failed to swith proxy'); throw err; } dispatch(fetchProxies(apiConfig)); const autoCloseOldConns = getAutoCloseOldConns(getState()); if (autoCloseOldConns) { // use fresh state const proxies = getProxies(getState()); // no wait closePrevConns(apiConfig, proxies, { groupName, itemName }); } /* dispatch('showModalClosePrevConns', (s: GlobalState) => { */ /* s.proxies.showModalClosePrevConns = true; */ /* s.proxies.switchProxyCtx = { to: { groupName, itemName } }; */ /* }); */ } function closeModalClosePrevConns() { return (dispatch: DispatchFn) => { dispatch('closeModalClosePrevConns', (s: State) => { s.proxies.showModalClosePrevConns = false; }); }; } function closePrevConns( apiConfig: ClashAPIConfig, proxies: ProxiesMapping, switchTo: SwitchProxyCtxItem, ) { // we must have fetched the proxies before // so the proxies here is fresh /* const proxies = s.proxies.proxies; */ const chain = resolveChain(proxies, switchTo.groupName, switchTo.itemName); closeGroupConns(apiConfig, switchTo.groupName, chain[0]); } function closePrevConnsAndTheModal(apiConfig: ClashAPIConfig) { return async (dispatch: DispatchFn, getState: GetStateFn) => { const s = getState(); const switchTo = s.proxies.switchProxyCtx?.to; if (!switchTo) { dispatch(closeModalClosePrevConns()); return; } // we must have fetched the proxies before // so the proxies here is fresh const proxies = s.proxies.proxies; closePrevConns(apiConfig, proxies, switchTo); dispatch('closePrevConnsAndTheModal', (s: State) => { s.proxies.showModalClosePrevConns = false; s.proxies.switchProxyCtx = undefined; }); }; } export function switchProxy(apiConfig: ClashAPIConfig, groupName: string, itemName: string) { return async (dispatch: DispatchFn, getState: GetStateFn) => { // switch proxy asynchronously switchProxyImpl(dispatch, getState, apiConfig, groupName, itemName).catch(noop); // optimistic UI update dispatch('store/proxies#switchProxy', (s) => { const proxies = s.proxies.proxies; if (proxies[groupName] && proxies[groupName].now) { proxies[groupName].now = itemName; } }); }; } function requestDelayForProxyOnce(apiConfig: ClashAPIConfig, name: string) { return async (dispatch: DispatchFn, getState: GetStateFn) => { let error = ''; let delayNumber: number | undefined; try { const latencyTestUrl = getLatencyTestUrl(getState()); const latencyTestTimeout = getLatencyTestTimeout(getState()); const res = await proxiesAPI.requestDelayForProxy(apiConfig, name, latencyTestUrl, latencyTestTimeout); if (res.ok === false) { error = res.statusText; } const body = await res.json(); delayNumber = body?.delay; } catch (err) { error = (err as Error).message; } const normalizedDelay = typeof delayNumber === 'number' && delayNumber > 0 ? delayNumber : undefined; updateDelayEntry(dispatch, getState, name, { error, number: normalizedDelay, testing: false, updatedAt: Date.now(), }); }; } export function requestDelayForProxy(apiConfig: ClashAPIConfig, name: string) { return async (dispatch: DispatchFn) => { await dispatch(requestDelayForProxyOnce(apiConfig, name)); }; } export function requestDelayForProxies(apiConfig: ClashAPIConfig, names: string[]) { return async (dispatch: DispatchFn, getState: GetStateFn) => { const proxyNames = getDangleProxyNames(getState()); const works = names // remove names that are provided by proxy providers .filter((p) => proxyNames.indexOf(p) > -1) .map((p) => dispatch(requestDelayForProxy(apiConfig, p))); await Promise.all(works); await dispatch(fetchProxies(apiConfig)); }; } export function requestDelayAll(apiConfig: ClashAPIConfig) { return async (dispatch: DispatchFn, getState: GetStateFn) => { const proxyNames = getDangleProxyNames(getState()); await Promise.all(proxyNames.map((p) => dispatch(requestDelayForProxy(apiConfig, p)))); const proxyProviders = getProxyProviders(getState()); // one by one for (const p of proxyProviders) { await healthcheckProviderByNameInternal(apiConfig, p.name); } await dispatch(fetchProxies(apiConfig)); }; } export function healthcheckProxy(apiConfig: ClashAPIConfig, name: string) { return async (dispatch: DispatchFn, getState: GetStateFn) => { updateDelayEntry(dispatch, getState, name, { testing: true, error: '' }); let delayNumber: number | undefined; let error = ''; try { const proxy = getProxies(getState())[name]; const providerName = proxy?.providerName; const latencyTestUrl = getLatencyTestUrl(getState()); const latencyTestTimeout = getLatencyTestTimeout(getState()); const res = providerName ? await proxiesAPI.healthcheckProviderProxy(apiConfig, providerName, name, latencyTestUrl, latencyTestTimeout) : await proxiesAPI.requestDelayForProxy(apiConfig, name, latencyTestUrl, latencyTestTimeout); if (res.ok === false) { error = res.statusText; } const body = await res.json().catch(() => undefined); delayNumber = body?.delay; } catch (err) { error = (err as Error).message || 'Request failed'; } const normalizedDelay = typeof delayNumber === 'number' && delayNumber > 0 ? delayNumber : undefined; const errorMessage = error || (normalizedDelay === undefined ? 'Timeout' : ''); updateDelayEntry(dispatch, getState, name, { number: normalizedDelay, error: errorMessage, testing: false, updatedAt: Date.now(), }); }; } function retrieveGroupNamesFrom(proxies: Record) { let groupNames = []; let globalAll: string[]; const proxyNames = []; for (const prop in proxies) { const p = proxies[prop]; if (p.all && Array.isArray(p.all)) { if (!p.hidden) { groupNames.push(prop); } if (prop === 'GLOBAL') { globalAll = Array.from(p.all); } } else if (NonProxyTypes.indexOf(p.type) < 0) { proxyNames.push(prop); } } if (globalAll) { // Put GLOBAL in the end globalAll.push('GLOBAL'); // Sort groups according to its index in GLOBAL group groupNames = groupNames .map((name) => [globalAll.indexOf(name), name]) .sort((a, b) => a[0] - b[0]) .map((group) => group[1]); } return [groupNames, proxyNames]; } type ProvidersRaw = { [key: string]: ProxyProvider; }; function formatProxyProviders(providersInput: ProvidersRaw): { providers: Array; proxies: { [key: string]: ProxyItem }; } { const keys = Object.keys(providersInput); const providers = []; const proxies = {}; for (let i = 0; i < keys.length; i++) { const provider: ProxyProvider = providersInput[keys[i]]; if (provider.name === 'default' || provider.vehicleType === 'Compatible') { continue; } const proxiesArr = provider.proxies; const names = []; for (let j = 0; j < proxiesArr.length; j++) { const proxy = proxiesArr[j]; proxies[proxy.name] = { ...proxy, providerName: provider.name }; names.push(proxy.name); } const formattedProvider = { ...provider, proxies: names }; providers.push(formattedProvider); } return { providers, proxies, }; } export const actions = { requestDelayForProxies, closeModalClosePrevConns, closePrevConnsAndTheModal, healthcheckProxy, }; export const proxyFilterText = atom({ key: 'proxyFilterText', default: '', });