diff --git a/src/assets/index.d.ts b/src/assets/index.d.ts new file mode 100644 index 0000000..31dca6b --- /dev/null +++ b/src/assets/index.d.ts @@ -0,0 +1 @@ +declare module '*.png' diff --git a/src/containers/App.tsx b/src/containers/App.tsx index b5f5498..22d8b2b 100644 --- a/src/containers/App.tsx +++ b/src/containers/App.tsx @@ -1,4 +1,4 @@ -import React, { useEffect } from 'react' +import React from 'react' import { Route, Redirect, Switch } from 'react-router-dom' import classnames from 'classnames' import { isClashX } from '@lib/jsBridge' @@ -11,15 +11,13 @@ import Settings from '@containers/Settings' import SlideBar from '@containers/Sidebar' import Connections from '@containers/Connections' import ExternalControllerModal from '@containers/ExternalControllerDrawer' -import { getLogsStreamReader } from '@lib/request' +import { useLogsStreamReader } from '@stores' import '../styles/common.scss' import '../styles/iconfont.scss' export default function App () { - useEffect(() => { - getLogsStreamReader() - }, []) + useLogsStreamReader() const routes = [ // { path: '/', name: 'Overview', component: Overview, exact: true }, diff --git a/src/containers/Connections/index.tsx b/src/containers/Connections/index.tsx index 030e788..6eb3a82 100644 --- a/src/containers/Connections/index.tsx +++ b/src/containers/Connections/index.tsx @@ -4,9 +4,8 @@ import classnames from 'classnames' import { useScroll } from 'react-use' import { groupBy } from 'lodash-es' import { Header, Card, Checkbox, Modal, Icon } from '@components' -import { useI18n } from '@stores' +import { useClient, useConnectionStreamReader, useI18n } from '@stores' import * as API from '@lib/request' -import { StreamReader } from '@lib/streamer' import { useObject, useVisible } from '@lib/hook' import { fromNow } from '@lib/date' import { RuleType } from '@models' @@ -93,6 +92,8 @@ interface formatConnection { export default function Connections() { const { translation, lang } = useI18n() const t = useMemo(() => translation('Connections').t, [translation]) + const connStreamReader = useConnectionStreamReader() + const client = useClient() // total const [traffic, setTraffic] = useObject({ @@ -103,7 +104,7 @@ export default function Connections() { // close all connections const { visible, show, hide } = useVisible() function handleCloseConnections() { - API.closeAllConnections().finally(() => hide()) + client.closeAllConnections().finally(() => hide()) } // connections @@ -161,8 +162,6 @@ export default function Connections() { ] as TableColumnOption[], [t]) useLayoutEffect(() => { - let streamReader: StreamReader | null = null - function handleConnection(snapshots: API.Snapshot[]) { for (const snapshot of snapshots) { setTraffic({ @@ -174,18 +173,12 @@ export default function Connections() { } } - (async function () { - streamReader = await API.getConnectionStreamReader() - streamReader.subscribe('data', handleConnection) - }()) - + connStreamReader?.subscribe('data', handleConnection) return () => { - if (streamReader) { - streamReader.unsubscribe('data', handleConnection) - streamReader.destory() - } + connStreamReader?.unsubscribe('data', handleConnection) + connStreamReader?.destory() } - }, [feed, setTraffic]) + }, [connStreamReader, feed, setTraffic]) const { getTableProps, diff --git a/src/containers/ExternalControllerDrawer/index.tsx b/src/containers/ExternalControllerDrawer/index.tsx index 5f4c732..c98bba5 100644 --- a/src/containers/ExternalControllerDrawer/index.tsx +++ b/src/containers/ExternalControllerDrawer/index.tsx @@ -1,14 +1,17 @@ import React, { useEffect } from 'react' +import { useAtom } from 'jotai' +import { useUpdateAtom } from 'jotai/utils' import { useObject } from '@lib/hook' import { Modal, Input, Alert } from '@components' -import { useI18n, useAPIInfo, useIdentity } from '@stores' +import { useI18n, useAPIInfo, identityAtom } from '@stores' +import { localStorageAtom } from '@stores/request' import './style.scss' export default function ExternalController () { const { translation } = useI18n() const { t } = translation('Settings') - const { data: info, update, fetch } = useAPIInfo() - const { identity, set: setIdentity } = useIdentity() + const { hostname, port, secret } = useAPIInfo() + const [identity, setIdentity] = useAtom(identityAtom) const [value, set] = useObject({ hostname: '', port: '', @@ -16,16 +19,14 @@ export default function ExternalController () { }) useEffect(() => { - fetch() - }, [fetch]) + set({ hostname, port, secret }) + }, [hostname, port, secret, set]) - useEffect(() => { - set({ hostname: info.hostname, port: info.port, secret: info.secret }) - }, [info, set]) + const setter = useUpdateAtom(localStorageAtom) function handleOk () { const { hostname, port, secret } = value - update({ hostname, port, secret }) + setter([{ hostname, port, secret }]) } return ( diff --git a/src/containers/Logs/index.tsx b/src/containers/Logs/index.tsx index 8fba84b..0f30960 100644 --- a/src/containers/Logs/index.tsx +++ b/src/containers/Logs/index.tsx @@ -1,9 +1,7 @@ import React, { useLayoutEffect, useEffect, useRef, useState } from 'react' import dayjs from 'dayjs' -import { useI18n } from '@stores' +import { useI18n, useLogsStreamReader } from '@stores' import { Card, Header } from '@components' -import { getLogsStreamReader } from '@lib/request' -import { StreamReader } from '@lib/streamer' import { Log } from '@models/Log' import './style.scss' @@ -13,6 +11,7 @@ export default function Logs () { const [logs, setLogs] = useState([]) const { translation } = useI18n() const { t } = translation('Logs') + const logsStreamReader = useLogsStreamReader() useLayoutEffect(() => { const ul = listRef.current @@ -22,22 +21,19 @@ export default function Logs () { }) useEffect(() => { - let streamReader: StreamReader | null = null - function handleLog (newLogs: Log[]) { logsRef.current = logsRef.current.slice().concat(newLogs.map(d => ({ ...d, time: new Date() }))) setLogs(logsRef.current) } - (async function () { - streamReader = await getLogsStreamReader() - logsRef.current = streamReader.buffer() + if (logsStreamReader) { + logsStreamReader.subscribe('data', handleLog) + logsRef.current = logsStreamReader.buffer() setLogs(logsRef.current) - streamReader.subscribe('data', handleLog) - }()) + } - return () => streamReader?.unsubscribe('data', handleLog) - }, []) + return () => logsStreamReader?.unsubscribe('data', handleLog) + }, [logsStreamReader]) return (
diff --git a/src/containers/Proxies/components/Group/index.tsx b/src/containers/Proxies/components/Group/index.tsx index 17a4bbe..555b966 100644 --- a/src/containers/Proxies/components/Group/index.tsx +++ b/src/containers/Proxies/components/Group/index.tsx @@ -1,7 +1,7 @@ import React, { useMemo } from 'react' import { useAtom } from 'jotai' -import { useProxy, useConfig, proxyMapping } from '@stores' -import { changeProxySelected, Group as IGroup, getConnections, closeConnection } from '@lib/request' +import { useProxy, useConfig, proxyMapping, useClient } from '@stores' +import { Group as IGroup } from '@lib/request' import { Tags, Tag } from '@components' import './style.scss' @@ -13,14 +13,15 @@ export function Group (props: GroupProps) { const { markProxySelected } = useProxy() const [proxyMap] = useAtom(proxyMapping) const { data: Config } = useConfig() + const client = useClient() const { config } = props async function handleChangeProxySelected (name: string) { - await changeProxySelected(props.config.name, name) + await client.changeProxySelected(props.config.name, name) markProxySelected(props.config.name, name) if (Config.breakConnections) { const list: string[] = [] - const snapshot = await getConnections() + const snapshot = await client.getConnections() for (const connection of snapshot.data.connections) { if (connection.chains.includes(props.config.name)) { list.push(connection.id) @@ -28,7 +29,7 @@ export function Group (props: GroupProps) { } for (const id of list) { - closeConnection(id) + client.closeConnection(id) } } } diff --git a/src/containers/Proxies/components/Provider/index.tsx b/src/containers/Proxies/components/Provider/index.tsx index b02235d..08c22a4 100644 --- a/src/containers/Proxies/components/Provider/index.tsx +++ b/src/containers/Proxies/components/Provider/index.tsx @@ -1,8 +1,8 @@ import React, { useMemo } from 'react' import { Card, Tag, Icon, Loading } from '@components' -import { useI18n, useProxyProviders } from '@stores' +import { useClient, useI18n, useProxyProviders } from '@stores' import { fromNow } from '@lib/date' -import { Provider as IProvider, Proxy as IProxy, updateProvider, healthCheckProvider } from '@lib/request' +import { Provider as IProvider, Proxy as IProxy } from '@lib/request' import { useVisible } from '@lib/hook' import { compareDesc } from '@containers/Proxies' import { Proxy } from '@containers/Proxies/components/Proxy' @@ -15,6 +15,7 @@ interface ProvidersProps { export function Provider (props: ProvidersProps) { const { update } = useProxyProviders() const { translation, lang } = useI18n() + const client = useClient() const { provider } = props const { t } = translation('Proxies') @@ -23,12 +24,12 @@ export function Provider (props: ProvidersProps) { function handleHealthChech () { show() - healthCheckProvider(provider.name).then(() => update()).finally(() => hide()) + client.healthCheckProvider(provider.name).then(() => update()).finally(() => hide()) } function handleUpdate () { show() - updateProvider(provider.name).then(() => update()).finally(() => hide()) + client.updateProvider(provider.name).then(() => update()).finally(() => hide()) } const proxies = useMemo(() => { diff --git a/src/containers/Proxies/components/Proxy/index.tsx b/src/containers/Proxies/components/Proxy/index.tsx index 8ba5185..028adc5 100644 --- a/src/containers/Proxies/components/Proxy/index.tsx +++ b/src/containers/Proxies/components/Proxy/index.tsx @@ -3,8 +3,8 @@ import { ResultAsync } from 'neverthrow' import type{ AxiosError } from 'axios' import classnames from 'classnames' import { BaseComponentProps } from '@models' -import { useProxy } from '@stores' -import { getProxyDelay, Proxy as IProxy } from '@lib/request' +import { useClient, useProxy } from '@stores' +import { Proxy as IProxy } from '@lib/request' import EE, { Action } from '@lib/event' import { isClashX, jsBridge } from '@lib/jsBridge' @@ -21,19 +21,20 @@ const TagColors = { '#ff3e5e': Infinity } -async function getDelay (name: string) { - if (isClashX()) { - const delay = await jsBridge?.getProxyDelay(name) ?? 0 - return delay - } - - const { data: { delay } } = await getProxyDelay(name) - return delay -} - export function Proxy (props: ProxyProps) { const { config, className } = props const { set } = useProxy() + const client = useClient() + + const getDelay = useCallback(async (name: string) => { + if (isClashX()) { + const delay = await jsBridge?.getProxyDelay(name) ?? 0 + return delay + } + + const { data: { delay } } = await client.getProxyDelay(name) + return delay + }, [client]) const speedTest = useCallback(async function () { const result = await ResultAsync.fromPromise(getDelay(config.name), e => e as AxiosError) @@ -45,7 +46,7 @@ export function Proxy (props: ProxyProps) { proxy.history.push({ time: Date.now().toString(), delay: validDelay }) } }) - }, [config.name, set]) + }, [config.name, getDelay, set]) const delay = useMemo( () => config.history?.length ? config.history.slice(-1)[0].delay : 0, diff --git a/src/containers/Rules/Provider/index.tsx b/src/containers/Rules/Provider/index.tsx index 3f4a597..46d261f 100644 --- a/src/containers/Rules/Provider/index.tsx +++ b/src/containers/Rules/Provider/index.tsx @@ -1,9 +1,9 @@ import * as React from 'react' import classnames from 'classnames' import { Card, Tag, Icon } from '@components' -import { useI18n, useRuleProviders } from '@stores' +import { useClient, useI18n, useRuleProviders } from '@stores' import { fromNow } from '@lib/date' -import { RuleProvider, updateRuleProvider } from '@lib/request' +import { RuleProvider } from '@lib/request' import { useVisible } from '@lib/hook' import './style.scss' @@ -14,6 +14,7 @@ interface ProvidersProps { export function Provider (props: ProvidersProps) { const { update } = useRuleProviders() const { translation, lang } = useI18n() + const client = useClient() const { provider } = props const { t } = translation('Rules') @@ -22,7 +23,7 @@ export function Provider (props: ProvidersProps) { function handleUpdate () { show() - updateRuleProvider(provider.name).then(() => update()).finally(() => hide()) + client.updateRuleProvider(provider.name).then(() => update()).finally(() => hide()) } const updateClassnames = classnames('rule-provider-icon', { 'rule-provider-loading': visible }) diff --git a/src/containers/Settings/index.tsx b/src/containers/Settings/index.tsx index 17ef6ae..c1a423c 100644 --- a/src/containers/Settings/index.tsx +++ b/src/containers/Settings/index.tsx @@ -1,9 +1,9 @@ import React, { useEffect, useMemo } from 'react' import classnames from 'classnames' +import { useUpdateAtom } from 'jotai/utils' import { capitalize } from 'lodash-es' import { Header, Card, Switch, ButtonSelect, ButtonSelectOptions, Input } from '@components' -import { useI18n, useClashXData, useAPIInfo, useGeneral, useIdentity, useVersion } from '@stores' -import { updateConfig } from '@lib/request' +import { useI18n, useClashXData, useAPIInfo, useGeneral, useVersion, useClient, identityAtom } from '@stores' import { useObject } from '@lib/hook' import { jsBridge } from '@lib/jsBridge' import { Lang } from '@i18n' @@ -15,10 +15,11 @@ export default function Settings () { const { premium } = useVersion() const { data: clashXData, update: fetchClashXData } = useClashXData() const { general, update: fetchGeneral } = useGeneral() - const { set: setIdentity } = useIdentity() - const { data: apiInfo } = useAPIInfo() + const setIdentity = useUpdateAtom(identityAtom) + const apiInfo = useAPIInfo() const { translation, setLang, lang } = useI18n() const { t } = translation('Settings') + const client = useClient() const [info, set] = useObject({ socks5ProxyPort: 7891, httpProxyPort: 7890, @@ -32,7 +33,7 @@ export default function Settings () { }, [general, set]) async function handleProxyModeChange (mode: string) { - await updateConfig({ mode }) + await client.updateConfig({ mode }) await fetchGeneral() } @@ -51,22 +52,22 @@ export default function Settings () { } async function handleHttpPortSave () { - await updateConfig({ port: info.httpProxyPort }) + await client.updateConfig({ port: info.httpProxyPort }) await fetchGeneral() } async function handleSocksPortSave () { - await updateConfig({ 'socks-port': info.socks5ProxyPort }) + await client.updateConfig({ 'socks-port': info.socks5ProxyPort }) await fetchGeneral() } async function handleMixedPortSave () { - await updateConfig({ 'mixed-port': info.mixedProxyPort }) + await client.updateConfig({ 'mixed-port': info.mixedProxyPort }) await fetchGeneral() } async function handleAllowLanChange (state: boolean) { - await updateConfig({ 'allow-lan': state }) + await client.updateConfig({ 'allow-lan': state }) await fetchGeneral() } diff --git a/src/containers/Sidebar/index.tsx b/src/containers/Sidebar/index.tsx index 40e75a6..19d46ed 100644 --- a/src/containers/Sidebar/index.tsx +++ b/src/containers/Sidebar/index.tsx @@ -3,9 +3,8 @@ import { NavLink } from 'react-router-dom' import classnames from 'classnames' import { useI18n, useVersion, useClashXData } from '@stores' -import './style.scss' import logo from '@assets/logo.png' -import useSWR from 'swr' +import './style.scss' interface SidebarProps { routes: { @@ -19,12 +18,10 @@ interface SidebarProps { export default function Sidebar (props: SidebarProps) { const { routes } = props const { translation } = useI18n() - const { version, premium, update } = useVersion() + const { version, premium } = useVersion() const { data } = useClashXData() const { t } = translation('SideBar') - useSWR('version', update) - const navlinks = routes.map( ({ path, name, exact, noMobile }) => (
  • diff --git a/src/lib/helper.ts b/src/lib/helper.ts index ccf160f..c3ccf06 100644 --- a/src/lib/helper.ts +++ b/src/lib/helper.ts @@ -1,17 +1,5 @@ -export function getLocalStorageItem (key: string, defaultValue = '') { - return window.localStorage.getItem(key) || defaultValue -} - -export function setLocalStorageItem (key: string, value: string) { - return window.localStorage.setItem(key, value) -} - export function noop () {} -export function getSearchParam(key: string) { - return new URLSearchParams(window.location.search).get(key) -} - export function partition (arr: T[], fn: (arg: T) => boolean): [T[], T[]] { const left: T[] = [] const right: T[] = [] diff --git a/src/lib/request.ts b/src/lib/request.ts index 7585d81..0c69e44 100644 --- a/src/lib/request.ts +++ b/src/lib/request.ts @@ -1,10 +1,4 @@ -import axios, { AxiosError } from 'axios' -import { ResultAsync } from 'neverthrow' -import { getLocalStorageItem, getSearchParam } from '@lib/helper' -import { isClashX, jsBridge } from '@lib/jsBridge' -import { createAsyncSingleton } from '@lib/asyncSingleton' -import { Log } from '@models/Log' -import { StreamReader } from './streamer' +import axios, { AxiosInstance } from 'axios' export interface Config { port: number @@ -99,172 +93,90 @@ export interface Connections { rulePayload: string } -export async function getExternalControllerConfig () { - if (isClashX()) { - const info = await jsBridge!.getAPIInfo() - - return { - hostname: info.host, - port: info.port, - secret: info.secret, - protocol: 'http:' - } - } - - let url: URL | undefined; - { - const meta = document.querySelector('meta[name="external-controller"]') - if (meta?.content?.match(/^https?:/)) { - // [protocol]://[secret]@[hostname]:[port] - url = new URL(meta.content) - } - } - - const hostname = getSearchParam('host') ?? getLocalStorageItem('externalControllerAddr', url?.hostname ?? '127.0.0.1') - const port = getSearchParam('port') ?? getLocalStorageItem('externalControllerPort', url?.port ?? '9090') - const secret = getSearchParam('secret') ?? getLocalStorageItem('secret', url?.username ?? '') - const protocol = getSearchParam('protocol') ?? hostname === '127.0.0.1' ? 'http:' : (url?.protocol ?? window.location.protocol) - - if (!hostname || !port) { - throw new Error('can\'t get hostname or port') - } - - return { hostname, port, secret, protocol } -} - -export const getInstance = createAsyncSingleton(async () => { - const { - hostname, - port, - secret, - protocol - } = await getExternalControllerConfig() - - return axios.create({ - baseURL: `${protocol}//${hostname}:${port}`, - headers: secret ? { Authorization: `Bearer ${secret}` } : {} - }) -}) - -export async function getConfig () { - const req = await getInstance() - return req.get('configs') -} - -export async function updateConfig (config: Partial) { - const req = await getInstance() - return req.patch('configs', config) -} - -export async function getRules () { - const req = await getInstance() - return req.get('rules') -} - -export async function updateRules () { - const req = await getInstance() - return req.put('rules') -} - -export async function getProxyProviders () { - const req = await getInstance() - return req.get('providers/proxies', { - validateStatus (status) { - // compatible old version - return (status >= 200 && status < 300) || status === 404 - } - }) - // compatible old version - .then(resp => { - if (resp.status === 404) { - resp.data = { providers: {} } - } - return resp +export class Client { + private axiosClient: AxiosInstance + constructor(url: string, secret?: string) { + this.axiosClient = axios.create({ + baseURL: url, + headers: secret ? { Authorization: `Bearer ${secret}` } : {} }) -} + } -export async function getRuleProviders () { - const req = await getInstance() - return req.get('providers/rules') -} + getConfig() { + return this.axiosClient.get('configs') + } -export async function updateProvider (name: string) { - const req = await getInstance() - return req.put(`providers/proxies/${encodeURIComponent(name)}`) -} + updateConfig(config: Partial) { + return this.axiosClient.patch('configs', config) + } -export async function updateRuleProvider (name: string) { - const req = await getInstance() - return req.put(`providers/rules/${encodeURIComponent(name)}`) -} + getRules() { + return this.axiosClient.get('rules') + } -export async function healthCheckProvider (name: string) { - const req = await getInstance() - return req.get(`providers/proxies/${encodeURIComponent(name)}/healthcheck`) -} - -export async function getProxies () { - const req = await getInstance() - return req.get('proxies') -} - -export async function getProxy (name: string) { - const req = await getInstance() - return req.get(`proxies/${encodeURIComponent(name)}`) -} - -export async function getVersion () { - const req = await getInstance() - return req.get<{ version: string, premium?: boolean }>('version') -} - -export async function getProxyDelay (name: string) { - const req = await getInstance() - return req.get<{ delay: number }>(`proxies/${encodeURIComponent(name)}/delay`, { - params: { - timeout: 5000, - url: 'http://www.gstatic.com/generate_204' + async getProxyProviders () { + const resp = await this.axiosClient.get('providers/proxies', { + validateStatus(status) { + // compatible old version + return (status >= 200 && status < 300) || status === 404 + } + }) + if (resp.status === 404) { + resp.data = { providers: {} } } - }) + return resp + } + + getRuleProviders () { + return this.axiosClient.get('providers/rules') + } + + updateProvider (name: string) { + return this.axiosClient.put(`providers/proxies/${encodeURIComponent(name)}`) + } + + updateRuleProvider (name: string) { + return this.axiosClient.put(`providers/rules/${encodeURIComponent(name)}`) + } + + healthCheckProvider (name: string) { + return this.axiosClient.get(`providers/proxies/${encodeURIComponent(name)}/healthcheck`) + } + + getProxies () { + return this.axiosClient.get('proxies') + } + + getProxy (name: string) { + return this.axiosClient.get(`proxies/${encodeURIComponent(name)}`) + } + + getVersion () { + return this.axiosClient.get<{ version: string, premium?: boolean }>('version') + } + + getProxyDelay (name: string) { + return this.axiosClient.get<{ delay: number }>(`proxies/${encodeURIComponent(name)}/delay`, { + params: { + timeout: 5000, + url: 'http://www.gstatic.com/generate_204' + } + }) + } + + closeAllConnections () { + return this.axiosClient.delete('connections') + } + + closeConnection (id: string) { + return this.axiosClient.delete(`connections/${id}`) + } + + getConnections () { + return this.axiosClient.get('connections') + } + + changeProxySelected (name: string, select: string) { + return this.axiosClient.put(`proxies/${encodeURIComponent(name)}`, { name: select }) + } } - -export async function closeAllConnections () { - const req = await getInstance() - return req.delete('connections') -} - -export async function closeConnection (id: string) { - const req = await getInstance() - return req.delete(`connections/${id}`) -} - -export async function getConnections () { - const req = await getInstance() - return req.get('connections') -} - -export async function changeProxySelected (name: string, select: string) { - const req = await getInstance() - return req.put(`proxies/${encodeURIComponent(name)}`, { name: select }) -} - -export const getLogsStreamReader = createAsyncSingleton(async function () { - const externalController = await getExternalControllerConfig() - const { data: config } = await getConfig() - const result = await ResultAsync.fromPromise(getVersion(), err => err as AxiosError) - const version = result.isErr() ? 'unkonwn version' : result.value.data.version - const useWebsocket = !!version || true - - const logUrl = `${externalController.protocol}//${externalController.hostname}:${externalController.port}/logs?level=${config['log-level']}` - return new StreamReader({ url: logUrl, bufferLength: 200, token: externalController.secret, useWebsocket }) -}) - -export const getConnectionStreamReader = createAsyncSingleton(async function () { - const externalController = await getExternalControllerConfig() - const result = await ResultAsync.fromPromise(getVersion(), err => err as AxiosError) - const version = result.isErr() ? 'unkonwn version' : result.value.data.version - - const useWebsocket = !!version || true - const logUrl = `${externalController.protocol}//${externalController.hostname}:${externalController.port}/connections` - return new StreamReader({ url: logUrl, bufferLength: 200, token: externalController.secret, useWebsocket }) -}) diff --git a/src/render.tsx b/src/render.tsx index 53f0a6b..a21718b 100644 --- a/src/render.tsx +++ b/src/render.tsx @@ -1,14 +1,17 @@ -import React from 'react' +import React, { Suspense } from 'react' import { render } from 'react-dom' import { HashRouter } from 'react-router-dom' import App from '@containers/App' +import { Loading } from '@components' import 'virtual:windi.css' export default function renderApp () { const rootEl = document.getElementById('root') const AppInstance = ( - + }> + + ) diff --git a/src/stores/index.ts b/src/stores/index.ts index 05ddac4..67452e4 100644 --- a/src/stores/index.ts +++ b/src/stores/index.ts @@ -1 +1,2 @@ export * from './jotai' +export * from './request' diff --git a/src/stores/jotai.ts b/src/stores/jotai.ts index f87b5db..8ce761a 100644 --- a/src/stores/jotai.ts +++ b/src/stores/jotai.ts @@ -1,7 +1,7 @@ import { ResultAsync } from 'neverthrow' import { AxiosError } from 'axios' import { atom, useAtom } from 'jotai' -import { atomWithStorage } from 'jotai/utils' +import { atomWithStorage, useUpdateAtom } from 'jotai/utils' import { atomWithImmer } from 'jotai/immer' import { useCallback, useEffect, useMemo } from 'react' import { get } from 'lodash-es' @@ -12,33 +12,14 @@ import { Language, locales, Lang, getDefaultLanguage } from '@i18n' import { useWarpImmerSetter, WritableDraft } from '@lib/jotai' import * as API from '@lib/request' import * as Models from '@models' -import { partition, setLocalStorageItem } from '@lib/helper' +import { partition } from '@lib/helper' import { isClashX, jsBridge } from '@lib/jsBridge' +import { useAPIInfo, useClient } from './request' +import { StreamReader } from '@lib/streamer' +import { Log } from '@models/Log' +import { Snapshot } from '@lib/request' -const identity = atom(true) - -type AsyncFunction = (...args: A[]) => Promise - -export function useIdentity () { - const [id, set] = useAtom(identity) - - function wrapFetcher (fn: AsyncFunction) { - return async function (...args: A[]) { - const result = await ResultAsync.fromPromise(fn(...args), e => e as AxiosError) - if (result.isErr()) { - if (result.error.response?.status === 401) { - set(false) - } - throw result.error - } - - set(true) - return result.value - } - } - - return { identity: id, wrapFetcher, set } -} +export const identityAtom = atom(true) export const languageAtom = atomWithStorage('language', undefined) @@ -66,10 +47,11 @@ export const version = atom({ export function useVersion () { const [data, set] = useAtom(version) - const { set: setIdentity } = useIdentity() + const client = useClient() + const setIdentity = useUpdateAtom(identityAtom) - async function update () { - const result = await ResultAsync.fromPromise(API.getVersion(), e => e as AxiosError) + useSWR([client], async function () { + const result = await ResultAsync.fromPromise(client.getVersion(), e => e as AxiosError) setIdentity(result.isOk()) set( @@ -77,20 +59,21 @@ export function useVersion () { ? { version: '', premium: false } : { version: result.value.data.version, premium: !!result.value.data.premium } ) - } + }) - return { version: data.version, premium: data.premium, update } + return { version: data.version, premium: data.premium } } export function useRuleProviders () { const [{ premium }] = useAtom(version) + const client = useClient() - const { data, mutate } = useSWR('/providers/rule', async () => { + const { data, mutate } = useSWR(['/providers/rule', client], async () => { if (!premium) { return [] } - const ruleProviders = await API.getRuleProviders() + const ruleProviders = await client.getRuleProviders() return Object.keys(ruleProviders.data.providers) .map(name => ruleProviders.data.providers[name]) @@ -117,9 +100,10 @@ export const proxyProvider = atom([] as API.Provider[]) export function useProxyProviders () { const [providers, set] = useAtom(proxyProvider) + const client = useClient() - const { data, mutate } = useSWR('/providers/proxy', async () => { - const proxyProviders = await API.getProxyProviders() + const { data, mutate } = useSWR(['/providers/proxy', client], async () => { + const proxyProviders = await client.getProxyProviders() return Object.keys(proxyProviders.data.providers) .map(name => proxyProviders.data.providers[name]) @@ -132,8 +116,10 @@ export function useProxyProviders () { } export function useGeneral () { - const { data, mutate } = useSWR('/config', async () => { - const resp = await API.getConfig() + const client = useClient() + + const { data, mutate } = useSWR(['/config', client], async () => { + const resp = await client.getConfig() const data = resp.data return { port: data.port, @@ -164,9 +150,10 @@ export const proxies = atomWithImmer({ export function useProxy () { const [allProxy, rawSet] = useAtom(proxies) const set = useWarpImmerSetter(rawSet) + const client = useClient() - const { mutate } = useSWR('/proxies', async () => { - const allProxies = await API.getProxies() + const { mutate } = useSWR(['/proxies', client], async () => { + const allProxies = await client.getProxies() const global = allProxies.data.proxies.GLOBAL as API.Group // fix missing name @@ -240,42 +227,64 @@ export function useClashXData () { return { data, update: mutate } } -export const apiData = atom({ - hostname: '127.0.0.1', - port: '9090', - secret: '' -}) - -export function useAPIInfo () { - const [data, set] = useAtom(apiData) - - const fetch = useCallback(async function fetch () { - const info = await API.getExternalControllerConfig() - set({ ...info }) - }, [set]) - - async function update (info: typeof data) { - const { hostname, port, secret } = info - setLocalStorageItem('externalControllerAddr', hostname) - setLocalStorageItem('externalControllerPort', port) - setLocalStorageItem('secret', secret) - window.location.reload() - } - - return { data, fetch, update } -} - export const rules = atomWithImmer([] as API.Rule[]) export function useRule () { const [data, rawSet] = useAtom(rules) const set = useWarpImmerSetter(rawSet) + const client = useClient() async function update () { - const resp = await API.getRules() + const resp = await client.getRules() set(resp.data.rules) } return { rules: data, update } } +const logsAtom = atom({ + key: '', + instance: null as StreamReader | null +}) + +export function useLogsStreamReader () { + const apiInfo = useAPIInfo() + const { general } = useGeneral() + const version = useVersion() + const [item, setItem] = useAtom(logsAtom) + + if (!version.version) { + return null + } + + const useWebsocket = !!version.version || true + const key = `${apiInfo.protocol}//${apiInfo.hostname}:${apiInfo.port}/logs?level=${general.logLevel ?? ''}&useWebsocket=${useWebsocket}&secret=${apiInfo.secret}` + if (item.key === key) { + return item.instance! + } + + const oldInstance = item.instance + + const logUrl = `${apiInfo.protocol}//${apiInfo.hostname}:${apiInfo.port}/logs?level=${general.logLevel ?? ''}` + const instance = new StreamReader({ url: logUrl, bufferLength: 200, token: apiInfo.secret, useWebsocket }) + setItem({ key, instance }) + + if (oldInstance) { + oldInstance.destory() + } + + return instance +} + +export function useConnectionStreamReader () { + const apiInfo = useAPIInfo() + const version = useVersion() + + const useWebsocket = !!version.version || true + + const url = `${apiInfo.protocol}//${apiInfo.hostname}:${apiInfo.port}/connections` + return useMemo( + () => version.version ? new StreamReader({ url, bufferLength: 200, token: apiInfo.secret, useWebsocket }) : null, + [apiInfo.secret, url, useWebsocket, version.version] + ) +} diff --git a/src/stores/request.ts b/src/stores/request.ts new file mode 100644 index 0000000..ff56654 --- /dev/null +++ b/src/stores/request.ts @@ -0,0 +1,78 @@ +import { atom, useAtom } from "jotai"; +import { isClashX, jsBridge } from "@lib/jsBridge"; +import { atomWithStorage, useAtomValue } from "jotai/utils"; +import { useLocation } from "react-use"; +import { Client } from "@lib/request"; + +const clashxConfigAtom = atom(async () => { + if (!isClashX()) { + return null + } + + const info = await jsBridge!.getAPIInfo() + return { + hostname: info.host, + port: info.port, + secret: info.secret, + protocol: 'http:' + } +}) + +export const localStorageAtom = atomWithStorage<{ + hostname: string; + port: string; + secret: string; +}[]>('externalControllers', []) + +export function useAPIInfo() { + const clashx = useAtomValue(clashxConfigAtom) + const location = useLocation() + const localStorage = useAtomValue(localStorageAtom) + + if (clashx) { + return clashx + } + + let url: URL | undefined; + { + const meta = document.querySelector('meta[name="external-controller"]') + if (meta?.content?.match(/^https?:/)) { + // [protocol]://[secret]@[hostname]:[port] + url = new URL(meta.content) + } + } + + const qs = new URLSearchParams(location.search) + + const hostname = qs.get('host') ?? localStorage?.[0]?.hostname ?? url?.hostname ?? '127.0.0.1' + const port = qs.get('port') ?? localStorage?.[0]?.port ?? url?.port ?? '9090' + const secret = qs.get('secret') ?? localStorage?.[0]?.secret ?? url?.username ?? '' + const protocol = qs.get('protocol') ?? hostname === '127.0.0.1' ? 'http:' : (url?.protocol ?? window.location.protocol) + + return { hostname, port, secret, protocol } +} + +const clientAtom = atom({ + key: '', + instance: null as Client | null +}) + +export function useClient() { + const { + hostname, + port, + secret, + protocol + } = useAPIInfo() + + const [item, setItem] = useAtom(clientAtom) + const key = `${protocol}//${hostname}:${port}?secret=${secret}` + if (item.key === key) { + return item.instance! + } + + const client = new Client(`${protocol}//${hostname}:${port}`, secret) + setItem({ key, instance: client }) + + return client +}