├── .husky └── pre-commit ├── packages ├── ui │ ├── src │ │ ├── assets │ │ │ └── img │ │ │ │ ├── .gitkeep │ │ │ │ ├── view.jpg │ │ │ │ └── flower.jpg │ │ ├── utility │ │ │ ├── sync │ │ │ │ └── webdav │ │ │ │ │ ├── index.ts │ │ │ │ │ └── helper.ts │ │ │ ├── generate-class-name.ts │ │ │ ├── hasher.ts │ │ │ ├── text-util.ts │ │ │ ├── ts-filter.ts │ │ │ ├── remote.ts │ │ │ ├── hooks │ │ │ │ ├── useImmerState.ts │ │ │ │ └── useIsDarkMode.ts │ │ │ ├── remote-rules.ts │ │ │ ├── valtio-helper.ts │ │ │ └── subscribe.ts │ │ ├── libs │ │ │ └── dnd.ts │ │ ├── modules │ │ │ ├── code-editor │ │ │ │ ├── CodeEditor.module.less │ │ │ │ ├── monaco │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── theme.ts │ │ │ │ │ ├── setup.ts │ │ │ │ │ └── key-binding.ts │ │ │ │ ├── index.ts │ │ │ │ ├── CodeThemeSelect.tsx │ │ │ │ ├── ModalCodeViewer.tsx │ │ │ │ └── CodeEditor.tsx │ │ │ ├── commands │ │ │ │ ├── index.tsx │ │ │ │ ├── run.tsx │ │ │ │ └── theme │ │ │ │ │ └── one-light.module.less │ │ │ ├── markdown │ │ │ │ └── index.tsx │ │ │ ├── global-model.ts │ │ │ └── global-loading │ │ │ │ ├── wrapComponent.tsx │ │ │ │ └── index.tsx │ │ ├── libs.ts │ │ ├── valtio.d.ts │ │ ├── storage │ │ │ ├── config.ts │ │ │ ├── index.ts │ │ │ └── customMerge.ts │ │ ├── pages │ │ │ ├── _layout │ │ │ │ ├── _shared.ts │ │ │ │ └── RootLayout.tsx │ │ │ ├── current-config │ │ │ │ ├── index.module.less │ │ │ │ ├── model.ts │ │ │ │ ├── ConfigDND.module.less │ │ │ │ ├── index.tsx │ │ │ │ └── ConfigDND.tsx │ │ │ ├── preference │ │ │ │ ├── index.module.less │ │ │ │ ├── fragments.tsx │ │ │ │ ├── model.ts │ │ │ │ └── modal │ │ │ │ │ ├── tree.txt │ │ │ │ │ └── SelectExport.tsx │ │ │ ├── home │ │ │ │ ├── index.module.less │ │ │ │ ├── index.tsx │ │ │ │ └── useAddRuleModal.tsx │ │ │ ├── subscribe-list │ │ │ │ ├── special │ │ │ │ │ └── nodefree.tsx │ │ │ │ ├── store.auto-update.ts │ │ │ │ └── store.tsx │ │ │ └── partial-config-list │ │ │ │ ├── index.module.less │ │ │ │ ├── model.auto-update.ts │ │ │ │ └── model.ts │ │ ├── ipc.ts │ │ ├── typings.d.ts │ │ ├── common │ │ │ ├── global.less │ │ │ └── index.ts │ │ ├── auto-imports.d.ts │ │ ├── types │ │ │ ├── index.ts │ │ │ └── ClashConfig.ts │ │ ├── store.ts │ │ └── index.tsx │ ├── .ncurc.json │ ├── index.html │ ├── uno.config.ts │ ├── NOTES.md │ ├── vite.config.ts │ └── package.json ├── clash-utils │ ├── README.md │ ├── src │ │ ├── index.ts │ │ ├── define │ │ │ ├── index.ts │ │ │ ├── ss.ts │ │ │ ├── ssr.ts │ │ │ └── vmess.ts │ │ ├── utils.ts │ │ └── subscribe.ts │ ├── tsconfig.json │ └── package.json └── main │ ├── src │ ├── ipc │ │ ├── index.ts │ │ ├── proxy.ts │ │ ├── win.ts │ │ ├── common.ts │ │ └── dialog.ts │ ├── global.d.ts │ ├── index.ts │ ├── init-meta.ts │ ├── devtool-extensions.ts │ ├── auto-update.ts │ ├── menu.ts │ └── main.ts │ ├── tsdown.config.ts │ └── package.json ├── .prettierignore ├── static ├── Icon.png └── Icon-default.png ├── assets ├── box@2x.png ├── cat@2x.png ├── cherry@2x.png └── readme.txt ├── electron-builder.js ├── .github ├── dependabot.yml └── workflows │ ├── ci.yml │ └── release.yml ├── prettier.config.cjs ├── bak ├── demo.applescript ├── ga.txt ├── main │ ├── rollup.package.txt │ └── rollup.config.mjs └── Loyalsoldier-clash-rules.json ├── eslint.config.js ├── .npmrc ├── .editorconfig ├── pnpm-workspace.yaml ├── turbo.json ├── .gitignore ├── jakefile.ts ├── index.ts ├── util.ts └── release.ts ├── .vscode └── settings.json ├── tsconfig.json ├── tsconfig.base.json ├── public └── index.html ├── License ├── license ├── NOTE.md ├── electron-builder.config.ts ├── package.json ├── README.md └── CHANGELOG.md /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | lint-staged 2 | -------------------------------------------------------------------------------- /packages/ui/src/assets/img/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /packages/ui/src/utility/sync/webdav/index.ts: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /packages/ui/src/libs/dnd.ts: -------------------------------------------------------------------------------- 1 | export * from '@dnd-kit/modifiers' 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | # .gitignore extra 2 | pnpm-lock.yaml 3 | auto-imports.d.ts 4 | -------------------------------------------------------------------------------- /static/Icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/magicdawn/clash-config-manager/HEAD/static/Icon.png -------------------------------------------------------------------------------- /assets/box@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/magicdawn/clash-config-manager/HEAD/assets/box@2x.png -------------------------------------------------------------------------------- /assets/cat@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/magicdawn/clash-config-manager/HEAD/assets/cat@2x.png -------------------------------------------------------------------------------- /assets/cherry@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/magicdawn/clash-config-manager/HEAD/assets/cherry@2x.png -------------------------------------------------------------------------------- /packages/clash-utils/README.md: -------------------------------------------------------------------------------- 1 | # clash-utils 2 | 3 | > parse subscribe text to clash proxy server config 4 | -------------------------------------------------------------------------------- /packages/ui/src/modules/code-editor/CodeEditor.module.less: -------------------------------------------------------------------------------- 1 | .editor { 2 | border: 1px solid purple; 3 | } 4 | -------------------------------------------------------------------------------- /static/Icon-default.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/magicdawn/clash-config-manager/HEAD/static/Icon-default.png -------------------------------------------------------------------------------- /packages/main/src/ipc/index.ts: -------------------------------------------------------------------------------- 1 | import './common' 2 | import './dialog' 3 | import './win' 4 | import './proxy' 5 | -------------------------------------------------------------------------------- /packages/ui/src/libs.ts: -------------------------------------------------------------------------------- 1 | export { default as YAML } from 'js-yaml' 2 | export { default as fse } from 'fs-extra' 3 | -------------------------------------------------------------------------------- /packages/clash-utils/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './define' 2 | export * from './subscribe' 3 | export * from './utils' 4 | -------------------------------------------------------------------------------- /packages/ui/src/modules/code-editor/monaco/index.ts: -------------------------------------------------------------------------------- 1 | import './key-binding' 2 | import './setup' 3 | import './theme' 4 | -------------------------------------------------------------------------------- /electron-builder.js: -------------------------------------------------------------------------------- 1 | import 'tsx' 2 | const config = (await import('./electron-builder.config.ts')).default 3 | export default config 4 | -------------------------------------------------------------------------------- /packages/ui/src/assets/img/view.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/magicdawn/clash-config-manager/HEAD/packages/ui/src/assets/img/view.jpg -------------------------------------------------------------------------------- /packages/ui/src/valtio.d.ts: -------------------------------------------------------------------------------- 1 | import 'valtio' 2 | declare module 'valtio' { 3 | function useSnapshot(p: T): T 4 | } 5 | -------------------------------------------------------------------------------- /packages/ui/src/assets/img/flower.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/magicdawn/clash-config-manager/HEAD/packages/ui/src/assets/img/flower.jpg -------------------------------------------------------------------------------- /packages/ui/src/modules/code-editor/index.ts: -------------------------------------------------------------------------------- 1 | export * from './CodeEditor' 2 | export * from './CodeThemeSelect' 3 | export * from './ModalCodeViewer' 4 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - directory: / 4 | package-ecosystem: github-actions 5 | schedule: 6 | interval: daily 7 | -------------------------------------------------------------------------------- /assets/readme.txt: -------------------------------------------------------------------------------- 1 | icon-park 的图标 2 | 图标类型选择填充, 填充颜色为 #fff 3 | 下载 png 即可 4 | 5 | 缩略图 6 | 拖到 squoosh 7 | - 选择 browser png encoder, 8 | - 选择 resize, 32x32, 9 | -------------------------------------------------------------------------------- /packages/ui/src/utility/generate-class-name.ts: -------------------------------------------------------------------------------- 1 | import { css as generateClassName } from '@emotion/css' 2 | export const styled = { generateClassName } 3 | -------------------------------------------------------------------------------- /prettier.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | ...require('@magicdawn/prettier-config'), 3 | // slow 4 | // plugins: ['prettier-plugin-organize-imports'], 5 | } 6 | -------------------------------------------------------------------------------- /packages/ui/.ncurc.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://raw.githubusercontent.com/raineorshine/npm-check-updates/main/src/types/RunOptions.json", 3 | "reject": ["monaco-themes"] 4 | } 5 | -------------------------------------------------------------------------------- /bak/demo.applescript: -------------------------------------------------------------------------------- 1 | -- tell application "ClashX" 2 | -- toggleProxy 3 | -- end 4 | 5 | -- tell application "Google Chrome" 6 | -- get URL of active tab of first window 7 | -- end tell 8 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import { fromSxzz } from '@magicdawn/eslint-config' 2 | 3 | export default fromSxzz({}, [ 4 | { ignores: ['**/dist/', '**/bundle/', 'bak/'] }, // custom ignore, 5 | ]) 6 | -------------------------------------------------------------------------------- /bak/ga.txt: -------------------------------------------------------------------------------- 1 | useEffect(() => { 2 | ;(window as any).gtag?.('event', 'page_view', { 3 | // eslint-disable-next-line camelcase 4 | page_path: pathname, 5 | }) 6 | }, [pathname]) 7 | -------------------------------------------------------------------------------- /packages/clash-utils/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.base.json", 3 | "include": ["./src"], 4 | "compilerOptions": { 5 | "declaration": true, 6 | "outDir": "dist" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /packages/ui/src/storage/config.ts: -------------------------------------------------------------------------------- 1 | import type { StorageData } from './index' 2 | 3 | export const keysToOmit = ['subscribe_detail' as const, 'subscribe_status' as const] satisfies (keyof StorageData)[] 4 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | # default false, `pnpm add` fails, have to use `pnpm add -w` 2 | ignore-workspace-root-check=true 3 | 4 | # -r include root 5 | include-workspace-root=true 6 | 7 | # use zsh alias: ncu-safe 8 | # not working 9 | # script-shell=/bin/zsh 10 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | tab_width = 2 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | 11 | [*.yml] 12 | indent_style = space 13 | indent_size = 2 14 | -------------------------------------------------------------------------------- /packages/ui/src/pages/_layout/_shared.ts: -------------------------------------------------------------------------------- 1 | import { css } from '@emotion/react' 2 | 3 | export const sharedPageCss = { 4 | page: css` 5 | height: calc(100vh - 46px); 6 | padding: 10px 0; 7 | display: flex; 8 | flex-direction: column; 9 | overflow: hidden; 10 | `, 11 | } 12 | -------------------------------------------------------------------------------- /packages/main/src/global.d.ts: -------------------------------------------------------------------------------- 1 | import type { BrowserWindow } from 'electron' 2 | 3 | interface CurrentMainWindow extends BrowserWindow { 4 | preventClose?: () => void 5 | stopPreventClose?: () => void 6 | } 7 | 8 | declare global { 9 | var mainWindow: CurrentMainWindow | undefined 10 | } 11 | -------------------------------------------------------------------------------- /packages/main/src/ipc/proxy.ts: -------------------------------------------------------------------------------- 1 | import { ipcMain, session } from 'electron' 2 | 3 | ipcMain.handle('set-use-system-proxy', async (event, useSystem: boolean) => { 4 | await session.defaultSession.closeAllConnections() 5 | await session.defaultSession.setProxy({ mode: useSystem ? 'system' : 'direct' }) 6 | }) 7 | -------------------------------------------------------------------------------- /packages/ui/src/ipc.ts: -------------------------------------------------------------------------------- 1 | import { ipcRenderer } from 'electron' 2 | import { runGenerate, runGenerateForceUpdate } from './modules/commands/run' 3 | 4 | ipcRenderer.on('generate', () => { 5 | runGenerate() 6 | }) 7 | ipcRenderer.on('generate-force-update', () => { 8 | runGenerateForceUpdate() 9 | }) 10 | -------------------------------------------------------------------------------- /packages/clash-utils/src/define/index.ts: -------------------------------------------------------------------------------- 1 | import type { ClashSsProxyItem } from './ss' 2 | import type { ClashSsrProxyItem } from './ssr' 3 | import type { ClashVmessProxyItem } from './vmess' 4 | 5 | export * from './ssr' 6 | export * from './vmess' 7 | 8 | export type ClashProxyItem = ClashVmessProxyItem | ClashSsrProxyItem | ClashSsProxyItem 9 | -------------------------------------------------------------------------------- /packages/main/src/ipc/win.ts: -------------------------------------------------------------------------------- 1 | import { BrowserWindow, ipcMain } from 'electron' 2 | 3 | ipcMain.handle('set-top-most', (event, flag: boolean) => { 4 | const win = BrowserWindow.getFocusedWindow() 5 | if (!win) return 6 | 7 | if (flag) { 8 | win.setAlwaysOnTop(flag, 'modal-panel') 9 | } else { 10 | win.setAlwaysOnTop(flag) 11 | } 12 | }) 13 | -------------------------------------------------------------------------------- /packages/ui/src/utility/hasher.ts: -------------------------------------------------------------------------------- 1 | import { createHash } from 'node:crypto' 2 | 3 | function hashFnFactory(hashName: string) { 4 | return (s: string) => createHash(hashName).update(s, 'utf8').digest('hex') 5 | } 6 | 7 | export const md5 = hashFnFactory('md5') 8 | export const sha1 = hashFnFactory('sha1') 9 | export const sha256 = hashFnFactory('sha256') 10 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - packages/** 3 | 4 | overrides: 5 | ant-float-label>antd: '6' 6 | ant-float-label>react: '19' 7 | ant-float-label>react-dom: '19' 8 | ignoreBuiltDependencies: 9 | - less 10 | 11 | onlyBuiltDependencies: 12 | - '@swc/core' 13 | - core-js 14 | - electron 15 | - esbuild 16 | - tldjs 17 | - unrs-resolver 18 | -------------------------------------------------------------------------------- /packages/ui/src/utility/text-util.ts: -------------------------------------------------------------------------------- 1 | export function firstLine(text: string) { 2 | return (text || '').split('\n')[0] || '' 3 | } 4 | 5 | export function limitLines(text = '', count = 100) { 6 | const lines = text.split('\n') 7 | 8 | if (lines.length <= count) { 9 | return text 10 | } 11 | 12 | return [...lines.slice(0, count), '......'].join('\n') 13 | } 14 | -------------------------------------------------------------------------------- /turbo.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://turbo.build/schema.json", 3 | "ui": "stream", 4 | "tasks": { 5 | "build": { 6 | "dependsOn": ["^build"] 7 | }, 8 | "main#build": { 9 | "outputs": ["../../bundle/production/main/**"] 10 | }, 11 | "ui#build": { 12 | "outputs": ["../../bundle/production/renderer/**"] 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /packages/ui/src/pages/current-config/index.module.less: -------------------------------------------------------------------------------- 1 | .page { 2 | padding: 10px 15px; 3 | 4 | display: flex; 5 | flex-direction: column; 6 | height: calc(100vh - 46px); 7 | overflow: hidden; 8 | } 9 | 10 | .open-btns { 11 | display: flex; 12 | justify-content: space-between; 13 | margin-top: 5px; 14 | 15 | button { 16 | width: 32%; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /packages/ui/src/typings.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'applescript' { 2 | export function execString(code: string, cb: (err: Error | undefined, result: any) => void): void 3 | } 4 | 5 | declare module 'launch-editor' { 6 | export default function launch( 7 | file: string, 8 | editor?: string, 9 | cb?: (fileName: string, errorMsg: string) => void, 10 | ): void 11 | } 12 | -------------------------------------------------------------------------------- /packages/main/src/ipc/common.ts: -------------------------------------------------------------------------------- 1 | import path from 'node:path' 2 | import { app, ipcMain } from 'electron' 3 | 4 | export const assetsDir = app.isPackaged 5 | ? path.join(process.resourcesPath, 'assets/') 6 | : path.join(import.meta.dirname, '../../../assets/') // from bundle/development/main/ 7 | 8 | ipcMain.handle('getAssetsDir', (event) => { 9 | return assetsDir 10 | }) 11 | -------------------------------------------------------------------------------- /bak/main/rollup.package.txt: -------------------------------------------------------------------------------- 1 | "_dev": "rollup -c -w", 2 | "_build": "NODE_ENV=production rollup -c", 3 | 4 | "rollup": "^3.26.2", 5 | "rollup-plugin-esbuild": "^5.0.0", 6 | "rollup-plugin-tsconfig-paths": "^1.5.1", 7 | 8 | "@rollup/plugin-commonjs": "^25.0.3", 9 | "@rollup/plugin-json": "^6.0.0", 10 | "@rollup/plugin-node-resolve": "^15.1.0", 11 | "@rollup/plugin-replace": "^5.0.2", 12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | report.*.json 3 | .DS_Store 4 | 5 | # build 6 | /bundle 7 | 8 | # the package 9 | /dist 10 | 11 | # tsc dist 12 | /packages/**/dist 13 | 14 | # changelog temp 15 | CHANGELOG.temp.md 16 | 17 | .yarn/* 18 | !.yarn/releases 19 | !.yarn/plugins 20 | !.yarn/sdks 21 | !.yarn/versions 22 | .pnp.* 23 | 24 | *.log 25 | 26 | src-tauri/target 27 | 28 | .turbo 29 | 30 | lib-tests/ 31 | -------------------------------------------------------------------------------- /jakefile.ts/index.ts: -------------------------------------------------------------------------------- 1 | import 'jake' 2 | import { release, releaseChangelog } from './release' 3 | 4 | import { sh } from './util' 5 | 6 | desc('同 `gulp -T`') 7 | task('default', () => { 8 | sh('jake -t', { silent: true }) 9 | }) 10 | desc('发布 release') 11 | task('release', release) 12 | 13 | namespace('release', () => { 14 | desc('发布 release: changelog') 15 | task('changelog', releaseChangelog) 16 | }) 17 | -------------------------------------------------------------------------------- /packages/main/src/index.ts: -------------------------------------------------------------------------------- 1 | import './init-meta' 2 | import './ipc' 3 | import contextMenu from 'electron-context-menu' 4 | import debug from 'electron-debug' 5 | import unhandled from 'electron-unhandled' 6 | import fixPath from 'fix-path' 7 | import { initMainWindow } from './main' 8 | 9 | function initCommon() { 10 | unhandled() 11 | debug() 12 | contextMenu() 13 | fixPath() 14 | } 15 | 16 | initCommon() 17 | initMainWindow() 18 | -------------------------------------------------------------------------------- /packages/ui/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Clash Config Manager 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /packages/ui/src/utility/ts-filter.ts: -------------------------------------------------------------------------------- 1 | export function nonNullable(value: T): value is NonNullable { 2 | return value !== null && value !== undefined 3 | } 4 | 5 | export type Truthy = T extends false | '' | 0 | null | undefined ? never : T // from lodash 6 | 7 | // https://stackoverflow.com/questions/47632622/typescript-and-filter-boolean?answertab=trending#tab-top 8 | export function truthy(value: T): value is Truthy { 9 | return !!value 10 | } 11 | -------------------------------------------------------------------------------- /packages/main/src/init-meta.ts: -------------------------------------------------------------------------------- 1 | import path from 'node:path' 2 | import { app } from 'electron' 3 | import { bundleId, name } from '../../../package.json' 4 | 5 | const prod = process.env.NODE_ENV === 'production' 6 | 7 | // Note: Must match `build.appId` in package.json 8 | app.setAppUserModelId(bundleId) 9 | 10 | // userData 11 | const appDataPath = app.getPath('appData') 12 | const userDataPath = path.join(appDataPath, prod ? name : name) 13 | app.setPath('userData', userDataPath) 14 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "node_modules/typescript/lib", 3 | "typescript.locale": "en", 4 | "files.exclude": { 5 | "**/.git": true, 6 | "**/.svn": true, 7 | "**/.hg": true, 8 | "**/CVS": true, 9 | "**/.DS_Store": true, 10 | "**/Thumbs.db": true, 11 | "**/node_modules": false 12 | }, 13 | "typescript.preferences.organizeImports": { 14 | "typeOrder": "last" 15 | }, 16 | "editor.codeActionsOnSave": { 17 | "source.fixAll.eslint": "explicit" 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /packages/ui/src/common/global.less: -------------------------------------------------------------------------------- 1 | :root { 2 | --bg: #fff; 3 | --c: #000; 4 | --border-c: #eee; 5 | 6 | scrollbar-width: thin; 7 | } 8 | 9 | :root.dark { 10 | --c: #eee; 11 | --bg: #121212; 12 | --border-c: #333; 13 | 14 | a { 15 | color: #809fff; 16 | } 17 | } 18 | 19 | body { 20 | color: var(--c); 21 | background-color: var(--bg); 22 | } 23 | 24 | .ant-message { 25 | .ant-message-custom-content { 26 | span[role='img'] + span { 27 | word-break: break-all; 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /jakefile.ts/util.ts: -------------------------------------------------------------------------------- 1 | import { execSync } from 'node:child_process' 2 | import path from 'node:path' 3 | import log from 'fancy-log' 4 | import minimist from 'minimist' 5 | 6 | export const argv = minimist(process.argv.slice(2)) 7 | export const PROJECT_ROOT = path.join(__dirname, '..') 8 | 9 | export const sh = (cmd: string, { silent = false }: { silent?: boolean } = {}) => { 10 | if (!silent) log('[exec]: %s', cmd) 11 | if (argv['dry-run']) { 12 | // just print 13 | } else { 14 | execSync(cmd, { stdio: 'inherit' }) 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /packages/main/src/ipc/dialog.ts: -------------------------------------------------------------------------------- 1 | import { BrowserWindow, dialog, ipcMain } from 'electron' 2 | 3 | ipcMain.handle('select-file', async (event) => { 4 | const win = BrowserWindow.fromWebContents(event.sender) 5 | if (!win) return 6 | 7 | const { canceled, filePaths } = await dialog.showOpenDialog(win, { 8 | properties: ['openFile'], 9 | filters: [ 10 | { name: 'json', extensions: ['json'] }, 11 | { name: 'All Files', extensions: ['*'] }, 12 | ], 13 | }) 14 | 15 | if (canceled) { 16 | return 17 | } else { 18 | return filePaths[0] 19 | } 20 | }) 21 | -------------------------------------------------------------------------------- /packages/ui/src/pages/preference/index.module.less: -------------------------------------------------------------------------------- 1 | .page { 2 | margin: 10px; 3 | 4 | :global { 5 | .header { 6 | display: flex; 7 | justify-content: space-between; 8 | align-items: center; 9 | } 10 | } 11 | } 12 | 13 | .modal { 14 | :global { 15 | .ant-modal-body { 16 | padding: 15px; 17 | } 18 | 19 | .label { 20 | width: 80px; 21 | text-align: right; 22 | padding-right: 10px; 23 | } 24 | 25 | .input-row { 26 | margin-top: 10px; 27 | &:last-child { 28 | margin-bottom: 10px; 29 | } 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /packages/clash-utils/src/utils.ts: -------------------------------------------------------------------------------- 1 | import { createHash } from 'node:crypto' 2 | 3 | export const B64 = { 4 | encode: (s: string) => Buffer.from(s, 'utf-8').toString('base64'), 5 | decode: (s: string) => Buffer.from(s, 'base64').toString('utf-8'), 6 | } 7 | 8 | export const md5 = (s: string) => createHash('md5').update(s, 'utf8').digest('hex') 9 | 10 | export type Truthy = T extends false | '' | 0 | null | undefined ? never : T // from lodash 11 | 12 | // https://stackoverflow.com/questions/47632622/typescript-and-filter-boolean?answertab=trending#tab-top 13 | export function truthy(value: T): value is Truthy { 14 | return !!value 15 | } 16 | -------------------------------------------------------------------------------- /packages/ui/src/modules/commands/index.tsx: -------------------------------------------------------------------------------- 1 | import CommandPalette from 'react-command-palette' 2 | import { commandPaletteRef, commands } from './run' 3 | // theme 4 | import oneLightTheme from './theme/one-light.module.less' 5 | 6 | export default function Commands() { 7 | return ( 8 | } 13 | resetInputOnClose 14 | alwaysRenderCommands 15 | theme={oneLightTheme} 16 | options={{ keys: ['name', 'key'] }} 17 | placeholder='Type to start' 18 | /> 19 | ) 20 | } 21 | -------------------------------------------------------------------------------- /packages/ui/src/pages/preference/fragments.tsx: -------------------------------------------------------------------------------- 1 | import { Checkbox, Tooltip } from 'antd' 2 | import { ipcRenderer } from 'electron' 3 | import { useSnapshot } from 'valtio' 4 | import { state } from './model' 5 | 6 | export function ConfigForUseSystemProxy() { 7 | const { useSystemProxy } = useSnapshot(state) 8 | return ( 9 | { 12 | state.useSystemProxy = e.target.checked 13 | ipcRenderer.invoke('set-use-system-proxy', e.target.checked) 14 | }} 15 | > 16 | 使用系统代理 17 | 18 | ) 19 | } 20 | -------------------------------------------------------------------------------- /packages/ui/src/pages/home/index.module.less: -------------------------------------------------------------------------------- 1 | .page { 2 | padding: 10px; 3 | 4 | .title { 5 | padding-left: 20px; 6 | } 7 | 8 | .btn-gen-wrapper { 9 | display: flex; 10 | flex-direction: column; 11 | 12 | .btn() { 13 | height: 60px; 14 | font-size: 25px; 15 | &:not(:first-child) { 16 | margin-top: 20px; 17 | } 18 | } 19 | 20 | .btn { 21 | .btn(); 22 | } 23 | 24 | .btn-gen { 25 | .btn(); 26 | } 27 | .btn-gen-force-update { 28 | .btn(); 29 | background-color: #004d62; 30 | color: #fff; 31 | } 32 | .btn-add-rule { 33 | .btn(); 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.base.json", 3 | "include": ["./packages/**/*.ts", "./packages/**/*.tsx"], 4 | "compilerOptions": { 5 | "baseUrl": "./packages", 6 | "paths": { 7 | "$common": ["common/src/"], 8 | "$common/*": ["common/src/*"], 9 | "$main": ["main/src/"], 10 | "$main/*": ["main/src/*"], 11 | "$ui": ["ui/src/"], 12 | "$ui/*": ["ui/src/*"], 13 | "$clash-utils": ["clash-utils/src/"], 14 | "$clash-utils/*": ["clash-utils/src/*"] 15 | }, 16 | "noEmit": true 17 | }, 18 | "ts-node": { 19 | "swc": true, 20 | "compilerOptions": { 21 | "module": "CommonJS" 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /packages/ui/src/modules/markdown/index.tsx: -------------------------------------------------------------------------------- 1 | import Markdown from 'react-markdown' 2 | import rehypeExternalLinks from 'rehype-external-links' 3 | import remarkGfm from 'remark-gfm' 4 | import type { ComponentProps } from 'react' 5 | 6 | export function MarkdownView({ 7 | className, 8 | children, 9 | ...restProps 10 | }: ComponentProps & { className?: string }) { 11 | return ( 12 |
13 | 18 | {children} 19 | 20 |
21 | ) 22 | } 23 | -------------------------------------------------------------------------------- /packages/main/tsdown.config.ts: -------------------------------------------------------------------------------- 1 | import path from 'node:path' 2 | import { findUpSync } from 'find-up-simple' 3 | import { defineConfig } from 'tsdown' 4 | 5 | const env = process.env.NODE_ENV || 'development' 6 | const REPO_ROOT = path.dirname(findUpSync('pnpm-workspace.yaml', { cwd: import.meta.dirname })!) 7 | 8 | export default defineConfig({ 9 | entry: ['./src/index.ts'], 10 | format: 'esm', 11 | outDir: path.join(REPO_ROOT, `bundle/${env}/main/`), 12 | clean: true, 13 | platform: 'node', 14 | // TODO: get node version based on electron version 15 | // output from `pnpm electron -i`: `Using: Node.js v22.15.1 and Electron.js v36.3.2` 16 | target: 'node22', 17 | env: { NODE_ENV: env }, 18 | external: ['electron'], 19 | noExternal: [/.*/], 20 | }) 21 | -------------------------------------------------------------------------------- /packages/ui/uno.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig, presetWind4, transformerDirectives, transformerVariantGroup } from 'unocss' 2 | 3 | export default defineConfig({ 4 | transformers: [transformerDirectives(), transformerVariantGroup()], 5 | 6 | presets: [ 7 | presetWind4({ 8 | preflights: { reset: false, theme: { mode: 'on-demand' } }, 9 | dark: { dark: '.dark' }, 10 | }), 11 | ], 12 | 13 | // https://github.com/unocss/unocss/issues/1620 14 | blocklist: ['container'], 15 | 16 | theme: { 17 | colors: {}, 18 | }, 19 | 20 | shortcuts: { 21 | 'flex-v-center': 'flex items-center', 22 | 'flex-center': 'flex items-center justify-center', 23 | 'inline-flex-center': 'inline-flex items-center justify-center', 24 | }, 25 | }) 26 | -------------------------------------------------------------------------------- /packages/clash-utils/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "clash-proxy-parser", 3 | "version": "0.0.1", 4 | "description": "parse subscribe text to clash proxy server config", 5 | "author": "magicdawn", 6 | "license": "MIT", 7 | "homepage": "https://github.com/magicdawn/clash-config-manager", 8 | "repository": { 9 | "type": "git", 10 | "url": "https://github.com/magicdawn/clash-config-manager" 11 | }, 12 | "keywords": [ 13 | "clash", 14 | "ssr", 15 | "clashx" 16 | ], 17 | "main": "dist/index.js", 18 | "types": "dist/index.d.ts", 19 | "files": [ 20 | "dist" 21 | ], 22 | "scripts": { 23 | "dev": "tsc", 24 | "build": "tsc", 25 | "prepublishOnly": "tsc" 26 | }, 27 | "devDependencies": { 28 | "typescript": "^5.9.3" 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /packages/ui/src/modules/code-editor/monaco/theme.ts: -------------------------------------------------------------------------------- 1 | import themelist from 'monaco-themes/themes/themelist.json' 2 | 3 | import { monaco } from './setup' 4 | const themeModules = import.meta.glob('monaco-themes-json-dir/**/*.json', { eager: true }) 5 | const themeModuleKeys = Object.keys(themeModules) 6 | 7 | for (const [name, fileWithoutExt] of Object.entries(themelist)) { 8 | const themeModuleKey = themeModuleKeys.find((k) => k.endsWith(`${fileWithoutExt}.json`)) 9 | if (!themeModuleKey) continue 10 | 11 | const themeData = themeModules[themeModuleKey] as monaco.editor.IStandaloneThemeData 12 | monaco.editor.defineTheme(name, themeData) 13 | } 14 | 15 | export const userDefinedThemes = Object.keys(themelist) 16 | 17 | export const builtinThemes: string[] = ['vs', 'vs-dark', 'hc-light', 'hc-black'] 18 | -------------------------------------------------------------------------------- /packages/ui/NOTES.md: -------------------------------------------------------------------------------- 1 | ## react@18 2 | 3 | packages/ui 4 | ├─┬ react-beautiful-dnd 5 | │ ├── ✕ unmet peer react@"^16.8.5 || ^17.0.0": found 18.2.0 6 | │ ├── ✕ unmet peer react-dom@"^16.8.5 || ^17.0.0": found 18.2.0 7 | │ └─┬ use-memo-one 8 | │ └── ✕ unmet peer react@"^16.8.0 || ^17.0.0": found 18.2.0 9 | ├─┬ react-command-palette 10 | │ ├── ✕ unmet peer react@17.x: found 18.2.0 11 | │ ├── ✕ unmet peer react-dom@17.x: found 18.2.0 12 | │ └─┬ react-modal 13 | │ ├── ✕ unmet peer react@"^0.14.0 || ^15.0.0 || ^16 || ^17": found 18.2.0 14 | │ └── ✕ unmet peer react-dom@"^0.14.0 || ^15.0.0 || ^16 || ^17": found 18.2.0 15 | ├─┬ react-router 16 | │ └─┬ mini-create-react-context 17 | │ └── ✕ unmet peer react@"^0.14.0 || ^15.0.0 || ^16.0.0 || ^17.0.0": found 18.2.0 18 | └─┬ recompose 19 | └── ✕ unmet peer react@"^0.14.0 || ^15.0.0 || ^16.0.0": found 18.2.0 20 | -------------------------------------------------------------------------------- /packages/main/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "main", 3 | "type": "module", 4 | "private": true, 5 | "scripts": { 6 | "dev": "tsdown --watch", 7 | "build": "NODE_ENV=production tsdown" 8 | }, 9 | "dependencies": { 10 | "bytes": "^3.1.2", 11 | "debug": "^4.4.3", 12 | "electron-context-menu": "^4.1.1", 13 | "electron-debug": "^4.1.0", 14 | "electron-log": "^5.4.3", 15 | "electron-unhandled": "^5.0.0", 16 | "electron-updater": "^6.7.3", 17 | "electron-util": "^0.18.1", 18 | "electron-window-state": "^5.0.3", 19 | "env-paths": "^3.0.0", 20 | "fix-path": "^4.0.0", 21 | "ms": "^2.1.3" 22 | }, 23 | "devDependencies": { 24 | "@types/bytes": "^3.1.5", 25 | "@types/debug": "^4.1.12", 26 | "@types/ms": "^2.1.0", 27 | "find-up-simple": "^1.0.1", 28 | "tsdown": "^0.18.1" 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /tsconfig.base.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2023", 4 | "lib": ["ES2024", "DOM"], 5 | "module": "ESNext", 6 | "moduleResolution": "Bundler", 7 | "esModuleInterop": true, 8 | "allowSyntheticDefaultImports": true, 9 | "strict": true, 10 | "skipLibCheck": true, 11 | "types": ["node", "electron", "unplugin-icons/types/react"], 12 | "jsx": "react-jsx", 13 | "jsxImportSource": "@emotion/react", 14 | // "verbatimModuleSyntax": true, // import {type X} from 'type-fest' 会得到保留, 不使用 verbatimModuleSyntax 15 | "experimentalDecorators": true, 16 | "allowJs": true, 17 | "useUnknownInCatchVariables": false, 18 | "plugins": [ 19 | { 20 | "name": "typescript-plugin-css-modules", 21 | "options": { 22 | "classnameTransform": "camelCaseOnly" 23 | } 24 | } 25 | ] 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | concurrency: 9 | group: ${{ github.workflow }} 10 | cancel-in-progress: true 11 | 12 | jobs: 13 | build-app: 14 | runs-on: macos-latest 15 | 16 | # success or timeout 17 | timeout-minutes: 10 18 | 19 | steps: 20 | - uses: actions/checkout@v4 21 | 22 | - uses: pnpm/action-setup@v4 23 | 24 | - uses: actions/setup-node@v4 25 | with: 26 | node-version: lts/* 27 | cache: pnpm 28 | 29 | # install deps 30 | - run: pnpm install --frozen-lockfile 31 | 32 | # turbo cache 33 | - uses: actions/cache@v4 34 | with: 35 | path: node_modules/.cache/turbo 36 | key: turbo-cache-${{ runner.os }} 37 | 38 | # build only 39 | - run: pnpm build 40 | 41 | # build .app file 42 | # - run: pnpm dist:pack 43 | -------------------------------------------------------------------------------- /packages/ui/src/modules/code-editor/monaco/setup.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * https://github.com/vitejs/vite/discussions/1791 3 | * https://github.com/Microsoft/monaco-editor/blob/main/docs/integrate-esm.md#using-vite 4 | */ 5 | 6 | // react-monaco-editor => momaco-editor/esm/vs/editor/editor.api.js, 而此 entry 只包含基础编辑功能 7 | // 使用 import monaco-editor => monaco-editor/esm/vs/editor.main.js, 导入全部功能(包含其他 language, 查找,替换模块) 8 | 9 | // 简单包含所有功能 10 | // import 'monaco-editor' 11 | 12 | // 也可以更为详细的定制 13 | import 'monaco-editor/esm/vs/basic-languages/yaml/yaml.contribution.js' 14 | import 'monaco-editor/esm/vs/editor/edcore.main.js' 15 | 16 | import * as monaco from 'monaco-editor/esm/vs/editor/editor.api.js' // for api usage 17 | import editorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker' 18 | 19 | export { monaco } 20 | 21 | self.MonacoEnvironment = { 22 | getWorker(_, label) { 23 | return new editorWorker() 24 | }, 25 | } 26 | -------------------------------------------------------------------------------- /packages/main/src/devtool-extensions.ts: -------------------------------------------------------------------------------- 1 | import os from 'node:os' 2 | import debugFactory from 'debug' 3 | import { session } from 'electron' 4 | import fs from 'fs-extra' 5 | 6 | const debug = debugFactory('ccm:dev:ext') 7 | 8 | async function loadExt(id: string) { 9 | const extDir = `${os.homedir()}/Library/Application Support/Google/Chrome/Default/Extensions/${id}` 10 | if (!(await fs.exists(extDir))) return 11 | 12 | const ver = (await fs.readdir(extDir))[0] 13 | const extVerDir = `${extDir}/${ver}` 14 | 15 | debug('add %s', extVerDir) 16 | return session.defaultSession.extensions.loadExtension(extVerDir, { allowFileAccess: true }) 17 | } 18 | 19 | export function initLoadDevtoolExtensions() { 20 | const ids = [ 21 | 'fmkadmapgofadopljbjfkapdkoienihi', // react-devtools 22 | 'lmhkpmbekcpmknklioeibfkpmmfibljd', // redux 23 | 'nhdogjmejiglipccpnnnanhbledajbpd', // vue 24 | ] 25 | return Promise.all(ids.map((id) => loadExt(id))) 26 | } 27 | -------------------------------------------------------------------------------- /packages/ui/src/utility/remote.ts: -------------------------------------------------------------------------------- 1 | import path from 'node:path' 2 | import fse from 'fs-extra' 3 | import ky from 'ky' 4 | import moment from 'moment' 5 | import { appCacheDir } from '$ui/common' 6 | import { md5 } from './hasher' 7 | 8 | export async function readUrlWithCache(url: string, forceUpdate = false) { 9 | const file = path.join(appCacheDir, 'readUrl', md5(url)) 10 | 11 | let shouldReuse = false 12 | let stat: fse.Stats 13 | 14 | // 今天之内的更新不会再下载 15 | const isRecent = (mtime: Date) => moment(mtime).format('YYYY-MM-DD') === moment().format('YYYY-MM-DD') 16 | if (!forceUpdate && (await fse.pathExists(file)) && (stat = await fse.stat(file)) && isRecent(stat.mtime)) { 17 | shouldReuse = true 18 | } 19 | 20 | let text: string 21 | if (shouldReuse) { 22 | text = await fse.readFile(file, 'utf8') 23 | } else { 24 | text = await ky.get(url).text() 25 | await fse.outputFile(file, text, 'utf8') 26 | } 27 | 28 | return { text, byRequest: !shouldReuse } 29 | } 30 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | <%= html.title %> 8 | <% if (pkg.description) { %> 9 | 10 | <% } %> 11 | 12 | 13 | 16 | 17 | 18 | 19 | 25 | 26 |
27 | 28 | 29 | -------------------------------------------------------------------------------- /packages/ui/src/utility/hooks/useImmerState.ts: -------------------------------------------------------------------------------- 1 | import { produce } from 'immer' 2 | import { useCallback, useState } from 'react' 3 | 4 | const reducer = produce((draft, payload) => { 5 | // invalid payload 6 | if (typeof payload === 'undefined') return 7 | 8 | if (typeof payload === 'object') { 9 | Object.assign(draft, payload) 10 | return 11 | } 12 | 13 | if (typeof payload === 'function') { 14 | return payload(draft) 15 | } 16 | 17 | // others just replace with payload 18 | // number / string / boolean / null ... 19 | return payload 20 | }) 21 | 22 | type PayloadFn = (draft: T) => T | void | undefined | null 23 | type Payload = Partial | PayloadFn 24 | 25 | export default function useImmerState( 26 | initialState: T | (() => T), 27 | ): [T, (payload: Payload) => void] { 28 | const [state, setState] = useState(initialState) 29 | const modifyState = useCallback( 30 | (payload: Payload) => { 31 | setState((state) => reducer(state, payload)) 32 | }, 33 | [setState], 34 | ) 35 | return [state, modifyState] 36 | } 37 | -------------------------------------------------------------------------------- /packages/ui/src/modules/global-model.ts: -------------------------------------------------------------------------------- 1 | import Emitter from 'emittery' 2 | import type { NavigateFunction } from 'react-router' 3 | 4 | export const globalEmitter = new Emitter<{ init: undefined; reload: undefined }>() 5 | 6 | // : Parameters 有重载的情况不准确 7 | const navigate: NavigateFunction = function (...args) { 8 | // @ts-ignore 9 | navigateSingleton?.(...args) 10 | } 11 | 12 | // actions 13 | export const actions = { 14 | init, 15 | reload, 16 | 17 | // router navigate 18 | navigate, 19 | } 20 | 21 | let inited = false 22 | function init() { 23 | if (inited) return 24 | globalEmitter.emit('init') 25 | inited = true 26 | } 27 | 28 | function reload() { 29 | globalEmitter.emit('reload') 30 | } 31 | 32 | // for define models 33 | export function onInit(cb: () => void) { 34 | globalEmitter.on('init', cb) 35 | } 36 | export function onReload(cb: () => void) { 37 | globalEmitter.on('reload', cb) 38 | } 39 | 40 | export let navigateSingleton: NavigateFunction | null = null 41 | export function setNavigateSingleton(nav: NavigateFunction) { 42 | navigateSingleton = nav 43 | } 44 | -------------------------------------------------------------------------------- /License: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Magicdawn (https://magicdawn.fun) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /license: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Magicdawn (https://magicdawn.fun) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /packages/ui/src/common/index.ts: -------------------------------------------------------------------------------- 1 | import * as remote from '@electron/remote' 2 | import { ipcRenderer } from 'electron' 3 | import envPaths from 'env-paths' 4 | import pMemoize from 'p-memoize' 5 | 6 | export const APP_NAME = 'clash-config-manager' 7 | 8 | /** 9 | * dirs this app will use 10 | */ 11 | 12 | // ~/Library/Application Support/clash-config-manager 13 | export const userDataPath = remote.app.getPath('userData') 14 | 15 | export const appEnvPaths = envPaths(APP_NAME, { suffix: '' }) 16 | 17 | // ~/Library/Caches/clash-config-manager 18 | export const appCacheDir = appEnvPaths.cache 19 | 20 | // $TMPDIR/clash-config-manager 21 | export const appTempDir = appEnvPaths.temp 22 | 23 | // bundled assets 24 | export const getAssetsDir = pMemoize(async () => { 25 | return await ipcRenderer.invoke('getAssetsDir') 26 | }) 27 | 28 | export const __DEV__ = import.meta.env.DEV 29 | export const __PROD__ = import.meta.env.PROD 30 | 31 | export const colorHighlightIdentifier = '--hightlight-color' 32 | export const colorHighlightValue = `var(${colorHighlightIdentifier})` 33 | export const colorHighlightHex = `#01847F` // 马尔斯绿 34 | -------------------------------------------------------------------------------- /packages/clash-utils/src/subscribe.ts: -------------------------------------------------------------------------------- 1 | import { urlLineToClashSsrServer, urlLineToClashVmessServer, type ClashProxyItem, type VmessUrlLine } from './define' 2 | import { urlLineToClashSsServer } from './define/ss' 3 | import { B64, truthy } from './utils' 4 | 5 | export function textToSubscribe(text: string) { 6 | text = B64.decode(text) 7 | const rawLines = text.split(/\r?\n/).filter(Boolean) 8 | 9 | const servers = rawLines 10 | .map((line) => { 11 | const idx = line.indexOf('://') 12 | const type = line.slice(0, idx) 13 | let text = line.slice(idx + '://'.length) 14 | text = B64.decode(text) 15 | 16 | let server: ClashProxyItem | undefined 17 | if (type === 'vmess') { 18 | const line = JSON.parse(text) as VmessUrlLine 19 | server = urlLineToClashVmessServer(line) 20 | } 21 | if (type === 'ss') { 22 | server = urlLineToClashSsServer(line) 23 | } 24 | if (type === 'ssr') { 25 | server = urlLineToClashSsrServer(text) 26 | } 27 | 28 | return server 29 | }) 30 | .filter(truthy) 31 | 32 | return servers 33 | } 34 | -------------------------------------------------------------------------------- /packages/ui/src/modules/code-editor/monaco/key-binding.ts: -------------------------------------------------------------------------------- 1 | import { monaco } from './setup' 2 | 3 | /** 4 | * https://github.com/microsoft/monaco-editor/issues/102 5 | */ 6 | 7 | const { KeyCode, KeyMod } = monaco 8 | 9 | monaco.editor.addKeybindingRules([ 10 | // ctrl + cmd + up/down 11 | { 12 | keybinding: KeyMod.CtrlCmd | KeyMod.WinCtrl | KeyCode.UpArrow, 13 | command: 'editor.action.moveLinesUpAction', 14 | }, 15 | { 16 | keybinding: KeyMod.CtrlCmd | KeyMod.WinCtrl | KeyCode.DownArrow, 17 | command: 'editor.action.moveLinesDownAction', 18 | }, 19 | 20 | // font size 21 | { 22 | keybinding: KeyMod.CtrlCmd | KeyCode.Minus, 23 | command: 'editor.action.fontZoomOut', 24 | }, 25 | { 26 | keybinding: KeyMod.CtrlCmd | KeyCode.Equal, 27 | command: 'editor.action.fontZoomIn', 28 | }, 29 | { 30 | keybinding: KeyMod.CtrlCmd | KeyCode.Digit0, 31 | command: 'editor.action.fontZoomReset', 32 | }, 33 | 34 | // cmd+shift+D duplicate line 35 | { 36 | keybinding: KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.KeyD, 37 | command: 'editor.action.duplicateSelection', 38 | }, 39 | ]) 40 | -------------------------------------------------------------------------------- /packages/ui/src/pages/subscribe-list/special/nodefree.tsx: -------------------------------------------------------------------------------- 1 | import moment from 'moment' 2 | import type { Subscribe } from '$ui/types' 3 | 4 | export type NodefreeData = { 5 | recentDays: number 6 | } 7 | 8 | export type NodefreeSubscribe = Subscribe 9 | 10 | export const defaultNodefreeSubscribe: NodefreeSubscribe = { 11 | name: 'nodefree', 12 | id: crypto.randomUUID(), 13 | url: 'internal://nodefree', 14 | autoUpdate: true, 15 | 16 | special: true, 17 | specialType: 'nodefree', 18 | specialData: { 19 | recentDays: 3, 20 | }, 21 | } 22 | 23 | export function nodefreeGetUrls(subscribe: NodefreeSubscribe): string[] { 24 | const recentDays = subscribe.specialData?.recentDays 25 | 26 | if (!recentDays) { 27 | return [] 28 | } 29 | 30 | // https://nodefree.org/dy/2023/01/2023010x.yaml 31 | const tpl = (m: moment.Moment) => 32 | `https://nodefree.org/dy/${m.format('YYYY')}/${m.format('MM')}/${m.format('YYYYMMDD')}.yaml` 33 | 34 | const urls: string[] = [] 35 | for (let i = 0; i < recentDays; i++) { 36 | // 从前一天开始, 当天的经常 404 报错 37 | const m = moment().subtract(i, 'days') 38 | urls.push(tpl(m)) 39 | } 40 | 41 | return urls 42 | } 43 | -------------------------------------------------------------------------------- /packages/ui/src/utility/remote-rules.ts: -------------------------------------------------------------------------------- 1 | import path from 'node:path' 2 | import fse from 'fs-extra' 3 | import { appCacheDir } from '$ui/common' 4 | import type { RemoteRuleItem } from '$ui/types' 5 | import { readUrlWithCache } from './remote' 6 | 7 | // use cacheDir because this is cleanable 8 | // you can recover from url settings 9 | export function externalFileForRuleItem(id: string) { 10 | return path.join(appCacheDir, `remote-rule-content/${id}.yml`) 11 | } 12 | 13 | async function saveRomoteRuleItem(id: string, content: string) { 14 | const file = externalFileForRuleItem(id) 15 | await fse.outputFile(file, content, 'utf8') 16 | } 17 | 18 | export async function getRuleItemContent(id: string) { 19 | const file = externalFileForRuleItem(id) 20 | if (!(await fse.pathExists(file))) return '' 21 | return fse.readFile(file, 'utf8') 22 | } 23 | 24 | export async function updateRemoteConfig(item: RemoteRuleItem, forceUpdate = false) { 25 | const { url } = item 26 | const { text: content, byRequest } = await readUrlWithCache(url, forceUpdate) 27 | 28 | // save 29 | await saveRomoteRuleItem(item.id, content) 30 | if (byRequest) item.updatedAt = Date.now() 31 | 32 | return { byRequest } 33 | } 34 | -------------------------------------------------------------------------------- /packages/ui/src/modules/code-editor/CodeThemeSelect.tsx: -------------------------------------------------------------------------------- 1 | import { Select } from 'antd' 2 | import { useSnapshot } from 'valtio' 3 | import { state as preferenceState } from '../../pages/preference/model' 4 | import { builtinThemes, userDefinedThemes } from './monaco/theme' 5 | import type { ComponentProps, CSSProperties } from 'react' 6 | 7 | type TOptions = ComponentProps['options'] 8 | 9 | const options: TOptions = [ 10 | ...builtinThemes.map((t) => ({ label: t, value: t })), 11 | { label: '------------', disabled: true }, 12 | ...userDefinedThemes.map((t) => ({ label: t, value: t })), 13 | ] 14 | 15 | export function CodeThemeSelect({ 16 | style, 17 | className, 18 | disabled = false, 19 | width = 150, 20 | }: { 21 | style?: CSSProperties 22 | className?: string 23 | disabled?: boolean 24 | width?: number 25 | }) { 26 | const theme = useSnapshot(preferenceState).vscodeTheme || 'vs' 27 | 28 | return ( 29 | { 87 | const name = e.target.value 88 | state.name = name 89 | }} 90 | /> 91 | 92 | 93 | 94 | 95 |
105 | 文件地址 106 |
107 | 108 | 109 | 110 | 111 |
112 | 113 |
114 | { 117 | state.clashMeta = e.target.checked 118 | }} 119 | > 120 | 为 Clash.Meta 生成 121 | 122 | 123 | { 126 | state.generateAllProxyGroup = e.target.checked 127 | }} 128 | > 129 | 生成 所有节点 组 130 | 131 | 132 | { 135 | state.generateSubNameProxyGroup = e.target.checked 136 | }} 137 | > 138 | 139 | 生成 订阅名同名 组 140 | 141 | 142 | 143 | { 146 | state.generateSubNameFallbackProxyGroup = e.target.checked 147 | }} 148 | > 149 | 150 | 生成 SubName-可用 组 151 | 152 | 153 | 154 | { 157 | state.generatedGroupNameEmoji = e.target.checked 158 | }} 159 | > 160 | 订阅组 emoji 161 | 162 | 163 | { 166 | const lang = e.target.checked ? 'zh' : 'en' 167 | state.generatedGroupNameLang = lang 168 | }} 169 | > 170 | 174 | ✅ 使用中文: {ProxyGroupTypeConfig['url-test'].nameZh} / {ProxyGroupTypeConfig.fallback.nameZh} /{' '} 175 | {ProxyGroupTypeConfig.select.nameZh} 176 |
❎ 使用英文: {ProxyGroupTypeConfig['url-test'].nameEn} / {ProxyGroupTypeConfig.fallback.nameEn}{' '} 177 | / {ProxyGroupTypeConfig.select.nameEn} 178 | 179 | } 180 | > 181 | 订阅组 中文 182 |
183 |
184 |
185 |
186 | 187 | 190 |
191 | 194 | 197 | 200 |
201 | 202 | ) 203 | } 204 | -------------------------------------------------------------------------------- /packages/ui/src/pages/preference/modal/SelectExport.tsx: -------------------------------------------------------------------------------- 1 | import { useMemoizedFn, useUpdateEffect } from 'ahooks' 2 | import { Modal, Tree, type TreeProps } from 'antd' 3 | import { cloneDeep, pick } from 'es-toolkit' 4 | import { useCallback, useState, type Key } from 'react' 5 | import { proxy, useSnapshot } from 'valtio' 6 | import { storageDataDisplayNames, type ExportData } from '$ui/storage' 7 | import { truthy } from '$ui/utility/ts-filter' 8 | import type { ConfigItem } from '$ui/types' 9 | import type { Merge } from 'type-fest' 10 | 11 | type SelectExportProps = { 12 | visible: boolean 13 | setVisible: (val: boolean) => void 14 | treeData?: TreeData[] // can not be null 15 | resolve?: Resolve | null 16 | } 17 | 18 | type SelectResult = { 19 | cancel: boolean 20 | keys?: Key[] 21 | } 22 | 23 | type Resolve = (result: SelectResult) => void 24 | 25 | export default function SelectExport({ visible, setVisible, treeData, resolve }: SelectExportProps) { 26 | const onCancel = useMemoizedFn(() => { 27 | setVisible(false) 28 | resolve?.({ cancel: true }) 29 | }) 30 | const onOk = useMemoizedFn(() => { 31 | setVisible(false) 32 | resolve?.({ cancel: false, keys: checkedKeys }) 33 | }) 34 | 35 | const [expandedKeys, setExpandedKeys] = useState(() => getAllKeys(treeData)) 36 | const [checkedKeys, setCheckedKeys] = useState([]) 37 | const [selectedKeys, setSelectedKeys] = useState([]) 38 | const [autoExpandParent, setAutoExpandParent] = useState(true) 39 | 40 | useUpdateEffect(() => { 41 | setExpandedKeys(getAllKeys(treeData)) 42 | }, [treeData]) 43 | 44 | const onExpand = (expandedKeys: Key[]) => { 45 | // if not set autoExpandParent to false, if children expanded, parent can not collapse. 46 | // or, you can remove all expanded children keys. 47 | console.log('onExpand', expandedKeys) 48 | setExpandedKeys(expandedKeys) 49 | setAutoExpandParent(false) 50 | } 51 | 52 | const onCheck: TreeProps['onCheck'] = (checkedKeys) => { 53 | console.log('onCheck', checkedKeys) 54 | if (Array.isArray(checkedKeys)) { 55 | setCheckedKeys(checkedKeys) 56 | } 57 | } 58 | 59 | const onSelect: TreeProps['onSelect'] = (selectedKeys, info) => { 60 | console.log('onSelect', info) 61 | setSelectedKeys(selectedKeys) 62 | } 63 | 64 | return ( 65 | 73 | 84 | 85 | ) 86 | } 87 | 88 | type TreeData = { 89 | key: string 90 | title: string 91 | children?: TreeData[] 92 | } 93 | 94 | function generateTreeData(obj: object, keyPrefix = '') { 95 | const treeData: TreeData[] = [] 96 | 97 | if (!obj) { 98 | return treeData 99 | } 100 | if (typeof obj !== 'object') { 101 | return treeData 102 | } 103 | 104 | if (Array.isArray(obj)) { 105 | obj.forEach((item, index) => { 106 | const key = `${keyPrefix}${index}` 107 | let title = `index=${index}` 108 | 109 | if (keyPrefix === 'subscribe_list.' || keyPrefix === 'rule_list.') { 110 | title += ` (${(item as any).name})` 111 | } 112 | 113 | if (keyPrefix === 'current_config_v2.list.') { 114 | title += ` -> ${(item as any).type === 'subscribe' ? '订阅' : '配置源'} (${(item as any).name})` 115 | } 116 | 117 | treeData.push({ 118 | key, 119 | title, 120 | }) 121 | }) 122 | 123 | return treeData 124 | } 125 | 126 | for (const currentKey of Object.keys(obj)) { 127 | const key = keyPrefix + currentKey 128 | const title = storageDataDisplayNames[key] || currentKey 129 | treeData.push({ 130 | key, 131 | title, 132 | children: generateTreeData(obj[currentKey as keyof typeof obj], `${key}.`), 133 | }) 134 | } 135 | 136 | return treeData 137 | } 138 | 139 | function getAllKeys(tree?: TreeData[] | null) { 140 | if (!tree || !tree.length) return [] 141 | let ret: string[] = [] 142 | tree.forEach((item) => { 143 | ret.push(item.key) 144 | ret = ret.concat(getAllKeys(item.children)) 145 | }) 146 | return ret 147 | } 148 | 149 | function clean(obj: T) { 150 | if (!obj || typeof obj !== 'object') return 151 | for (const i of Object.keys(obj) as (keyof T)[]) { 152 | const val: any = obj[i] 153 | 154 | if (Array.isArray(val)) { 155 | ;(obj as any)[i] = val.filter(truthy) 156 | continue 157 | } 158 | 159 | clean(val) 160 | } 161 | } 162 | 163 | const proxyProps = proxy<{ 164 | visible: boolean 165 | treeData?: TreeData[] | null 166 | resolve?: Resolve | null 167 | }>({ 168 | visible: true, 169 | treeData: null, 170 | resolve: null, 171 | }) 172 | 173 | export function SelectExportForStaticMethod() { 174 | const { treeData, visible, resolve } = useSnapshot(proxyProps) 175 | 176 | const setVisible = useCallback((val: boolean) => { 177 | proxyProps.visible = val 178 | }, []) 179 | 180 | if (!treeData) { 181 | return null 182 | } 183 | 184 | return 185 | } 186 | 187 | type PickupData = Omit 188 | 189 | // Merge is Object.assign for Types 190 | type PickupDataExtended = Merge< 191 | PickupData, 192 | { 193 | current_config_v2: Merge< 194 | ExportData['current_config_v2'], 195 | { 196 | list: (ConfigItem & { name?: string })[] 197 | } 198 | > 199 | } 200 | > 201 | 202 | export async function pickDataFrom(dataFrom: any) { 203 | const treeSource = cloneDeep(dataFrom) as Partial 204 | 205 | // current_config_v2 添加 name 字段 206 | if (treeSource?.current_config_v2?.list) { 207 | treeSource.current_config_v2.list.forEach((item, i) => { 208 | const { id } = item 209 | const target = 210 | treeSource?.subscribe_list?.find((i) => i.id === id) || treeSource?.rule_list?.find((i) => i.id === id) 211 | item.name = target?.name 212 | }) 213 | treeSource.current_config_v2.list = treeSource.current_config_v2.list.filter((item) => { 214 | return !!item.name 215 | }) 216 | } 217 | 218 | const treeData = generateTreeData(treeSource) 219 | 220 | const { cancel, keys } = await new Promise((resolve) => { 221 | Object.assign(proxyProps, { treeData, visible: true, resolve }) 222 | }) 223 | 224 | if (cancel || !keys?.length) { 225 | return { cancel, keys } 226 | } 227 | 228 | // sleect 229 | const data = pick( 230 | dataFrom, 231 | keys.map((x) => x.toString()), 232 | ) 233 | 234 | // clean 235 | clean(data) 236 | 237 | return { cancel, keys, data } 238 | } 239 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## v0.19.2 2023-07-17 4 | 5 | - fix: 包含 filter 的 proxy-group, proxies filter 结果为空时, clashx 报错的问题. (移除该 proxy-group) 6 | - feat: ClashX.Meta 配置文件迁移到了 `~/.config/clash.meta/` 文件夹了, 该工具支持 `Clash.Meta` 勾选 7 | 8 | 详细: 9 | 10 | - deps change in several commits 11 | - 78bd471 updated at 2023-06-08 16:17 by mc git-up 12 | - 2f02e9f feat: migrate to data router 13 | - 86d9fe2 feat: only skip write when content unchanged & mtime is today 14 | 15 | ## v0.19.1 2023-05-20 16 | 17 | - 添加规则弹窗打开时, 窗口置顶. 方便从浏览器或其他地方复制 url. 18 | - 升级依赖 19 | 20 | 更多更改: 21 | 22 | - cbdb23b chore: none ncu-safe deps 23 | - 58fd67d chore: ncu-safe -r 24 | - 9c5723f feat: set window top most when AddRuleModal show 25 | - 5c9854f feat: use antd.App & antd.App.useApp 26 | - f2985c8 chore: tweak 27 | - 290bcd5 chore: add timeout limit to ci.yml 28 | - b83109f chore: ci only on main branch 29 | - 076311b chore: use ts-reset 30 | 31 | ## v0.19.0 2023-03-28 32 | 33 | - 升级 ant-design 到 v5, v5 UI 非常好看 👍 34 | - 深色模式 35 | 36 | 更多更改: 37 | 38 | - 80253ee feat: dark mode 39 | - 8074a58 chore: use ky insteads of umi-request 40 | - 3f35b52 feat: organize imports for source code 41 | - 28a717d feat: 配置无变化时不再写入文件 42 | - 03eb92c chore: use rollup v3 43 | - 637d2a8 chore: update more deps 44 | - 3390341 chore: ncu-safe 45 | - a274463 feat: clean up tray menu 46 | - ff0f449 chore: update deps & update antd v5 47 | - 3182029 chore: fix nodefree urls 48 | - 5b0035d chore: tweak 49 | 50 | ## v0.18.3 2023-01-08 51 | 52 | - 5385e3c feat: 支持 proxy-group.filter, 示例 `{name: 🇯🇵JPN, type: select, proxies: [], filter: JPN}` 53 | - d1a8b54 feat: make ALL group & sub-name group 可配置 54 | - 6a875dc chore: 缩减 monaco editor 使用 55 | - 2071b8d feat: 规范 cache / temp dir 的使用 56 | 57 | ## v0.18.2 2022-12-14 58 | 59 | - fix export / import json logic 60 | 61 | ## v0.18.1 2022-12-13 62 | 63 | - fix config merge in generate logic 64 | 65 | ## v0.18.0 2022-12-13 66 | 67 | - feat: 支持多个订阅, 每个订阅会生成 `<订阅>` / `<订阅>-最快` / `<订阅>-可用` / `<订阅>-手选` 分组, 分别对应 `url-test` / `fallback` / `select` 类型的分组 68 | - feat: 支持添加特殊订阅 nodefree 69 | - feat: 将 remote / remote-rule-provider 内容移出 electron-store, 解决因此导致的卡顿 70 | 71 | ## v0.17.0 2022-11-17 72 | 73 | - b26f0bf tweak tooltip style 74 | - 11d8f3e feat: add duplicate line key-binding 75 | - 59561f2 feat: add monaco-editor custom keybinding 76 | - 4b31d88 feat: current-config add scroll bar 77 | - 0a84843 chore: tweak theme selector 78 | - 88b3353 feat: add monaco themes 79 | - cde1426 feat: rm runCommand 80 | - 42e431b feat: do not update not used items 81 | - 6b1773b feat: auto-update, do not update item not using or disabled 82 | - 58c14eb dep: update electron to latest 83 | - 16bb2b6 ci: build only 84 | - 6f57caa chore: electron-build.js tweak artifactName 85 | - a54562f fix: remove renderer fs sync calls 86 | 87 | ## v0.16.0 2022-11-15 88 | 89 | - 订阅: 支持查看节点 90 | - 配置组装: 使用中的配置, 支持 toggle 91 | - 配置源: 支持 `rule-provider` 类型的远程规则, 为了使用 https://github.com/Loyalsoldier/clash-rules, 但是生成的配置文件非常大... 92 | 93 | ## v0.15.0 2022-11-03 94 | 95 | - 订阅逻辑切换, 之前是使用自己 parse `ss://` / `vmess://` 协议, 改为 96 | 使用 `user-agent: ClashX`,让机场返回 clash config yaml, 从 yaml 中摘取 `proxies` 字段 97 | - `user-agent: ClashX` 会返回 `subscription-userinfo` header, 反应了使用量, 可以在更新订阅后直观看到使用量 98 | 99 | ## v0.14.0 2022-11-03 100 | 101 | - 修正订阅更新按钮改为从网络更新 102 | - 添加托盘图标, 关闭窗口后隐藏到托盘, 此时自动更新任务还会自动跑 103 | 104 | ## v0.13.0 2022-09-02 105 | 106 | - chore: update deps 107 | - fix(ui): fix global Pacman Loading 108 | - chore: tweak style & fix edit partial config readonly mode 109 | - chore: add github actions config 110 | - feat: add hide icon for subscribe url 111 | - chore: update screenshots 112 | 113 | ## v0.12.3 2022-08-16 114 | 115 | - chore: fix btn disabled conditions (12 days ago) 116 | - c3f60c1 - feat: impl button 添加纯规则配置 (12 days ago) 117 | - 411c95c - chore: tweak current-config page style (13 days ago) 118 | - aced774 - chore: rename pages & adjust table title style (13 days ago) 119 | - cbc9319 - chore(vite): fix dev (2 weeks ago) 120 | - 20f8c43 - chore: clean up deps (2 weeks ago) 121 | 122 | ## v0.12.2 2022-07-27 123 | 124 | - fix: fix yaml usage error 125 | 126 | ## v0.12.1 2022-07-27 127 | 128 | - fix: 在 renderer 使用 esm, 解决 monaco editor yaml syntax 使用 `dynamic import` 的问题, c34815c 129 | 130 | ## v0.12.0 2022-07-27 131 | 132 | - fix: remove undefined in yaml, 7d5ea9b 133 | - fix: fix external link breaks app state, b8e45e2 134 | - chore: update lots of deps, 666df98 135 | - chore: fix monaco-editor usage, adba7fa 136 | 137 | ## v0.11.0 2022-07-12 138 | 139 | - chore: 在 SelectExport 中去掉删除的遗留项 140 | - feat: add note for webdav service config 141 | - fix: use ?? insteadof || for boolean fields, 修复是否自动更新, 无法取消掉的问题 142 | - fix: fix RuleAddModal 使用 clipboard 读取 url 不好使的问题 143 | - chore: add m1 arch build 144 | - chore: clean up or update deps, `@types/*`, `webdav` etc 145 | 146 | ## v0.10.0 2022-07-09 147 | 148 | - [x] 重构: 移除 easy-peasy / redux, 使用 valtio 作为全局状态管理 149 | - [x] 重构: 移除 rxjs BehaviorSubject / recompose 等, 使用 valtio 全局组件 150 | - [x] 重构: 开启 TypeScript strictNullChecks 151 | - [x] 重构: UI 优化 152 | - [x] react-router v6 153 | - [x] feat: 订阅支持自动更新, 并因此更新配置 154 | 155 | ## v0.9.0 2022-07-02 156 | 157 | - [x] yarn -> pnpm 158 | - [x] poi -> rollup / vite, 原因是 poi 对 ts 支持有限 159 | - [x] increase AddRuleModal target length limit, from 10000 to 200000 160 | - [x] clean up deps, use react@18 161 | - [x] 订阅管理增加排除关键词支持, (excludeKeywords), 可以按节点名字匹配关键词忽略特定节点 162 | 163 | ## v0.8.0 164 | 165 | - 支持 `ssr://` 协议配置到 clash 166 | 167 | ## v0.7.0 168 | 169 | - it's broken for electron-updater@latest, it's using `fs/promises` module, so upgrade 170 | - electron -> v16 171 | - electron-builder -> latest 172 | - electron-store -> latest 173 | - use `@electron/remote` 174 | 175 | ## v0.6.1 176 | 177 | - chore: update `electron-*` especially electron-updater, because auto update is broken now(v0.6 / v0.5) 178 | 179 | ## v0.6.0 180 | 181 | - clash vmess `ws-path` / `ws-headers`, 变成 `ws-opts.path` / `ws-opts.headers` 更改 182 | 183 | ## v0.5.2 184 | 185 | - fix build 186 | 187 | ## v0.5.1 188 | 189 | - fix 由于 monorepo 导致 meta userData 目录不正确的问题. 190 | 191 | ## v0.5.0 192 | 193 | - monorepo 194 | - 首页 icon size 调整 195 | - auto-update 增加 catch 196 | 197 | ## v0.4.0 198 | 199 | - TypeScript 重构前端部分 200 | - 使用 easy-peasy 代替 reamtch 201 | - 配置生成区分 `forceUpdate` 和 普通生成 202 | - 主页添加生成按钮和快速添加规则按钮 203 | 204 | ## v0.3.1 205 | 206 | - 修复由于订阅中包含 ss/ssr 服务导致的生成错误. 目前是只保留 `vmess://` 服务. 207 | 208 | ## v0.3.0 2020-10-11 209 | 210 | - 使用 react-router-config 211 | - 修复选择导出 modal 关不掉的问题. (rxjs BehaviorSubject 状态同步问题) 212 | - 修复导入取消报错问题. 213 | - 更新内置的基础数据规则. 新增自定义规则模板 214 | 215 | ## v0.2.3 2020-10-05 216 | 217 | - 修复自动更新, 使用菜单显示, 修复 quitAndInstall 218 | - 使用 CCM_RUN_MODE = cli 使用 cli, 去除 yargs 219 | 220 | ## v0.2.2 2020-10-03 221 | 222 | - try to enable auto-update 223 | 224 | ## v0.2.1 2020-10-03 225 | 226 | - fix 刚开始启动时使用 command palette, generate 出错的问题. 227 | 228 | ## v0.2.0 2020-10-02 229 | 230 | - fix #1, 消息遮挡操作问题 231 | - add `code` like cli (因 yargs 不能使用 webpack 打包, 现在不起作用) 232 | - 添加 command palette 233 | - 添加吃豆人(pacman) loading 234 | 235 | ## v0.1.2 2020-09-26 236 | 237 | - fix can not quit problem 238 | 239 | ## v0.1.1 2020-09-26 240 | 241 | - fix some style issue 242 | - fix window restore problem, fix window getBounds problem 243 | 244 | ## v0.1.0 2020-09-22 245 | 246 | - embed preset config 247 | - support partial export 248 | 249 | ## v0.0.8 2020-09-22 250 | 251 | - fix urlToSubscribe use ua `electron`, as the App name includes `clash`, the prod UA includes the app name 252 | 253 | ## v0.0.7 2020-09-19 254 | 255 | - support remote config file 256 | 257 | ## v0.0.6 2020-09-19 258 | 259 | - 适配 clash core 1.0, see https://github.com/Dreamacro/clash/wiki/breaking-changes-in-1.0.0 260 | 261 | ## v0.0.5 2020-09-19 262 | 263 | - fix error can not find command `atom` / `code` 264 | 265 | ## v0.0.4 2020-09-19 266 | 267 | - [x] 快速添加规则, mc clash add-rule GUI version 268 | - [x] 记住窗口位置 269 | - [x] 导入导出(store 加密有必要, 防止扫描) 270 | - [ ] 备份不处理详情. (no need) 271 | - [x] 在 vscode/Atom 中编辑规则 272 | 273 | ## v0.0.3 2020-09-19 274 | 275 | - fix dmg icon 276 | - feat add rule 277 | - etc... 278 | 279 | ## v0.0.2 2020-09-18 280 | 281 | - add icons & make modals centered, etc UI modifications. 282 | 283 | ## v0.0.1 uknown date 284 | 285 | the usable version 286 | -------------------------------------------------------------------------------- /packages/ui/src/pages/subscribe-list/store.tsx: -------------------------------------------------------------------------------- 1 | import { isEqual, pick, uniqWith } from 'es-toolkit' 2 | import { HTTPError } from 'ky' 3 | import { ref } from 'valtio' 4 | import { fse } from '$ui/libs' 5 | import { onInit, onReload } from '$ui/modules/global-model' 6 | import storage from '$ui/storage' 7 | import { message, notification } from '$ui/store' 8 | import { getSubscribeNodesByUrl } from '$ui/utility/subscribe' 9 | import { valtioState } from '$ui/utility/valtio-helper' 10 | import type { ClashProxyItem } from '$clash-utils' 11 | import type { Subscribe } from '$ui/types' 12 | import { nodefreeGetUrls } from './special/nodefree' 13 | import { restartAutoUpdate, scheduleAutoUpdate, stopAutoUpdate } from './store.auto-update' 14 | 15 | const SUBSCRIBE_LIST_STORAGE_KEY = 'subscribe_list' 16 | const SUBSCRIBE_DETAIL_STORAGE_KEY = 'subscribe_detail' 17 | const SUBSCRIBE_STATUS_STORAGE_KEY = 'subscribe_status' 18 | 19 | interface IState { 20 | list: Subscribe[] 21 | detail: Record 22 | status: Record // 订阅状态 23 | } 24 | 25 | const { state, load, init } = valtioState( 26 | { 27 | list: [], 28 | detail: {}, 29 | status: {}, 30 | }, 31 | { 32 | persist(val) { 33 | storage.set(SUBSCRIBE_LIST_STORAGE_KEY, val.list) 34 | storage.set(SUBSCRIBE_STATUS_STORAGE_KEY, val.status) 35 | // 只保留当前 list 存在的订阅 36 | const detail = pick(val.detail, val.list.map((item) => item.url).filter(Boolean)) 37 | storage.set(SUBSCRIBE_DETAIL_STORAGE_KEY, detail) 38 | }, 39 | 40 | load() { 41 | const list = storage.get(SUBSCRIBE_LIST_STORAGE_KEY) || [] 42 | const status: Record = storage.get(SUBSCRIBE_STATUS_STORAGE_KEY) || {} 43 | 44 | // 只保留当前 list 存在的订阅 45 | const detail = pick( 46 | // eslint-disable-next-line @typescript-eslint/consistent-type-assertions 47 | storage.get(SUBSCRIBE_DETAIL_STORAGE_KEY) || ({} as IState['detail']), 48 | list.map((item) => item.url).filter(Boolean), 49 | ) 50 | for (const [url, servers] of Object.entries(detail)) { 51 | servers?.forEach((s) => ref(s)) // do not observe server object 52 | } 53 | 54 | return { list, detail, status } 55 | }, 56 | }, 57 | ) 58 | export { state } 59 | 60 | export const actions = { 61 | load, 62 | init, 63 | check, 64 | add, 65 | edit, 66 | del, 67 | update, 68 | toggleUrlVisible, 69 | } 70 | 71 | function check(payload: { url: string; name: string; editItemIndex?: number | null }) { 72 | const { url, name, editItemIndex } = payload 73 | 74 | let { list } = state 75 | if (editItemIndex || editItemIndex === 0) { 76 | list = state.list.filter((i, index) => index !== editItemIndex) 77 | } 78 | if (list.find((x) => x.url === url)) { 79 | return 'url已存在' 80 | } 81 | if (list.find((x) => x.name === name)) { 82 | return 'name已存在' 83 | } 84 | } 85 | 86 | function add(payload: Subscribe) { 87 | state.list.push(payload) 88 | restartAutoUpdate(payload) 89 | } 90 | 91 | function edit(payload: Subscribe & { editItemIndex: number }) { 92 | const { editItemIndex, ...subscribeItem } = payload 93 | 94 | // bak & save 95 | const previousSubscribeItem = state.list[editItemIndex] 96 | state.list[editItemIndex] = subscribeItem 97 | 98 | if ( 99 | previousSubscribeItem.autoUpdateInterval !== subscribeItem.autoUpdateInterval || 100 | previousSubscribeItem.autoUpdate !== subscribeItem.autoUpdate 101 | ) { 102 | restartAutoUpdate(subscribeItem) 103 | } 104 | } 105 | 106 | function del(index: number) { 107 | stopAutoUpdate(state.list[index].id) 108 | state.list.splice(index, 1) 109 | } 110 | 111 | export async function update({ 112 | idOrUrl, 113 | silent = false, 114 | successMsg, 115 | forceUpdate = false, 116 | }: { 117 | idOrUrl: string 118 | silent?: boolean 119 | successMsg?: string 120 | forceUpdate?: boolean 121 | }) { 122 | const index = state.list.findIndex((s) => s.id === idOrUrl || s.url === idOrUrl) 123 | if (index === -1) return 124 | let currentSubscribe = state.list[index] 125 | 126 | // update external `proxyUrls` 127 | { 128 | const { useSubConverter, proxyUrlsFromExternalFile, subConverterUrl } = currentSubscribe 129 | if (useSubConverter && proxyUrlsFromExternalFile) { 130 | const serviceUrl = subConverterUrl || SubConverterServiceUrls[0] 131 | if (!(await fse.exists(proxyUrlsFromExternalFile))) { 132 | throw new Error(`proxyUrlsFromExternalFile ${proxyUrlsFromExternalFile} 不存在`) 133 | } 134 | const proxyUrls = await fse.readFile(proxyUrlsFromExternalFile, 'utf-8') 135 | const url = getConvertedUrl(proxyUrls, serviceUrl) 136 | if (url !== currentSubscribe.url) { 137 | const newSubscribe = { ...currentSubscribe, proxyUrls, url } 138 | state.list[index] = newSubscribe 139 | currentSubscribe = newSubscribe 140 | } 141 | } 142 | } 143 | 144 | let servers: ClashProxyItem[] = [] 145 | let status: string | undefined 146 | let err: any 147 | 148 | // special nodefree 149 | if (currentSubscribe.specialType === 'nodefree') { 150 | const urls = nodefreeGetUrls(currentSubscribe) 151 | if (!urls.length) return 152 | servers = ( 153 | await Promise.all( 154 | urls.map(async (url) => { 155 | let currentServers: ClashProxyItem[] = [] 156 | let err: Error | undefined 157 | try { 158 | ;({ servers: currentServers } = await getSubscribeNodesByUrl({ 159 | url, 160 | forceUpdate, 161 | ua: currentSubscribe.ua, 162 | })) 163 | } catch (e) { 164 | err = e 165 | } 166 | if (err) { 167 | console.error('nodefree %s failed', url, err) 168 | } 169 | return currentServers 170 | }), 171 | ) 172 | ).flat() 173 | 174 | if (!servers.length) { 175 | message.error('更新订阅出错: 所有链接均未返回节点') 176 | return 177 | } 178 | 179 | // uniq 180 | servers = uniqWith(servers, isEqual) 181 | 182 | // name 处理 183 | servers.forEach((s) => { 184 | s.name = s.name.replace(/^[-_]/, '') 185 | }) 186 | 187 | // name 不能是 duplicate 188 | const names = new Set() 189 | servers.forEach((item) => { 190 | if (!names.has(item.name)) { 191 | names.add(item.name) 192 | return 193 | } 194 | 195 | let i = 1 196 | const newName = () => `${item.name} (DUP-${i})` 197 | while (names.has(newName())) i++ 198 | 199 | item.name = newName() 200 | names.add(item.name) 201 | }) 202 | } 203 | 204 | // normal 205 | else { 206 | try { 207 | ;({ servers, status } = await getSubscribeNodesByUrl({ 208 | url: currentSubscribe.url, 209 | forceUpdate, 210 | ua: currentSubscribe.ua, 211 | })) 212 | } catch (e) { 213 | err = e 214 | } 215 | } 216 | 217 | if (err) { 218 | let extraMessage: string | undefined 219 | if (currentSubscribe.useSubConverter && err instanceof HTTPError && err.response.status === 400) { 220 | const body = await err.response.text() 221 | if (body) extraMessage = body 222 | } 223 | notification.error({ 224 | placement: 'bottomRight', 225 | duration: false, 226 | closable: true, 227 | key: `update-subscribe-error:${currentSubscribe.id}`, 228 | title: '更新订阅出错', 229 | description: ( 230 | <> 231 | {extraMessage} 232 | {err.message || err} 233 | 234 | ), 235 | }) 236 | throw err 237 | } 238 | 239 | const keywords = currentSubscribe?.excludeKeywords || [] 240 | if (keywords.length) { 241 | for (const keyword of keywords) { 242 | servers = servers.filter((server) => server.name && !server.name.includes(keyword)) 243 | } 244 | } 245 | 246 | if (currentSubscribe.addPrefixToProxies) { 247 | servers.forEach((s) => { 248 | s.name = `${currentSubscribe.name} - ${s.name}` 249 | }) 250 | } 251 | 252 | /** 253 | * hysteris2 特殊处理 254 | */ 255 | servers.forEach((_s) => { 256 | const s = _s as any 257 | if (s.type === 'hysteria2' && s.obfs && s.obfs === 'none') { 258 | s.obfs = '' 259 | } 260 | }) 261 | 262 | if (!silent || successMsg) { 263 | const msg = successMsg || (currentSubscribe?.name ? `订阅(${currentSubscribe.name}) 更新成功` : `订阅更新成功`) 264 | message.success(msg) 265 | } 266 | 267 | // save 268 | if (currentSubscribe) currentSubscribe.updatedAt = Date.now() 269 | servers.forEach((s) => ref(s)) // prevent observe server inner 270 | state.detail[idOrUrl] = servers 271 | restartAutoUpdate(currentSubscribe) 272 | 273 | // 经过网络更新, status 一定是 string, 可能是空 string 274 | if (status !== undefined) { 275 | state.status[idOrUrl] = status || '' 276 | } 277 | } 278 | 279 | /** 280 | * listeners 281 | */ 282 | 283 | onInit(() => { 284 | init() 285 | // wait all init done 286 | process.nextTick(() => { 287 | scheduleAutoUpdate() 288 | }) 289 | }) 290 | onReload(load) 291 | 292 | function toggleUrlVisible(index: number) { 293 | const cur = state.list[index]?.urlVisible ?? true 294 | state.list[index].urlVisible = !cur 295 | } 296 | 297 | export const SubConverterServiceUrls = ['https://api.ytools.cc/sub'] 298 | 299 | export function getConvertedUrl(sub: string, converter: string) { 300 | const subUrlJoined = sub 301 | .split('\n') 302 | .map((line) => line.trim()) 303 | .filter((line) => line && !(line.startsWith('#') || line.startsWith(';'))) 304 | .join('|') 305 | const params = new URLSearchParams({ 306 | // TODO: figure out these fields means 307 | target: 'clash', 308 | insert: 'false', 309 | config: 'https://raw.githubusercontent.com/ACL4SSR/ACL4SSR/master/Clash/config/ACL4SSR_Online_Full_NoAuto.ini', 310 | append_type: 'true', 311 | emoji: 'true', 312 | list: 'true', 313 | xudp: 'false', 314 | udp: 'true', 315 | tfo: 'false', 316 | expand: 'true', 317 | scv: 'true', // skip-cert-verify 318 | fdn: 'false', 319 | new_name: 'true', 320 | url: subUrlJoined, 321 | }) 322 | 323 | const u = new URL(converter) 324 | u.search = params.toString() 325 | return u.href 326 | } 327 | -------------------------------------------------------------------------------- /packages/ui/src/pages/current-config/ConfigDND.tsx: -------------------------------------------------------------------------------- 1 | import { useMemoizedFn } from 'ahooks' 2 | import { Switch, Tooltip } from 'antd' 3 | import clsx from 'clsx' 4 | import debugFactory from 'debug' 5 | import { useEffect, useMemo, useState } from 'react' 6 | import { DragDropContext, Draggable, Droppable } from 'react-beautiful-dnd' 7 | import { useSnapshot } from 'valtio' 8 | import { limitLines } from '$ui/utility/text-util' 9 | import type { ConfigItem } from '$ui/types' 10 | import { state as libraryRuleListState } from '../partial-config-list/model' 11 | import { state as librarySubscribeState } from '../subscribe-list/store' 12 | import styles from './ConfigDND.module.less' 13 | import { state } from './model' 14 | 15 | const dndDebug = debugFactory('app:page:current-config:ConfigDND') 16 | 17 | export function ConfigDND() { 18 | // subscribe 19 | const subscribeList = useSnapshot(librarySubscribeState.list) 20 | const subscribeSourceList = useMemo(() => { 21 | return subscribeList.map((item) => { 22 | return { 23 | ...item, 24 | text: item.name, 25 | tooltip: item.url, 26 | tooltipIsYaml: false, 27 | } 28 | }) 29 | }, [subscribeList]) 30 | 31 | // rule 32 | const ruleList = useSnapshot(libraryRuleListState.list) 33 | const ruleSourceList = useMemo(() => { 34 | return ruleList.map((item) => { 35 | return { 36 | id: item.id, 37 | text: item.name, 38 | tooltip: item.type === 'local' ? limitLines(item.content, 100) : item.url, 39 | tooltipIsYaml: item.type === 'local', 40 | type: item.type, // rule-type 41 | } 42 | }) 43 | }, [ruleList]) 44 | 45 | // 只放 {type, id} 46 | const { list: resultList } = useSnapshot(state) 47 | 48 | // 具体 item 49 | const resultItemList = useMemo(() => { 50 | return resultList 51 | .map(({ type, id }) => { 52 | if (type === 'subscribe') { 53 | return subscribeSourceList.find((x) => x.id === id) 54 | } 55 | if (type === 'rule') { 56 | return ruleSourceList.find((x) => x.id === id) 57 | } 58 | }) 59 | .filter(Boolean) 60 | }, [resultList, ruleSourceList]) 61 | 62 | // 从 list 删除已经不存在的 id 63 | useEffect(() => { 64 | if (resultList.length === resultItemList.length) return 65 | state.list = state.list.filter((item) => { 66 | return resultItemList.find((x) => x.id === item.id) 67 | }) 68 | }, [resultList, resultItemList]) 69 | 70 | // id set 71 | const resultIdSet = useMemo(() => { 72 | return new Set(resultList.map((x) => x.id)) 73 | }, [resultList]) 74 | 75 | const [disableDropOnTrash, setDisableDropOnTrash] = useState(true) 76 | 77 | const onDragStart = useMemoizedFn((start) => { 78 | const droppableId = start.source.droppableId 79 | if (droppableId === 'result-list') { 80 | setDisableDropOnTrash(false) 81 | } 82 | }) 83 | 84 | const onDragEnd = useMemoizedFn((result, provided) => { 85 | setDisableDropOnTrash(true) 86 | 87 | // console.log(result) 88 | // {reason, draggableId, type} 89 | const { source, destination } = result 90 | if (!destination || !destination.droppableId) return 91 | 92 | // left -> trash 93 | if (source.droppableId === 'result-list' && destination.droppableId === 'trash') { 94 | const delIndex = source.index 95 | state.list.splice(delIndex, 1) // remove 96 | return 97 | } 98 | 99 | let addItem: ConfigItem | undefined 100 | const modifyActions: Array<(list: ConfigItem[]) => void> = [] 101 | 102 | // right -> left 103 | if (source.droppableId === 'rule-source-list') { 104 | const id = ruleSourceList[source.index].id 105 | addItem = { type: 'rule', id } 106 | } 107 | if (source.droppableId === 'subscribe-source-list') { 108 | const id = subscribeSourceList[source.index].id 109 | addItem = { type: 'subscribe', id } 110 | } 111 | 112 | // left 自己排序 113 | if (source.droppableId === 'result-list') { 114 | addItem = resultList[source.index] 115 | modifyActions.push((l) => l.splice(source.index, 1)) // remove source 116 | } 117 | 118 | if (!addItem) { 119 | console.log('no item, result =', result) 120 | return 121 | } 122 | 123 | const newindex = destination.index 124 | for (const action of modifyActions) { 125 | action(state.list) 126 | } 127 | state.list.splice(newindex, 0, addItem!) 128 | }) 129 | 130 | return ( 131 |
132 | 133 |
134 |
135 | 当前配置 136 | 140 |
  • 从右侧拖拽订阅源 和 配置源到此处使用订阅或配置
  • 141 |
  • 拖拽到使用中的配置到垃圾桶删除
  • 142 | 143 | } 144 | > 145 | 146 |
    147 |
    148 | 149 | {(provided, snapshot) => ( 150 |
    151 |
    152 | {resultItemList.map((item, index) => { 153 | return 154 | })} 155 | {provided.placeholder} 156 |
    157 |
    158 | )} 159 |
    160 |
    161 | 162 |
    163 | 164 | {(provided, snapshot) => ( 165 |
    170 |
    171 |
    垃圾桶
    172 |
    173 | {provided.placeholder} 174 |
    175 | )} 176 |
    177 | 178 |
    可用订阅
    179 | 180 | {(provided, snapshot) => ( 181 |
    182 |
    183 | {provided.placeholder} 184 | {subscribeSourceList.map((item, index) => { 185 | return ( 186 | 193 | ) 194 | })} 195 |
    196 |
    197 | )} 198 |
    199 | 200 |
    可用配置
    201 | 202 | {(provided, snapshot) => ( 203 |
    204 |
    205 | {provided.placeholder} 206 | {ruleSourceList.map((item, index) => { 207 | return ( 208 | 215 | ) 216 | })} 217 |
    218 |
    219 | )} 220 |
    221 |
    222 |
    223 |
    224 | ) 225 | } 226 | 227 | interface SourceProps { 228 | item: any // TODO: rm any 229 | type: 'source' | 'result' 230 | isDragDisabled?: boolean 231 | index: number 232 | } 233 | 234 | const Source = ({ item, type, isDragDisabled, index }: SourceProps) => { 235 | const { text, id } = item 236 | 237 | // toggle 238 | const { list } = useSnapshot(state) 239 | const itemInConfigList = useMemo(() => { 240 | return list.find((x) => x.id === id) 241 | }, [list]) 242 | const toggleEnabled = !itemInConfigList?.disabled 243 | 244 | return ( 245 | 246 | {(provided, snapshot) => ( 247 |
    253 | {type === 'result' && ( 254 | { 260 | const itemInConfigList = state.list.find((x) => x.id === id) 261 | if (!itemInConfigList) return 262 | itemInConfigList.disabled = !v 263 | }} 264 | /> 265 | )} 266 |
    267 | {text} 268 | {item.tooltip}
    273 | } 274 | > 275 | 276 | 277 |
    278 | 279 | )} 280 |
    281 | ) 282 | } 283 | --------------------------------------------------------------------------------