summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorHaishan <[email protected]>2020-07-01 22:06:26 +0800
committerHaishan <[email protected]>2020-07-04 17:58:56 +0800
commit32bed273c83f0593187110d2b08a0f9ec5a7efd7 (patch)
tree0b47da752de3ee0d87945c1122b2cf9d3bf8043f /src
parent55e928a87f561ab927774834b50e099a0758522d (diff)
feat: support rule provider
Diffstat (limited to 'src')
-rw-r--r--src/api/rule-provider.ts74
-rw-r--r--src/api/rules.js8
-rw-r--r--src/api/rules.ts41
-rw-r--r--src/components/Button.module.css4
-rw-r--r--src/components/Button.tsx9
-rw-r--r--src/components/RuleSearch.js6
-rw-r--r--src/components/Rules.js115
-rw-r--r--src/components/Rules.module.css22
-rw-r--r--src/components/StateProvider.js5
-rw-r--r--src/components/proxies/TextFilter.tsx21
-rw-r--r--src/components/rules/RuleProviderItem.module.css43
-rw-r--r--src/components/rules/RuleProviderItem.tsx71
-rw-r--r--src/components/rules/TextFilter.tsx18
-rw-r--r--src/hooks/useTextInput.ts23
-rw-r--r--src/store/index.js2
-rw-r--r--src/store/logs.js16
-rw-r--r--src/store/rules.js62
-rw-r--r--src/types.ts5
18 files changed, 420 insertions, 125 deletions
diff --git a/src/api/rule-provider.ts b/src/api/rule-provider.ts
new file mode 100644
index 0000000..5d39527
--- /dev/null
+++ b/src/api/rule-provider.ts
@@ -0,0 +1,74 @@
+import { getURLAndInit } from 'src/misc/request-helper';
+import { ClashAPIConfig } from 'src/types';
+
+export type RuleProvider = RuleProviderAPIItem & { idx: number };
+
+export type RuleProviderAPIItem = {
+ behavior: string;
+ name: string;
+ ruleCount: number;
+ type: 'Rule';
+ // example value "2020-06-30T16:23:01.44143802+08:00"
+ updatedAt: string;
+ vehicleType: 'HTTP' | 'File';
+};
+
+type RuleProviderAPIData = {
+ providers: Record<string, RuleProviderAPIItem>;
+};
+
+function normalizeAPIResponse(data: RuleProviderAPIData) {
+ const providers = data.providers;
+ const names = Object.keys(providers);
+ const byName: Record<string, RuleProvider> = {};
+
+ // attach an idx to each item
+ for (let i = 0; i < names.length; i++) {
+ const name = names[i];
+ byName[name] = { ...providers[name], idx: i };
+ }
+
+ return { byName, names };
+}
+
+export async function fetchRuleProviders(
+ endpoint: string,
+ apiConfig: ClashAPIConfig
+) {
+ const { url, init } = getURLAndInit(apiConfig);
+
+ let data = { providers: {} };
+ try {
+ const res = await fetch(url + endpoint, init);
+ if (res.ok) {
+ data = await res.json();
+ }
+ } catch (err) {
+ // log and ignore
+ // eslint-disable-next-line no-console
+ console.log('failed to GET /providers/rules', err);
+ }
+ return normalizeAPIResponse(data);
+}
+
+export async function refreshRuleProviderByName({
+ name,
+ apiConfig,
+}: {
+ name: string;
+ apiConfig: ClashAPIConfig;
+}) {
+ const { url, init } = getURLAndInit(apiConfig);
+ try {
+ const res = await fetch(url + `/providers/rules/${name}`, {
+ method: 'PUT',
+ ...init,
+ });
+ return res.ok;
+ } catch (err) {
+ // log and ignore
+ // eslint-disable-next-line no-console
+ console.log('failed to PUT /providers/rules/:name', err);
+ return false;
+ }
+}
diff --git a/src/api/rules.js b/src/api/rules.js
deleted file mode 100644
index 574de96..0000000
--- a/src/api/rules.js
+++ /dev/null
@@ -1,8 +0,0 @@
-import { getURLAndInit } from '../misc/request-helper';
-
-const endpoint = '/rules';
-
-export async function fetchRules(apiConfig) {
- const { url, init } = getURLAndInit(apiConfig);
- return await fetch(url + endpoint, init);
-}
diff --git a/src/api/rules.ts b/src/api/rules.ts
new file mode 100644
index 0000000..b57b0e3
--- /dev/null
+++ b/src/api/rules.ts
@@ -0,0 +1,41 @@
+import invariant from 'invariant';
+import { getURLAndInit } from 'src/misc/request-helper';
+import { ClashAPIConfig } from 'src/types';
+
+// const endpoint = '/rules';
+
+type RuleItem = RuleAPIItem & { id: number };
+
+type RuleAPIItem = {
+ type: string;
+ payload: string;
+ proxy: string;
+};
+
+function normalizeAPIResponse(json: {
+ rules: Array<RuleAPIItem>;
+}): Array<RuleItem> {
+ invariant(
+ json.rules && json.rules.length >= 0,
+ 'there is no valid rules list in the rules API response'
+ );
+
+ // attach an id
+ return json.rules.map((r: RuleAPIItem, i: number) => ({ ...r, id: i }));
+}
+
+export async function fetchRules(endpoint: string, apiConfig: ClashAPIConfig) {
+ let json = { rules: [] };
+ try {
+ const { url, init } = getURLAndInit(apiConfig);
+ const res = await fetch(url + endpoint, init);
+ if (res.ok) {
+ json = await res.json();
+ }
+ } catch (err) {
+ // log and ignore
+ // eslint-disable-next-line no-console
+ console.log('failed to fetch rules', err);
+ }
+ return normalizeAPIResponse(json);
+}
diff --git a/src/components/Button.module.css b/src/components/Button.module.css
index 719a9aa..83da6af 100644
--- a/src/components/Button.module.css
+++ b/src/components/Button.module.css
@@ -43,6 +43,10 @@
}
}
+.btn:disabled {
+ opacity: 0.5;
+}
+
.btnStart {
margin-right: 5px;
display: inline-flex;
diff --git a/src/components/Button.tsx b/src/components/Button.tsx
index 7409c58..9450969 100644
--- a/src/components/Button.tsx
+++ b/src/components/Button.tsx
@@ -16,6 +16,7 @@ type ButtonInternalProps = {
type ButtonProps = {
isLoading?: boolean;
onClick?: (e: React.MouseEvent<HTMLButtonElement>) => unknown;
+ disabled?: boolean;
kind?: 'primary' | 'minimal';
className?: string;
} & ButtonInternalProps;
@@ -23,6 +24,7 @@ type ButtonProps = {
function Button(props: ButtonProps, ref: React.Ref<HTMLButtonElement>) {
const {
onClick,
+ disabled = false,
isLoading,
kind = 'primary',
className,
@@ -43,7 +45,12 @@ function Button(props: ButtonProps, ref: React.Ref<HTMLButtonElement>) {
className
);
return (
- <button className={btnClassName} ref={ref} onClick={internalOnClick}>
+ <button
+ className={btnClassName}
+ ref={ref}
+ onClick={internalOnClick}
+ disabled={disabled}
+ >
{isLoading ? (
<>
<span
diff --git a/src/components/RuleSearch.js b/src/components/RuleSearch.js
deleted file mode 100644
index bc58642..0000000
--- a/src/components/RuleSearch.js
+++ /dev/null
@@ -1,6 +0,0 @@
-import { getSearchText, updateSearchText } from '../store/rules';
-import Search from './Search';
-import { connect } from './StateProvider';
-
-const mapState = (s) => ({ searchText: getSearchText(s), updateSearchText });
-export default connect(mapState)(Search);
diff --git a/src/components/Rules.js b/src/components/Rules.js
index a04116a..949e5e9 100644
--- a/src/components/Rules.js
+++ b/src/components/Rules.js
@@ -1,27 +1,63 @@
import React from 'react';
import { RotateCw } from 'react-feather';
-import { areEqual, FixedSizeList as List } from 'react-window';
+import { queryCache, useQuery } from 'react-query';
+import { areEqual, VariableSizeList } from 'react-window';
+import { useRecoilState } from 'recoil';
+import { fetchRuleProviders } from 'src/api/rule-provider';
+import { fetchRules } from 'src/api/rules';
+import { RuleProviderItem } from 'src/components/rules/RuleProviderItem';
+import { TextFilter } from 'src/components/rules/TextFilter';
+import { ruleFilterText } from 'src/store/rules';
import useRemainingViewPortHeight from '../hooks/useRemainingViewPortHeight';
import { getClashAPIConfig } from '../store/app';
-import { fetchRules, fetchRulesOnce, getRules } from '../store/rules';
import ContentHeader from './ContentHeader';
import Rule from './Rule';
-import RuleSearch from './RuleSearch';
+import s from './Rules.module.css';
import { Fab, position as fabPosition } from './shared/Fab';
import { connect } from './StateProvider';
-const { memo, useEffect, useMemo, useCallback } = React;
+const { memo, useMemo, useCallback } = React;
const paddingBottom = 30;
-function itemKey(index, data) {
- const item = data[index];
+function itemKey(index, { rules, provider }) {
+ const providerQty = provider.names.length;
+
+ if (index < providerQty) {
+ return provider.names[index];
+ }
+ const item = rules[index - providerQty];
return item.id;
}
+function getItemSizeFactory({ provider }) {
+ return function getItemSize(idx) {
+ const providerQty = provider.names.length;
+ if (idx < providerQty) {
+ // provider
+ return 90;
+ }
+ // rule
+ return 80;
+ };
+}
+
const Row = memo(({ index, style, data }) => {
- const r = data[index];
+ const { rules, provider, apiConfig } = data;
+ const providerQty = provider.names.length;
+
+ if (index < providerQty) {
+ const name = provider.names[index];
+ const item = provider.byName[name];
+ return (
+ <div style={style} className={s.RuleProviderItemWrapper}>
+ <RuleProviderItem apiConfig={apiConfig} {...item} />
+ </div>
+ );
+ }
+
+ const r = rules[index - providerQty];
return (
<div style={style}>
<Rule {...r} />
@@ -31,42 +67,75 @@ const Row = memo(({ index, style, data }) => {
const mapState = (s) => ({
apiConfig: getClashAPIConfig(s),
- rules: getRules(s),
});
export default connect(mapState)(Rules);
-function Rules({ dispatch, apiConfig, rules }) {
- const fetchRulesHooked = useCallback(() => {
- dispatch(fetchRules(apiConfig));
- }, [apiConfig, dispatch]);
- useEffect(() => {
- dispatch(fetchRulesOnce(apiConfig));
- }, [dispatch, apiConfig]);
+function useRuleAndProvider(apiConfig) {
+ const { data: rules } = useQuery(['/rules', apiConfig], fetchRules, {
+ suspense: true,
+ });
+ const { data: provider } = useQuery(
+ ['/providers/rules', apiConfig],
+ fetchRuleProviders,
+ { suspense: true }
+ );
+
+ const [filterText] = useRecoilState(ruleFilterText);
+ if (filterText === '') {
+ return { rules, provider };
+ } else {
+ const f = filterText.toLowerCase();
+ return {
+ rules: rules.filter((r) => r.payload.toLowerCase().indexOf(f) >= 0),
+ provider: {
+ byName: provider.byName,
+ names: provider.names.filter((t) => t.toLowerCase().indexOf(f) >= 0),
+ },
+ };
+ }
+}
+
+function useInvalidateQueries() {
+ return useCallback(() => {
+ queryCache.invalidateQueries('/rules');
+ queryCache.invalidateQueries('/providers/rules');
+ }, []);
+}
+
+function Rules({ apiConfig }) {
const [refRulesContainer, containerHeight] = useRemainingViewPortHeight();
const refreshIcon = useMemo(() => <RotateCw width={16} />, []);
+
+ const { rules, provider } = useRuleAndProvider(apiConfig);
+ const invalidateQueries = useInvalidateQueries();
+
+ const getItemSize = getItemSizeFactory({ rules, provider });
+
return (
<div>
- <ContentHeader title="Rules" />
- <RuleSearch />
+ <div className={s.header}>
+ <ContentHeader title="Rules" />
+ <TextFilter />
+ </div>
<div ref={refRulesContainer} style={{ paddingBottom }}>
- <List
+ <VariableSizeList
height={containerHeight - paddingBottom}
width="100%"
- itemCount={rules.length}
- itemSize={80}
- itemData={rules}
+ itemCount={rules.length + provider.names.length}
+ itemSize={getItemSize}
+ itemData={{ rules, provider, apiConfig }}
itemKey={itemKey}
>
{Row}
- </List>
+ </VariableSizeList>
</div>
<Fab
icon={refreshIcon}
text="Refresh"
- onClick={fetchRulesHooked}
position={fabPosition}
+ onClick={invalidateQueries}
/>
</div>
);
diff --git a/src/components/Rules.module.css b/src/components/Rules.module.css
index 79a9626..6459e17 100644
--- a/src/components/Rules.module.css
+++ b/src/components/Rules.module.css
@@ -1 +1,21 @@
-/* */
+.header {
+ display: grid;
+ grid-template-columns: 1fr minmax(auto, 330px);
+ align-items: center;
+
+ /*
+ * the content header has some padding
+ * we need to apply some right padding to this container then
+ */
+ padding-right: 15px;
+ @media (--breakpoint-not-small) {
+ padding-right: 40px;
+ }
+}
+
+.RuleProviderItemWrapper {
+ padding: 6px 15px;
+ @media (--breakpoint-not-small) {
+ padding: 10px 40px;
+ }
+}
diff --git a/src/components/StateProvider.js b/src/components/StateProvider.js
index 56527aa..e905d98 100644
--- a/src/components/StateProvider.js
+++ b/src/components/StateProvider.js
@@ -1,6 +1,11 @@
import produce, * as immer from 'immer';
import React from 'react';
+// in logs store we update logs in place
+// outside of immer produce
+// this is just workaround
+immer.setAutoFreeze(false);
+
const {
createContext,
memo,
diff --git a/src/components/proxies/TextFilter.tsx b/src/components/proxies/TextFilter.tsx
index 6465572..75f1d51 100644
--- a/src/components/proxies/TextFilter.tsx
+++ b/src/components/proxies/TextFilter.tsx
@@ -1,28 +1,11 @@
-import debounce from 'lodash-es/debounce';
import * as React from 'react';
-import { useRecoilState } from 'recoil';
+import { useTextInut } from 'src/hooks/useTextInput';
import { proxyFilterText } from '../../store/proxies';
import shared from '../shared.module.css';
-const { useCallback, useState, useMemo } = React;
-
export function TextFilter() {
- const [, setTextGlobal] = useRecoilState(proxyFilterText);
- const [text, setText] = useState('');
-
- const setTextDebounced = useMemo(() => debounce(setTextGlobal, 300), [
- setTextGlobal,
- ]);
-
- const onChange = useCallback(
- (e: React.ChangeEvent<HTMLInputElement>) => {
- setText(e.target.value);
- setTextDebounced(e.target.value);
- },
- [setTextDebounced]
- );
-
+ const [onChange, text] = useTextInut(proxyFilterText);
return (
<input
className={shared.input}
diff --git a/src/components/rules/RuleProviderItem.module.css b/src/components/rules/RuleProviderItem.module.css
new file mode 100644
index 0000000..f4f52c8
--- /dev/null
+++ b/src/components/rules/RuleProviderItem.module.css
@@ -0,0 +1,43 @@
+.RuleProviderItem {
+ display: grid;
+ grid-template-columns: 40px 1fr 46px;
+ height: 100%;
+}
+
+.left {
+ display: inline-flex;
+ align-items: center;
+ color: var(--color-text-secondary);
+ opacity: 0.4;
+}
+
+.middle {
+ display: grid;
+ grid-template-rows: 1fr auto auto;
+ align-items: center;
+}
+
+.gray {
+ color: #777;
+}
+
+.refreshButtonWrapper {
+ display: grid;
+ place-items: center;
+}
+
+.rotate {
+ display: inline-flex;
+}
+.isRotating {
+ animation: rotating 3s infinite linear;
+}
+
+@keyframes rotating {
+ from {
+ transform: rotate(0deg);
+ }
+ to {
+ transform: rotate(360deg);
+ }
+}
diff --git a/src/components/rules/RuleProviderItem.tsx b/src/components/rules/RuleProviderItem.tsx
new file mode 100644
index 0000000..3b6d93d
--- /dev/null
+++ b/src/components/rules/RuleProviderItem.tsx
@@ -0,0 +1,71 @@
+import cx from 'clsx';
+import { formatDistance } from 'date-fns';
+import * as React from 'react';
+import { RotateCw } from 'react-feather';
+import { queryCache, useMutation } from 'react-query';
+import { refreshRuleProviderByName } from 'src/api/rule-provider';
+import Button from 'src/components/Button';
+import { SectionNameType } from 'src/components/shared/Basic';
+import { ClashAPIConfig } from 'src/types';
+
+import s from './RuleProviderItem.module.css';
+
+function useRefresh(
+ name: string,
+ apiConfig: ClashAPIConfig
+): [(ev: React.MouseEvent<HTMLButtonElement>) => unknown, boolean] {
+ const [mutate, { isLoading }] = useMutation(refreshRuleProviderByName, {
+ onSuccess: () => {
+ queryCache.invalidateQueries('/providers/rules');
+ },
+ });
+
+ const onClickRefreshButton = (ev: React.MouseEvent<HTMLButtonElement>) => {
+ ev.preventDefault();
+ mutate({ name, apiConfig });
+ };
+
+ return [onClickRefreshButton, isLoading];
+}
+
+function RotatableRotateCw({ isRotating }: { isRotating: boolean }) {
+ const cls = cx(s.rotate, {
+ [s.isRotating]: isRotating,
+ });
+ return (
+ <span className={cls}>
+ <RotateCw width={16} />
+ </span>
+ );
+}
+
+export function RuleProviderItem({
+ idx,
+ name,
+ vehicleType,
+ behavior,
+ updatedAt,
+ ruleCount,
+ apiConfig,
+}) {
+ const [onClickRefreshButton, isRefreshing] = useRefresh(name, apiConfig);
+ const timeAgo = formatDistance(new Date(updatedAt), new Date());
+ return (
+ <div className={s.RuleProviderItem}>
+ <span className={s.left}>{idx}</span>
+ <div className={s.middle}>
+ <SectionNameType name={name} type={`${vehicleType} / ${behavior}`} />
+ <div className={s.gray}>
+ {ruleCount < 2 ? `${ruleCount} rule` : `${ruleCount} rules`}
+ </div>
+ <small className={s.gray}>Updated {timeAgo} ago</small>
+ </div>
+ <span className={s.refreshButtonWrapper}>
+ <Button onClick={onClickRefreshButton} disabled={isRefreshing}>
+ <RotatableRotateCw isRotating={isRefreshing} />
+ </Button>
+ </span>
+ </div>
+ );
+}
+
diff --git a/src/components/rules/TextFilter.tsx b/src/components/rules/TextFilter.tsx
new file mode 100644
index 0000000..a3cc29e
--- /dev/null
+++ b/src/components/rules/TextFilter.tsx
@@ -0,0 +1,18 @@
+import * as React from 'react';
+import { useTextInut } from 'src/hooks/useTextInput';
+import { ruleFilterText } from 'src/store/rules';
+
+import shared from '../shared.module.css';
+
+export function TextFilter() {
+ const [onChange, text] = useTextInut(ruleFilterText);
+ return (
+ <input
+ className={shared.input}
+ type="text"
+ value={text}
+ onChange={onChange}
+ placeholder="Filter"
+ />
+ );
+}
diff --git a/src/hooks/useTextInput.ts b/src/hooks/useTextInput.ts
new file mode 100644
index 0000000..1fa19f7
--- /dev/null
+++ b/src/hooks/useTextInput.ts
@@ -0,0 +1,23 @@
+import debounce from 'lodash-es/debounce';
+import * as React from 'react';
+import { RecoilState, useRecoilState } from 'recoil';
+
+const { useCallback, useState, useMemo } = React;
+
+export function useTextInut(
+ x: RecoilState<string>
+): [(e: React.ChangeEvent<HTMLInputElement>) => void, string] {
+ const [, setTextGlobal] = useRecoilState(x);
+ const [text, setText] = useState('');
+ const setTextDebounced = useMemo(() => debounce(setTextGlobal, 300), [
+ setTextGlobal,
+ ]);
+ const onChange = useCallback(
+ (e: React.ChangeEvent<HTMLInputElement>) => {
+ setText(e.target.value);
+ setTextDebounced(e.target.value);
+ },
+ [setTextDebounced]
+ );
+ return [onChange, text];
+}
diff --git a/src/store/index.js b/src/store/index.js
index 0e559ad..bd4e7a9 100644
--- a/src/store/index.js
+++ b/src/store/index.js
@@ -8,14 +8,12 @@ import { initialState as configs } from './configs';
import { initialState as logs } from './logs';
import { initialState as modals } from './modals';
import { actions as proxiesActions, initialState as proxies } from './proxies';
-import { initialState as rules } from './rules';
export const initialState = {
app: app(),
modals,
configs,
proxies,
- rules,
logs,
};
diff --git a/src/store/logs.js b/src/store/logs.js
index c3b3b9a..6da2b33 100644
--- a/src/store/logs.js
+++ b/src/store/logs.js
@@ -2,9 +2,9 @@ import { createSelector } from 'reselect';
const LogSize = 300;
-const getLogs = s => s.logs.logs;
-const getTail = s => s.logs.tail;
-export const getSearchText = s => s.logs.searchText;
+const getLogs = (s) => s.logs.logs;
+const getTail = (s) => s.logs.tail;
+export const getSearchText = (s) => s.logs.searchText;
export const getLogsForDisplay = createSelector(
getLogs,
getTail,
@@ -21,13 +21,13 @@ export const getLogsForDisplay = createSelector(
}
if (searchText === '') return x;
- return x.filter(r => r.payload.toLowerCase().indexOf(searchText) >= 0);
+ return x.filter((r) => r.payload.toLowerCase().indexOf(searchText) >= 0);
}
);
export function updateSearchText(text) {
- return dispatch => {
- dispatch('logsUpdateSearchText', s => {
+ return (dispatch) => {
+ dispatch('logsUpdateSearchText', (s) => {
s.logs.searchText = text.toLowerCase();
});
};
@@ -42,7 +42,7 @@ export function appendLog(log) {
// mutate intentionally for performance
logs[tail] = log;
- dispatch('logsAppendLog', s => {
+ dispatch('logsAppendLog', (s) => {
s.logs.tail = tail;
});
};
@@ -52,5 +52,5 @@ export const initialState = {
searchText: '',
logs: [],
// tail's initial value must be -1
- tail: -1
+ tail: -1,
};
diff --git a/src/store/rules.js b/src/store/rules.js
index 61e3328..bdd835d 100644
--- a/src/store/rules.js
+++ b/src/store/rules.js
@@ -1,58 +1,6 @@
-import invariant from 'invariant';
-import { createSelector } from 'reselect';
+import { atom } from 'recoil';
-import * as rulesAPI from '../api/rules';
-
-export const getAllRules = (s) => s.rules.allRules;
-export const getSearchText = (s) => s.rules.searchText;
-export const getRules = createSelector(
- getSearchText,
- getAllRules,
- (searchText, allRules) => {
- if (searchText === '') return allRules;
- return allRules.filter((r) => r.payload.indexOf(searchText) >= 0);
- }
-);
-export function updateSearchText(text) {
- return (dispatch) => {
- dispatch('rulesUpdateSearchText', (s) => {
- s.rules.searchText = text.toLowerCase();
- });
- };
-}
-
-export function fetchRules(apiConfig) {
- return async (dispatch) => {
- const res = await rulesAPI.fetchRules(apiConfig);
- const json = await res.json();
- invariant(
- json.rules && json.rules.length >= 0,
- 'there is no valid rules list in the rules API response'
- );
-
- // attach an id
- const allRules = json.rules.map((r, i) => {
- r.id = i;
- return r;
- });
-
- dispatch('rulesFetchRules', (s) => {
- s.rules.allRules = allRules;
- });
- };
-}
-
-export function fetchRulesOnce(apiConfig) {
- return async (dispatch, getState) => {
- const allRules = getAllRules(getState());
- if (allRules.length === 0) return await dispatch(fetchRules(apiConfig));
- };
-}
-
-// {"type":"FINAL","payload":"","proxy":"Proxy"}
-// {"type":"IPCIDR","payload":"172.16.0.0/12","proxy":"DIRECT"}
-export const initialState = {
- // filteredRules: [],
- allRules: [],
- searchText: '',
-};
+export const ruleFilterText = atom({
+ key: 'ruleFilterText',
+ default: '',
+});
diff --git a/src/types.ts b/src/types.ts
index e69de29..047f3fb 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -0,0 +1,5 @@
+export type ClashAPIConfig = {
+ hostname: string;
+ port: number;
+ secret?: string;
+};